026*:冷热启动优化、二进制重排、clang插桩(Header、Load Commands 、segment)(main函数前、main函数后)重签名 、ASLR、(PageFault 、System Trace、order文件)-fsanitize-coverage=func,trace-pc-guard Dl_info
问题
1:(虚拟内存、ASLR)(PE、ELF、Mach-O)
2:(Header、Load Commands 、segment)
3:Header(cputype、filetype)
4:Load Commands(动态链接器的位置、程序的入口、依赖库的信息、代码的位置、符号表的位置)
5:(main函数前、main函数后)重签名 、ASLR、
6:(PageFault 、System Trace、order文件)
7:-fsanitize-coverage=func,trace-pc-guard Dl_info
目录
1:基本概念
2:启动优化
3:二进制重排
预备
正文
一:基本概念
1:虚拟内存 & 物理内存
早期的数据访问是直接通过物理地址访问
的,这种方式有以下两个问题:
-
1、内存不够用
-
2、内存数据的安全问题
内存不够用的方案:虚拟内存
针对问题1,我们在进程和物理内存之间增加一个中间层
,这个中间层就是所谓的虚拟内存
,主要用于解决当多个进程同时存在时,对物理内存的管理。提高了CPU的利用率,使多个进程可以同时、按需加载
。所以虚拟内存其本质就是一张虚拟地址和物理地址对应关系的映射表
-
每个进程都有一个独立的
虚拟内存
,其地址都是从0开始
,大小是4G固定的,每个虚拟内存又会划分为一个一个的页
(页的大小在iOS中是16K,其他的是4K
),每次加载都是以页为单位加载的,进程间是无法互相访问的,保证了进程间数据的安全性。 -
一个进程中,只有部分功能是活跃的,所以只需要
将进程中活跃的部分放入物理内存
,避免物理内存的浪费 -
当CPU需要访问数据时,首先是访问虚拟内存,然后通过虚拟内存去寻址,即可以理解为在表中找对应的物理地址,然后对相应的物理地址进行访问
-
如果在访问时,虚拟地址的内容未加载到物理内存,会发生
缺页异常(pagefault)
,将当前进程阻塞掉,此时需要先将数据载入到物理内存,然后再寻址,进行读取。这样就避免了内存浪费
如下图所示,虚拟内存与物理内存间的关系
内存数据的安全问题:ASLR技术
在上面解释的虚拟内存中,我们提到了虚拟内存的起始地址与大小都是固定的,这意味着,当我们访问时,其数据的地址也是固定的,这会导致我们的数据非常容易被破解,为了解决这个问题,所以苹果为了解决这个问题,在iOS4.3开始引入了ASLR
技术。
ASLR的概念:(Address Space Layout Randomization ) 地址空间配置随机加载
,是一种针对缓冲区溢出
的安全保护技术
,通过对堆、栈、共享库映射等线性区布局的随机化,通过增加攻击者预测目的地址的难度,防止攻击者直接定位攻击代码位置,达到阻止溢出攻击的目的的一种技术。
其目的的通过利用随机方式配置数据地址空间
,使某些敏感数据(例如APP登录注册、支付相关代码)配置到一个恶意程序无法事先获知的地址,令攻击者难以进行攻击。
由于ASLR的存在,导致可执行文件和动态链接库在虚拟内存中的加载地址每次启动都不固定
,所以需要在编译时来修复镜像中的资源指针,来指向正确的地址。即正确的内存地址 = ASLR地址 + 偏移值
2:可执行文件
不同的操作系统,其可执行文件的格式也不同。系统内核将可执行文件读取到内存,然后根据可执行文件的头签名(magic
魔数)判断二进制文件的格式PE、ELF、Mach-O
这三种可执行文件格式都是COFF
(Command file format)格式的变种,COFF的主要贡献是目标文件里面引入了“段”的机制
,不同的目标文件可以拥有不同数量和不同类型的“段”。2.1:通用二进制文件
因为不同CPU平台支持的指令不同,比如arm64
和x86
,苹果中的通用二进制格式就是将多种架构的Mach-O文件打包在一起
,然后系统根据自己的CPU平台,选择合适的Mach-O,所以通用二进制格式
也被称为胖二进制格式
,如下图所示
<mach-o/fat.h>中
,可以在下载xnu,然后根据 xnu -> EXTERNAL_HEADERS ->mach-o
中找到该文件,通用二进制文件开始的Fat Header
是fat_header
结构体,而Fat Archs是表示通用二进制文件中有多少个Mach-O,单个Mach-O的描述是通过fat_arch
结构体。两个结构体的定义如下:/* - magic:可以让系统内核读取该文件时知道是通用二进制文件 - nfat_arch:表明下面有多个fat_arch结构体,即通用二进制文件包含多少个Mach-O */ struct fat_header { uint32_t magic; /* FAT_MAGIC */ uint32_t nfat_arch; /* number of structs that follow */ }; /* fat_arch是描述Mach-O - cputype 和 cpusubtype:说明Mach-O适用的平台 - offset(偏移)、size(大小)、align(页对齐)描述了Mach-O二进制位于通用二进制文件的位置 */ struct fat_arch { cpu_type_t cputype; /* cpu specifier (int) */ cpu_subtype_t cpusubtype; /* machine specifier (int) */ uint32_t offset; /* file offset to this object file */ uint32_t size; /* size of this object file */ uint32_t align; /* alignment as a power of 2 */ };
所以,综上所述,
-
通用二进制文件是苹果公司提出的一种新的二进制文件的存储结构,可以
同时存储多种架构的二进制指令
,使CPU在读取该二进制文件时可以自动检测并选用合适的架构,以最理想的方式进行读取 -
由于通用二进制文件会同时存储多种架构,所以比单一架构的二进制文件大很多,会占用大量的磁盘空间,但由于系统会自动选择最合适的,不相关的架构代码不会占用内存空间,且
执行效率高
了 -
还可以通过指令来进行Mach-O的合并与拆分
-
查看当前Mach-O的架构:
lipo -info MachO文件
-
合并:
lipo -create MachO1 MachO2 -output 输出文件路径
-
拆分:
lipo MachO文件 –thin 架构 –output 输出文件路径
-
2.2:Mach-O文件
Mach-O
文件是Mach Object
文件格式的缩写,它是用于可执行文件、动态库、目标代码的文件格式。作为a.out
格式的替代,Mach-O
格式提供了更强的扩展性,以及更快的符号表信息访问速度
熟悉Mach-O文件格式,有助于更好的理解苹果底层的运行机制,更好的掌握dyld加载Mach-O的步骤。
查看Mach-O文件
如果想要查看具体的Mach-O文件信息,可以通过以下两种方式,推荐使用第二种方式,更直观
【方式一】otool终端命令:otool -l Mach-O文件名
【方式二】 MachOView
工具(推荐):将Mach-O可执行文件拖动到MachOView
工具打开
2.3:Mach-O文件格式
对于OS X 和iOS来说,Mach-O是其可执行文件的格式,主要包括以下几种文件类型
Executable
:可执行文件Dylib
:动态链接库Bundle
:无法被链接的动态库,只能在运行时使用dlopen加载Image
:指的是Executable、Dylib和Bundle的一种Framework
:包含Dylib、资源文件和头文件的集合
下面图示是Mach-O 镜像文件格式
Mach-O
文件主要分为三大部分:
-
Header Mach-O头部
:主要是Mach-O的cpu架构,文件类型以及加载命令等信息 -
Load Commands 加载命令
:描述了文件中数据的具体组织结构,不同的数据类型使用不同的加载命令表示 -
Data 数据
:数据中的每个段(segment)的数据都保存在这里,段的概念与ELF文件中段的概念类似。每个段都有一个或多个部分,它们放置了具体的数据与代码,主要包含代码,数据,例如符号表,动态符号表等等
Mach-O的Header
包含了整个Mach-O文件的关键信息
,使得CPU能快速知道Mac-O的基本信息,其在Mach.h
(路径同前文的fat.h一致)针对32
位和64
位架构的cpu,分别使用了mach_header
和mach_header_64
结构体来描述Mach-O头部
。mach_header
是连接器加载时最先读取的内容,决定了一些基础架构、系统类型、指令条数等信息,这里查看64位架构的mach_header_64
结构体定义,相比于32
位架构的mach_header
,只是多了一个reserved
保留字段,
/* - magic:0xfeedface(32位) 0xfeedfacf(64位),系统内核用来判断是否是mach-o格式 - cputype:CPU类型,比如ARM - cpusubtype:CPU的具体类型,例如arm64、armv7 - filetype:由于可执行文件、目标文件、静态库和动态库等都是mach-o格式,所以需要filetype来说明mach-o文件是属于哪种文件 - ncmds:sizeofcmds:LoadCommands加载命令的条数(加载命令紧跟header之后) - sizeofcmds:LoadCommands加载命令的大小 - flags:标志位标识二进制文件支持的功能,主要是和系统加载、链接有关 - reserved:保留字段 */ struct mach_header_64 { uint32_t magic; /* mach magic number identifier */ cpu_type_t cputype; /* cpu specifier */ cpu_subtype_t cpusubtype; /* machine specifier */ uint32_t filetype; /* type of file */ uint32_t ncmds; /* number of load commands */ uint32_t sizeofcmds; /* the size of all the load commands */ uint32_t flags; /* flags */ uint32_t reserved; /* reserved */ };
其中filetype
主要记录Mach-O的文件类型,常用的有以下几种
#define MH_OBJECT 0x1 /* 目标文件*/ #define MH_EXECUTE 0x2 /* 可执行文件*/ #define MH_DYLIB 0x6 /* 动态库*/ #define MH_DYLINKER 0x7 /* 动态链接器*/ #define MH_DSYM 0xa /* 存储二进制文件符号信息,用于debug分析*/
相对应的,Header在MachOView
中的展示如下
2.3.2:Load Commands
在Mach-O文件中,Load Commands
主要是用于加载指令
,其大小和数目在Header中已经被提供,其在Mach.h
中的定义如下
/* load_command用于加载指令 - cmd 加载命令的类型 - cmdsize 加载命令的大小 */ struct load_command { uint32_t cmd; /* type of load command */ uint32_t cmdsize; /* total size of command in bytes */ };
我们在MachOView
中查看Load Commands,其中记录了很多信息,例如动态链接器的位置、程序的入口、依赖库的信息、代码的位置、符号表的位置
等等,如下所示
其中LC_SEGMENT_64
的类型segment_command_64
定义如下
/* segment_command 段加载命令 - cmd:表示加载命令类型, - cmdsize:表示加载命令大小(还包括了紧跟其后的nsects个section的大小) - segname:16个字节的段名字 - vmaddr:段的虚拟内存起始地址 - vmsize:段的虚拟内存大小 - fileoff:段在文件中的偏移量 - filesize:段在文件中的大小 - maxprot:段页面所需要的最高内存保护(4 = r,2 = w,1 = x) - initprot:段页面初始的内存保护 - nsects:段中section数量 - flags:其他杂项标志位 - 从fileoff(偏移)处,取filesize字节的二进制数据,放到内存的vmaddr处的vmsize字节。(fileoff处到filesize字节的二进制数据,就是“段”) - 每一个段的权限相同(或者说,编译时候,编译器把相同权限的数据放在一起,成为段),其权限根据initprot初始化。initprot指定了如何通过读/写/执行位初始化页面的保护级别 - 段的保护设置可以动态改变,但是不能超过maxprot中指定的值(在iOS中,+x和+w是互斥的) */ struct segment_command_64 { /* for 64-bit architectures */ uint32_t cmd; /* LC_SEGMENT_64 */ uint32_t cmdsize; /* includes sizeof section_64 structs */ char segname[16]; /* segment name */ uint64_t vmaddr; /* memory address of this segment */ uint64_t vmsize; /* memory size of this segment */ uint64_t fileoff; /* file offset of this segment */ uint64_t filesize; /* amount to map from the file */ vm_prot_t maxprot; /* maximum VM protection */ vm_prot_t initprot; /* initial VM protection */ uint32_t nsects; /* number of sections in segment */ uint32_t flags; /* flags */ };
2.3.3:Data
Load Commands后就是Data
区域,这个区域存储了具体的只读、可读写代码
,例如方法、符号表、字符表、代码数据、连接器所需的数据(重定向、符号绑定等)。主要是存储具体的数据。其中大多数的Mach-O文件均包含以下三个段:
__TEXT 代码段
:只读,包括函数,和只读的字符串__DATA 数据段
:读写,包括可读写的全局变量等__LINKEDIT
: __LINKEDIT包含了方法和变量的元数据(位置,偏移量),以及代码签名等信息。
在Data
区中,Section
占了很大的比例,Section
在Mach.h
中是以结构体section_64
(在arm64架构下)表示,其定义如下
/* Section节在MachO中集中体现在TEXT和DATA两段里. - sectname:当前section的名称 - segname:section所在的segment名称 - addr:内存中起始位置 - size:section大小 - offset:section的文件偏移 - align:字节大小对齐 - reloff:重定位入口的文件偏移 - nreloc:重定位入口数量 - flags:标志,section的类型和属性 - reserved1:保留(用于偏移量或索引) - reserved2:保留(用于count或sizeof) - reserved3:保留 */ struct section_64 { /* for 64-bit architectures */ char sectname[16]; /* name of this section */ char segname[16]; /* segment this section goes in */ uint64_t addr; /* memory address of this section */ uint64_t size; /* size in bytes of this section */ uint32_t offset; /* file offset of this section */ uint32_t align; /* section alignment (power of 2) */ uint32_t reloff; /* file offset of relocation entries */ uint32_t nreloc; /* number of relocation entries */ uint32_t flags; /* flags (section type and attributes)*/ uint32_t reserved1; /* reserved (for offset or index) */ uint32_t reserved2; /* reserved (for count or sizeof) */ uint32_t reserved3; /* reserved */ };
Section
在MachOView
中可以看出,主要集中体现在TEXT
和DATA
两段里,如下所示
section - __TEXT | 说明 |
---|---|
__TEXT.__text |
主程序代码 |
__TEXT.__cstring |
C语言字符串 |
__TEXT.__const |
const 关键字修饰的常量 |
__TEXT.__stubs |
用于 Stub 的占位代码,很多地方称之为桩代码 |
__TEXT.__stubs_helper |
当 Stub 无法找到真正的符号地址后的最终指向 |
__TEXT.__objc_methname |
Objective-C 方法名称 |
__TEXT.__objc_methtype |
Objective-C 方法类型 |
__TEXT.__objc_classname |
Objective-C 类名称 |
section - __DATA | 说明 |
---|---|
__DATA.__data |
初始化过的可变数据 |
__DATA.__la_symbol_ptr |
lazy binding 的指针表,表中的指针一开始都指向 __stub_helper |
__DATA.nl_symbol_ptr |
非 lazy binding 的指针表,每个表项中的指针都指向一个在装载过程中,被动态链机器搜索完成的符号 |
__DATA.__const |
没有初始化过的常量 |
__DATA.__cfstring |
程序中使用的 Core Foundation 字符串(CFStringRefs) |
__DATA.__bss |
BSS,存放为初始化的全局变量,即常说的静态内存分配 |
__DATA.__common |
没有初始化过的符号声明 |
__DATA.__objc_classlist |
Objective-C 类列表 |
__DATA.__objc_protolist |
Objective-C 原型 |
__DATA.__objc_imginfo |
Objective-C 镜像信息 |
__DATA.__objc_selfrefs |
Objective-C self 引用 |
__DATA.__objc_protorefs |
Objective-C 原型引用 |
__DATA.__objc_superrefs |
Objective-C 超类引用 |
二:启动优化
1. 冷启动和热启动
首次启动
应用、kill
应用后重新打开
应用、应用置于后台
隔一段时间再返回前台
等情况,都是应用
的启动
。
有时启动
很快
,有时启动
很慢
。这是冷启动
和热启动
的原因:
冷启动是指内存中不包含该应用程序相关的数据,必须要从磁盘载入到内存中的启动过程。
注意:重新打开 APP, 不一定就是冷启动。
- 当内存不足,APP被系统自动杀死后,再启动就是冷启动。
- 如果在重新打开 APP 之前,APP 的相关数据还存储在内存中,这时再打开 APP,就是热启动
- 冷启动与热启动是由系统决定的,我们无法决定。
- 当然设备重启以后,第一次打开 APP 的过程,一定是冷启动。
2. 启动性能检测和分析
测试APP启动,分为两个阶段:
- main函数前:
dyld
负责的启动流程
系统处理,我们从
dyld应用加载
的流程来优化
。(借助系统工具
分析耗时)
- main函数后:
开发者
自己的业务代码
通过
检测业务流程
来优化
(main函数
打个时间点
、第一个页面
渲染完成打个时间点
。测算耗时)
2.1 main函数前
Edit Scheme -> Run -> Arguments ->Environment Variables
点击+添加环境变量 DYLD_PRINT_STATISTICS
设为 1
),然后运行,以下是iPhone7p正常启动的pre-main时间(以WeChat为例)1:新建APP
文件夹,放入砸壳后
的包、
2:加入appSign.sh
重签名脚本:
# ${SRCROOT} 它是工程文件所在的目录 TEMP_PATH="${SRCROOT}/Temp" #资源文件夹,我们提前在工程目录下新建一个APP文件夹,里面放ipa包 ASSETS_PATH="${SRCROOT}/APP" #目标ipa包路径 TARGET_IPA_PATH="${ASSETS_PATH}/*.ipa" #清空Temp文件夹 rm -rf "${SRCROOT}/Temp" mkdir -p "${SRCROOT}/Temp" #---------------------------------------- # 1. 解压IPA到Temp下 unzip -oqq "$TARGET_IPA_PATH" -d "$TEMP_PATH" # 拿到解压的临时的APP的路径 TEMP_APP_PATH=$(set -- "$TEMP_PATH/Payload/"*.app;echo "$1") # echo "路径是:$TEMP_APP_PATH" #---------------------------------------- # 2. 将解压出来的.app拷贝进入工程下 # BUILT_PRODUCTS_DIR 工程生成的APP包的路径 # TARGET_NAME target名称 TARGET_APP_PATH="$BUILT_PRODUCTS_DIR/$TARGET_NAME.app" echo "app路径:$TARGET_APP_PATH" rm -rf "$TARGET_APP_PATH" mkdir -p "$TARGET_APP_PATH" cp -rf "$TEMP_APP_PATH/" "$TARGET_APP_PATH" #---------------------------------------- # 3. 删除extension和WatchAPP.个人证书没法签名Extention rm -rf "$TARGET_APP_PATH/PlugIns" rm -rf "$TARGET_APP_PATH/Watch" #---------------------------------------- # 4. 更新info.plist文件 CFBundleIdentifier # 设置:"Set : KEY Value" "目标文件路径" /usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier >$PRODUCT_BUNDLE_IDENTIFIER" "$TARGET_APP_PATH/Info.plist" #---------------------------------------- # 5. 给MachO文件上执行权限 # 拿到MachO文件的路径 APP_BINARY=`plutil -convert xml1 -o - $TARGET_APP_PATH/Info.plist|grep -A1 Exec|tail -n1|cut -f2 -d\>|cut -f1 -d\<` #上可执行权限 chmod +x "$TARGET_APP_PATH/$APP_BINARY" #---------------------------------------- # 6. 重签名第三方 FrameWorks TARGET_APP_FRAMEWORKS_PATH="$TARGET_APP_PATH/Frameworks" if [ -d "$TARGET_APP_FRAMEWORKS_PATH" ]; then for FRAMEWORK in "$TARGET_APP_FRAMEWORKS_PATH/"* do #签名 /usr/bin/codesign --force --sign "$EXPANDED_CODE_SIGN_IDENTITY" "$FRAMEWORK" done fi #注入 #yololib "$TARGET_APP_PATH/$APP_BINARY" >"Frameworks/HankHook.framework/HankHook"
3:Demo
工程添加
脚本指令./appSign.sh
真机运行
后,可看到:
Total pre-main time: 1.2 seconds (100.0%) dylib loading time: 326.38 milliseconds (25.4%) rebase/binding time: 146.54 milliseconds (11.4%) ObjC setup time: 40.49 milliseconds (3.1%) initializer time: 767.04 milliseconds (59.9%) slowest intializers : libSystem.B.dylib : 6.86 milliseconds (0.5%) libMainThreadChecker.dylib : 38.26 milliseconds (2.9%) libglInterpose.dylib : 447.73 milliseconds (34.9%) marsbridgenetwork : 48.86 milliseconds (3.8%) mars : 30.85 milliseconds (2.4%) 砸壳应用 : 212.00 milliseconds (16.5%)
2.2 分析DYLD耗时元素:
Total pre-main time: main函数前
的总耗时
dylib loading time
: dylib库
的加载耗时
(官方建议,动态库不超过6个
)
此应用的Frameworks
:
rebase/binding time
: 重定向
和绑定
操作的耗时
MachO
中image镜像
到内存中
)[binding绑定]:
MachO
中每个文件使用其他库
的符号
时,绑定库名
和地址
安全
考虑,编译时
和运行时
地址不一样。使用了ASLR
(Address space layout randomization)地址空间配置随机加载
,每次载入内存
后,需要将原地址
加上ASLR随机偏移值
来进行内存读取
。 具体原因,下面
分析虚拟内存
与物理内存
时,就清楚
了ObjC setup time
: OC类
的注册耗时
(OC类越多,越耗时)
swift
没有OC类
,所以在这一步有优越性
。
initializer time
:初始化耗时(load非懒加载类和c++构造函数的耗时)
slowest intializers: 最慢
的启动对象
:
libSystem.B.dylib
: 系统库libMainThreadChecker.dylib
: 系统库libglInterpose.dylib
: 系统库(调试使用的,不影响)砸壳应用
:自己的APP耗时
2.2 main函数后
main函数阶段的优化建议主要有以下几点:
在main函数之后的didFinishLaunching
方法中,主要是执行了各种业务,有很多并不是必须在这里立即执行的,这种业务我们可以采取延迟加载,防止影响启动时间。
1:业务层面:
-
减少启动初始化的流程
,能懒加载的懒加载,能延迟的延迟,能放后台初始化的放后台,尽量不要占用主线程的启动时间 -
优化代码逻辑,
去除非必须的代码逻辑
,减少每个流程的消耗时间 -
启动阶段能
使用多线程
来初始化的,就使用多线程 -
尽量
使用纯代码
来进行UI框架的搭建,尤其是主UI框架,例如UITabBarController。尽量避免使用Xib或者SB,相比纯代码而言,这种更耗时 -
删除废弃类、方法
2:技术层面
- 1.
二进制重排
(重排的是编译阶段
的文件顺序
,减少
启动时刻,硬盘
到内存
的操
作次数
)
三:二进制重排
导致Page Fault次数过多的根本原因是启动时刻需要调用的方法,处于不同的Page导致的
。因此,我们的优化思路就是:将所有启动时刻需要调用的方法,排列在一起,即放在一个页中,这样就从多个Page Fault变成了一个Page Fault
。这就是二进制重排的核心原理
1:二进制重排原理
- 应用
启动前
,页表
是空
的,每一页
都是PageFault
(页缺省),启动时用到的
每一页都需要
cpu从硬盘读取
到物理内存
中,虽然加载一页
的耗时没什么感觉
。但如果同时
加载几百页
,这个耗时就得考虑了。
本节我们研究的就是APP启动优化
,所以这里也是一个优化点
。
- 优化核心:
减少
在启动时
需要加载
的页数
iOS
中每一页
是16K
大小,但是16K中
,可能真正
在启动时刻需要
用到的,可能不到1K
。但
是启动需要
访问到这1K
数据,不得不
把整页
都加载
。- 我们的
二进制重排
,就是为了把
启动用到的
这些数据
,整合
到一起,然后再
进行内存分页
。这样启动用到的
数据都在前几页
中了。启动时
,只需
要加载几页数据
就可以了。
1.1 二进制重排
中的二进制
二进制: 只有0
和1
的两个数的数制
。是机器识别
的进制
。
-
此处
的二进制
,主要是指
我们代码文件
中的函数
,编译后
变成的机器识别符号
,再转换
的二进制
文件。 -
所以二进制重排,
重排
的是代码文件
和函数
的顺序
。只加载用到的数据。用到的页。
1.2 二进制数据顺序
创建个Demo
项目,加入测试代码:
#import "ViewController.h" @interface ViewController () @end @implementation ViewController void test1() { printf("1"); } void test2() { printf("2"); } - (void)viewDidLoad { [super viewDidLoad]; printf("viewDidLoad"); test1(); } +(void)load { printf("load"); test2(); } @end
在Build Settings
中搜索link Map
,设置Write Link Map File
为YES
:
Command + B
编译后,右键 Show In Finder
打开包文件夹
:沿路径
找到并打开Demo-LinkMap-normal-x86_64.txt
文件:
函数顺序:(书写顺序)
文件顺序:(加入顺序)
在Build Setting -> Write Link Map File
设置为YES
link map
文件,如下所示,可以发现 类中函数的加载顺序是从上到下
的,而文件
的顺序是根据Build Phases -> Compile Sources
中的顺序加载的总结
- 二进制的排列顺序:先
文件
按照加载顺序
排列,文件内部
按照函数
书写顺序从上到下
排列
我们要做的,就是把启动
会用到
的函数
排列在一起
2.PageFault检测
1:连接真机
,运行自己项目
,打开Instruments
检测工具:
System Trace
:3:选择真机
,选择自己的项目
,点击
第一个按钮运行
,等APP启动后
,点击
第一个按钮停止
。
4:选择
自己项目
,选中主线程
,选择虚拟内存
,查看File Backed Page In
(就是PageFault缺省页):
可以看到这里启动
加载了1783页
,总耗时278毫秒
,平均耗时156微秒
。
(多试几次
,可能
物理内存中存在
已有数据
,加载页数
会少一些
。完全冷启动
的话,加载页数
应该会更多
,耗时更明显)
3.体验二进制重排
二进制重排,关键是order
文件
前面讲objc源码时,会在工程中看到order
文件:
打开.order
文件,可以看到内部都是排序好
的函数符号
。
这是因为苹果
自己的库
,也
都进行了二进制重排
。
- 我们打开创建的
Demo
项目,我想把排序改成load
->test1
->ViewDidAppear
->main
。
1:在Demo
项目根目录
创建一个.order文件
2:在ht.order
文件中手动
顺序写入函数
(还写了个不存在的hello函数)
3:在Build Settings
中搜索order file
,加入./ht.order
4:Command + B
编译后,再次去查看link map文件
:
- 发现
order文件
中不存在的函数
(hello),编译器
会直接跳过
。 - 其他
函数符号
,完全按照我们order
顺序排列。 order
中没有的函数
,按照默认顺序
接在order
函数后面
。
4:二进制重排实践
下面,我们来进行具体的实践,首先理解几个名词
Link Map
Linkmap是iOS编译过程的中间产物,记录了二进制文件的布局
,需要在Xcode的Build Settings
里开启Write Link Map File
,Link Map主要包含三部分:
-
Object Files
生成二进制用到的link单元的路径和文件编号 -
Sections
记录Mach-O每个Segment/section的地址范围 -
Symbols
按顺序记录每个符号的地址范围
ld
ld
是Xcode使用的链接器,有一个参数order_file
,我们可以通过在Build Settings -> Order File
配置一个后缀为order的文件路径。在这个order文件中,将所需要的符号按照顺序写在里面,在项目编译时,会按照这个文件的顺序进行加载,以此来达到我们的优化
所以二进制重排的本质就是对启动加载的符号进行重新排列
。
到目前为止,原理我们基本弄清楚了,如果项目比较小,完全可以自定义一个order文件,将方法的顺序手动添加,但是如果项目较大,涉及的方法特别多,此时我们如何获取启动运行的函数呢?有以下几种思路
-
1、hook objc_msgSend
:我们知道,函数的本质是发送消息,在底层都会来到objc_msgSend
,但是由于objc_msgSend的参数是可变的,需要通过汇编
获取,对开发人员要求较高。而且也只能拿到OC
和 swift中@objc
后的方法 -
2、静态扫描
:扫描Mach-O
特定段和节里面所存储的符号以及函数数据 -
3、Clang插桩
:即批量hook,可以实现100%符号覆盖,即完全获取swift、OC、C、block
函数
四:clang插桩
官方介绍: https://clang.llvm.org/docs/SanitizerCoverage.html#tracing-pcs
-
官方提供了
LLVM
的代码覆盖监测工具
。其中包含了Tracing PCs
(追踪PC)。 -
我们创建
TranceDemo
项目,按照官方
给的示例
,来尝试开发
1 添加trace
-
按照官方描述,可以加入
跟踪代码
,并给出了回调函数
。
打开TranceDemo
, Build Settings
中搜索Other C
,加入-fsanitize-coverage=trace-pc-guard,需要实现两个方法
下面实现这两个函数
// trace-pc-guard-cb.cc #include <stdint.h> #include <stdio.h> #include <sanitizer/coverage_interface.h> // This callback is inserted by the compiler as a module constructor // into every DSO. 'start' and 'stop' correspond to the // beginning and end of the section with the guards for the entire // binary (executable or DSO). The callback will be called at least // once per DSO and may be called multiple times with the same parameters. extern "C" 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. } // 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); extern "C" void __sanitizer_cov_trace_pc_guard(uint32_t *guard) { if (!*guard) return; // Duplicate the guard check. // 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. void *PC = __builtin_return_address(0); char PcDescr[1024]; // This function is a part of the sanitizer run-time. // To use it, link with AddressSanitizer or other sanitizer. __sanitizer_symbolize_pc(PC, "%p %F %L", PcDescr, sizeof(PcDescr)); printf("guard: %p %x PC %s\n", guard, *guard, PcDescr); }
2:复制
项目案例
,粘贴
到项目的ViewController
中,去除注释
和extern 声明
,加入几个测试函数
:
#import "ViewController.h" #include <stdint.h> #include <stdio.h> #include <sanitizer/coverage_interface.h> @interface ViewController () @end @implementation ViewController +(void)load {} void (^block)(void) = ^{ printf("123"); }; void test() { block(); } - (void)viewDidLoad { [super viewDidLoad]; } 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; // Duplicate the guard check. 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); } - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event { test(); } @end
Command+B
编译,发现找不到
符号__sanitizer_symbolize_pc
(需要导入库),我们暂时把这一行注释掉
3:运行程序
touchBegin
-> __sanitizer_cov_trace_pc_guard
->test
-> __sanitizer_cov_trace_pc_guard
->block
-> __sanitizer_cov_trace_pc_guard
确实
每个函数
在触发
时,都调用了__sanitizer_cov_trace_pc_guard
函数。原因:
- 只要在
Other C Flags
处加
入标记
,开启了trace
功能。LLVM
会在每个函数边缘
(开始位置),插入
一行调用__sanitizer_cov_trace_pc_guard
的代码。编译期
就插入
了。所以可以100%覆盖。
- 以上,就是
Clang插桩
。插桩
操作完成
后,我们需要获取
所有函数符号
、存储
并导出order文件
。
3. 获取函数符号
__builtin_return_address
: return的地址。
函数
return
,是返回到上一层
的函数
。
- 通过
return
的地址,拿到的是上一层级
的函数信息
。- 参数:
0
: 表示当前函数的上一层
。1
:是上一层
的上一层
地址。
- 导入
#import <dlfcn.h>
,通过Dl_info
拿到函数信息:
typedef struct dl_info { const char *dli_fname; /* 文件地址*/ void *dli_fbase; /* 起始地址(machO模块的虚拟地址)*/ const char *dli_sname; /* 符号名称 */ void *dli_saddr; /* 内存真实地址(偏移后的真实物理地址) */ } Dl_info;
- 在
__sanitizer_cov_trace_pc_guard
函数加入代码:
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) { if(!*guard) return; void *PC = __builtin_return_address(0); //0 当前函数地址, 1 上一层级函数地址 Dl_info info; // 声明对象 dladdr(PC, &info); // 读取PC地址,赋值给info printf("dli_fname:%s \n dli_fbase:%p \n dli_sname:%s \n dli_saddr:%p \n ", info.dli_fname, info.dli_fbase, info.dli_sname, info.dli_saddr); }
- 运行程序,可以看到:
dli_fname
: 文件地址dli_fbase
: 起始地址(machO模块的虚拟地址)dli_sname
: 符号名称dli_saddr
: 内存真实地址(偏移后的真实物理内存地址)
- 此时,我们
成功
拿到函数符号
。
4.存储符号
注意:__sanitizer_cov_trace_pc_guard
函数是在多线程
环境下,所以需要注意写入安全
写入安全
,就是上锁
。此处我使用OSAtomic原子锁
。存储方式
,也有很多种, 此处我使用队列
进行存储
。
- 导入
#include <libkern/OSAtomic.h>
原子头文件,创建原子队列
,定义节点结构体
:
#import "ViewController.h" #include <stdint.h> #include <stdio.h> #include <sanitizer/coverage_interface.h> #import <dlfcn.h> #import <libkern/OSAtomic.h> // 原子操作 @interface ViewController () @end @implementation ViewController +(void)load {} void (^block)(void) = ^{ printf("123"); }; void test666() { block(); } - (void)viewDidLoad { [super viewDidLoad]; } // 定义原子队列 static OSQueueHead symbolList = OS_ATOMIC_QUEUE_INIT; // 原子队列初始化 // 定义符号结构体 typedef struct { void * pc; void * next; }SYNode; 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; } void __sanitizer_cov_trace_pc_guard(uint32_t *guard) { // 这里是多线程,会有资源抢夺。 // 这个会影响load函数,所以需要移除哨兵 // if(!*guard) return; void *PC = __builtin_return_address(0); //0 当前函数地址, 1 上一层级函数地址 Dl_info info; // 声明对象 dladdr(PC, &info); // 读取PC地址,赋值给info // 创建结构体 SYNode * node = malloc(sizeof(SYNode)); // 创建结构体空间 *node = (SYNode){PC, NULL}; // node节点的初始化赋值(pc为当前PC值,NULL为next值) // 加入结构 (offsetof: 按照参数1大小作为偏移值,给到next) // 拿到并赋值 // 拿到symbolList地址,偏移SYNode字节,将node赋值给symbolList最后节点的next指针。 OSAtomicEnqueue(&symbolList, node, offsetof(SYNode, next)); } - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event { // 创建可变数组 NSMutableArray<NSString *> * symbolNames = [NSMutableArray array]; // 每次while循环,都会加入一次hook (__sanitizer_cov_trace_pc_guard) 只要是跳转,就会被block // 直接修改[other c clang]: -fsanitize-coverage=func,trace-pc-guard 指定只有func才加Hook while (1) { // 去除链表 SYNode * node = OSAtomicDequeue(&symbolList, offsetof(SYNode, next)); if(node ==NULL) break; Dl_info info = {0}; // 取出节点的pc,赋值给info dladdr(node->pc, &info); // 释放节点 free(node); // 存名字 NSString *name = @(info.dli_sname); // 三目运算符 写法 BOOL isObjc = [name hasPrefix: @"+["] || [name hasPrefix: @"-["]; NSString * symbolName = isObjc ? name : [NSString stringWithFormat:@"_%@",name]; [symbolNames addObject:symbolName]; } // 反向集合 NSEnumerator * enumerator = [symbolNames reverseObjectEnumerator]; // 创建数组 NSMutableArray * funcs = [NSMutableArray arrayWithCapacity:symbolNames.count]; // 临时变量 NSString * name; // 遍历集合,去重,添加到funcs中 while (name = [enumerator nextObject]) { // 数组中去重添加 if (![funcs containsObject:name]) { [funcs addObject:name]; } } // 移除当前touchesBegan函数 (跟启动无关) [funcs removeObject:[NSString stringWithFormat:@"%s",__FUNCTION__]]; // 数组转字符串 NSString * funcStr = [funcs componentsJoinedByString:@"\n"]; // 文件路径 NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"ht.order"]; // 文件内容 NSData * fielContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding]; // 创建文件 [[NSFileManager defaultManager] createFileAtPath:filePath contents:fielContents attributes:nil]; NSLog(@"%@",funcs); NSLog(@"%@",filePath); NSLog(@"%@",fielContents); } @end
坑点:
-
if(!*guard) return;
需要去掉,会影响+load
的写入
-
while循环
,也会触发__sanitizer_cov_trace_pc_guard
:
【原因】:
- 通过看汇编,可以看到while也触发了
__sanitizer_cov_trace_pc_guard
的跳转。原因是,trace
的触发
,并不是
根据函数
来进行hook
的,而是hook
了每一个跳转(bl)
。 while
也有跳转
,所以进入了死循环
。
【方案】:
Build Settings
的Other C Flags
配置,添加一个func
指定条件:-fsanitize-coverage=func,trace-pc-guard
根据打印路径,查看ht.order
文件,完美!
从真机沙盒中拿到ht.order文件。
复制ht.order
文件,放到根目录
,就完成了。
可以根据上一节的内容,打开
link Map
查看最终
的符号排序
,使用Instruments
检查自己应用的PageFault数量
和耗时
注意
- 【二进制重排
order文件
】需要代码封版后
,再生成
。 (代码还在变动,生成就没意义了)- 【二进制重排
相关代码
】不要写到
自己项目中
去。写个小工具
跑一下,拿到order文件
即可。
注意