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平台支持的指令不同,比如arm64x86,苹果中的通用二进制格式就是将多种架构的Mach-O文件打包在一起,然后系统根据自己的CPU平台,选择合适的Mach-O,所以通用二进制格式也被称为胖二进制格式,如下图所示

通用二进制格式的定义在<mach-o/fat.h>中,可以在下载xnu,然后根据 xnu -> EXTERNAL_HEADERS ->mach-o中找到该文件,通用二进制文件开始的Fat Headerfat_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文件的格式,一个完成的Mach-O文件主要分为三大部分:
  • Header Mach-O头部:主要是Mach-O的cpu架构,文件类型以及加载命令等信息

  • Load Commands 加载命令:描述了文件中数据的具体组织结构,不同的数据类型使用不同的加载命令表示

  • Data 数据:数据中的每个段(segment)的数据都保存在这里,段的概念与ELF文件中段的概念类似。每个段都有一个或多个部分,它们放置了具体的数据与代码,主要包含代码,数据,例如符号表,动态符号表等等

2.3.1:Header

Mach-O的Header包含了整个Mach-O文件的关键信息,使得CPU能快速知道Mac-O的基本信息,其在Mach.h(路径同前文的fat.h一致)针对32位和64位架构的cpu,分别使用了mach_headermach_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占了很大的比例,SectionMach.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 */
};

SectionMachOView中可以看出,主要集中体现在TEXTDATA两段里,如下所示

其中常见的section,主要有以下一些
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, 不一定就是冷启动。

  1. 当内存不足,APP被系统自动杀死后,再启动就是冷启动。
  2. 如果在重新打开 APP 之前,APP 的相关数据还存储在内存中,这时再打开 APP,就是热启动
  3. 冷启动与热启动是由系统决定的,我们无法决定。
  4. 当然设备重启以后,第一次打开 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 timedylib库加载耗时(官方建议,动态库不超过6个

此应用的Frameworks:

 
rebase/binding time: 重定向绑定操作的耗时
[rebase重定向]:从磁盘的MachOimage镜像内存中)
[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 Fault次数过多的根本原因是启动时刻需要调用的方法,处于不同的Page导致的。因此,我们的优化思路就是:将所有启动时刻需要调用的方法,排列在一起,即放在一个页中,这样就从多个Page Fault变成了一个Page Fault。这就是二进制重排的核心原理 

1:二进制重排原理

  • 应用启动前页表的,每一页都是PageFault(页缺省),启动时用到的每一页都需要cpu从硬盘读取物理内存中,虽然加载一页的耗时没什么感觉。但如果同时加载几百页,这个耗时就得考虑了。

本节我们研究的就是APP启动优化,所以这里也是一个优化点

  • 优化核心: 减少启动时需要加载页数
  • iOS每一页16K大小,但是16K中,可能真正在启动时刻需要用到的,可能不到1K。 启动需要访问到这1K数据,不得不整页加载
  • 我们的二进制重排,就是为了启动用到的这些数据整合到一起,然后进行内存分页。这样启动用到的数据都在前几页中了。启动时只需加载几页数据就可以了。

1.1 二进制重排中的二进制

二进制: 只有01的两个数的数制。是机器识别进制

  • 此处二进制,主要是我们代码文件中的函数编译后变成的机器识别符号,再转换二进制文件。

  • 所以二进制重排,重排的是代码文件函数顺序。只加载用到的数据。用到的页

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 FileYES:



Command + B编译后,右键 Show In Finder打开包文件夹
 

沿路径找到并打开Demo-LinkMap-normal-x86_64.txt文件:

 

函数顺序:(书写顺序)

文件顺序:(加入顺序)

Build Setting -> Write Link Map File设置为YES

CMD+B编译demo,然后在对应的路径下查找 link map文件,如下所示,可以发现 类中函数的加载顺序是从上到下的,而文件的顺序是根据Build Phases -> Compile Sources中的顺序加载的

总结

  • 二进制的排列顺序:先文件按照加载顺序排列,文件内部按照函数书写顺序从上到下排列

我们要做的,就是把启动用到函数排列在一起

2.PageFault检测

1:连接真机运行自己项目,打开Instruments检测工具:

 

 
2:选择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

坑点:

  1. if(!*guard) return;需要去掉,会影响+load写入

  2. 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数量耗时

注意

  1. 【二进制重排order文件】需要代码封版后再生成。 (代码还在变动,生成就没意义了)
  2. 【二进制重排相关代码不要写到自己项目中去。写个小工具跑一下,拿到order文件即可。

注意

 

引用

1:iOS-底层原理 32:启动优化(一)基本概念

2:iOS-底层原理 32:启动优化(二)优化建议

3:iOS-底层原理 32:启动优化(三)二进制重排

4:OC底层原理三十三:启动优化(二进制重排)

5:OC底层原理三十四:启动优化(Clang插桩)

6:二十七、iOS冷启动优化 - 二进制重排 & Clang插桩

7:二十六、 启动优化,二进制重排

8:iOS-OC启动优化:clang插桩实现二进制重排

9:iOS 启动优化(上)

10:IOS-启动优化(上)

11:IOS-启动优化(下)

12:二进制重排&优化启动

posted on 2020-12-03 16:18  风zk  阅读(1071)  评论(0编辑  收藏  举报

导航