前序
App启动及框架底层的研究,会以下面一个逻辑分为5篇博客进行讲解:
-
App启动怎么监控?【进阶之路五】
前序
本篇是iOS进阶之路的第四篇,将讲述二进制重排在App启动优化中的作用和效果?如果想要了解iOS启动的相关知识,相信通过几篇博客会加深大家对启动到底做了哪些事情!后期会继续分享更多的干货供大家分析参考点评!!!
在进入二进制重排技术方案之前,可能有很多的准备工作要讲解,大家如果想要more stronger,请耐心读完本篇,会对大家有很大的提升,开讲!!!
基础知识回顾
1.1 存储管理
存储器是计算机系统的重要资源之一。因为任何程序和数据以及各种控制用的数据结构都必须占用一定的存储空间,因此存储管理直接影响系统性能。
存储器有内存【primary storage】和外存【secondary storage】组成。内存是由顺序编址的块组成,块包含相应的物理单元。CPU要通过启动相应的输入输出设备后才能使外存与内存交换信息。
1.2 虚拟内存与物理内存
物理内存是真正的内存,看机器配置的时候,看的就是这个物理内存。是指目前CPU外部地址总线上的寻址物理地址内存的地址,是地址变换的最终结果地址,也就是计算机安装的内存条大小。如果启用了分页机制,那么线性地址会使用页目录和页表中的项变换成物理地址,如果没有启动分页机制,那么线性地址就直接称为物理地址。
虚拟内存是为了满足系统对超出物理内存容量的需求时在外存【如硬盘】上开辟的存储空间。由于虚拟内存其实是放在外存上,因而与物理内存相比读写速度都会变得非常慢。
1.3 进程访问地址
进程访问一个地址,经历的过程如下:
- 每次要访问地址空间上的某一个地址,都首先把地址翻译成实际的物理内存地址;
- 所有进程共享整一块物理内存,每个进程只把自己目前需要的虚拟地址空间映射到物理内存上;
- 进程需要知道哪些地址空间上的数据在物理内存上,哪些不在物理内存上,以及物理内存上的哪一个位置上,就需要通过页表来记录;
- 页表的每一个表项分为两部分,第一部分记录此页是否在物理内存上,第二部分记录物理内存页的地址;
- 当进程访问某个虚拟地址的时候,就会先去看页表,如果发现对应的数据不在物理内存上,就会发生缺页异常【Page Fault】;
- 缺页异常的处理过程,操作系统立即阻塞该进程,并将硬盘对应的页换入内存,然后使该进程就绪,如果内存已经满了,没有空地方了,那就会找一个页覆盖,至于具体覆盖哪一个页上,就要看操作系统的页面置换算法是如何设计的了。
1.4 操作系统页面置换算法
置换算法在内存中没有空闲页面时被调用,它的目的是选出一个被淘汰的页面。如果内存有足够的空闲页面存放所调用的页,则不必使用置换算法。把内存和外存统一管理的目的是把那些被访问概率非常高的页存放在内存中。因此置换算法应该是置换那些被访问概率最低的页,将它们移出内存。常用的置换算法如下:
- 随机淘汰算法:在系统设计人员认为无法确定哪些页面被访问的概率较低时,随机地选择某个用户的页面并换出是一个明智的做法;
- 先进先出算法【FIFO】:FIFO算法选择在内存驻留时间最长的一页淘汰掉。由实验和测试发现FIFO算法内存利用率并不是很高;
- 最近最久未使用算法【LRU】:当需要淘汰某一页时,选择离当前时间最近的一段时间内最久没有使用过的页先淘汰。找出最近最久未被使用的页面,就需要对每一个页面设置有关的访问记录项,而且每一次访问都必须更新这些记录;
- 理想型淘汰算法【OPT】:实现淘汰掉将来再也不会出现的页面-此算法无法
思考: Page Fault会阻塞进程,那么肯定会对性能产生影响,那么我们是不是可以做一些优化呢?
、
二进制重排原理
2.1 Page Fault
通过上面已知:进程如果直接去访问物理内存无疑是很不安全的,所以操作系统会在物理内存上又建立了虚拟内存。为了提高效率和方便管理,又对虚拟内存和物理内存进行分页。当进程访问一个虚拟内存Page而对应的物理内存却不存在时,会触发一次缺页中断。
2.2 重排原理
编译器在生成二进制代码的时候,默认会按照链接.o顺序写文件,按照Object File内部的函数顺序写函数。相关内容
静态库文件.a就是一组.o文件的ar包,可以用ar -t查看.a包含的所有.o。
复制代码
假如只有两个page,分别是page1和page2,其中红色的是method1和method3启动时候需要调用,为了执行对应的代码,系统必须进行两次Page Fault
但是如果我们把method1和method4排布在一起,那么只需要一次Page Fault即可,这就是二进制文件重排的核心原理。
在实际项目中,可以将启动时需要调用的函数放在一起,以尽可能减少Page Fault,进而减少启动耗时。
问题
其实出现上面的原理一旦清晰,会引发几个思考:
- 重排效果怎么样 - 获取启动阶段的page fault次数
- 重排成功了嘛 - 拿到当前二进制的函数布局
- 如何重排 - 让链接器按照指定到顺序生成Mach-O
- 重排的内容 - 获取启动时候用到的函数
调试Page Fault次数
在我们日常开发中使用到的性能分析利器无疑是Time Profiler,但是使用过的都知道Time Profiler是基于采样的,并且只能统计线程实际运行的时间,而发生Page Fault的时候线程是被blocked,所以用一个更为强大的工具- System Trace。
- 打开Xcode->Open Developer Tool -> Instruments -> System Trace
- 最好真机,卸载App,重新安装,选择工程,启动项目,当页面加载出来的时候,关闭System Trace统计【左上角手机的左边】
- 查看File Backed Page In 如下图
File Backed Page In就是我们说的Page Fault,对应的有count,duration等,一页Page Fault最大耗时以及最小耗时等参数。
经过三次卸载重新安装App监控,发现每次count都有很大的出入:
原因:当杀掉进程之后,它所占用的物理内存空间,如果没有被覆盖使用的话,那么这部分内存可能会一直存在或者存在相当长的时间。重新打开内存就不需要全部初始化了,所以有一个问题是: 冷热启动不能简单的以后台杀死程序而简单的判断。
二进制重排前阵
二进制重排是对即将生成的可执行文件重新排列,发生在链接阶段。
前面都是基础知识和概念,下面我们将详细讲述二进制重排技术!功能强大的Xcode是不是已经提供了此功能,答案显然是的,
通过libobjc这个项目来查看二进制重排技术【其使用到了二进制重排技术】
- 链接器dyld,dyld有一个参数是Order File,可以通过Order File配置一个Order 文件的路径;
- 在Order File文件中,将需要的符号按顺序写在里面;
- 当工程build编译的时候,Xcode会读取这个Order File文件,打的二进制包就会按照Order File这个文件中的符号顺序进行生成对应的mach-0
查看libobjc.order文件内容
疑问点:二进制重排?
- order文件里如果符号写错了或者符号不存在会不会有问题?
dyld链接器会忽略这些符号,但是如果link选项-order_file_statistics会以warning的形式把没有找到的符号打印在日志里面。
- 加入了order_file会不会影响上架?
二进制重排只是重新排列了所生成mach-0中函数表与符号表的顺序,并且objc源码【上面也用到了这种方式】
查看工程符号顺序
想要看工程的工程符号顺序有没有修改成功,或者工程前的符号顺序,这就使用到了Xcode工程的Link Map【Link Map是编译期间的产物】
如果想要看Dyld读取二进制文件顺序,就需要查看Compile Sources 编译文件,然后看到了ViewController在最前面
然后Clean一下,运行工程,查看Product ->右击点击show in finder ,找到Intermediates.noindex文件
然后找到一层一层找到一个文件是LaunchNewAPP-LinkMap-normal-x86_64.txt【找到一个.txt文件打开,经过我测试,真机和模拟器是不一样的,嘿嘿】
这个文件中存储了所有符号的顺序,在# Symbols:
部分后面:
上图中最左侧地址就是实际代码地址而非符号地址,因为二进制重排并非只是修改符号地址,而是利用符号顺序,重新排列整个代码在文件的偏移地址,将启动需要加载的方法地址放到前面内存页中,以此达到了减少Page Faultde 次数,从而达到优化的目的
实战操作
来到工程根目录【ios2018no8】,查看Link map -> Write Link Map File 由No 改为Yes,最原始的 ios2018no8-LinkMap-normal-x86_64.txt文件如下:
我们开始进行优化
来到工程根目录【ios2018no8】,进入终端:touch lb.order,发现多个文件
然后通过终端 vim lb.order
当你输入完成后,按键esc,然后输入shift + i ,输入wq
然后再次进入工程,进行配置order file,输入${SRCROOT}/lb.order
然后开始clean项目,编译运行项目,重新看ios2018no8-LinkMap-normal-x86_64.txt文件
然后查看文件,看下二进制重排结果:
可以看到,上面在order file文件中的三个方法已经被放到了最前面了。假如三个方法原本在三个不同的page上,那么我们就已经优化了两个 Page Fault
目标达到!!!嘿嘿
获取启动所有函数符号
7.1 方案
对于这个问题,本人下篇博客会专门讲述获取启动时调用的方案。思路大致如下:
- Hook objc_msgSend:通过这个方法能拿到oc以及swift @objc dynamic后的方法,并且由于可变参数个数,这就需要用汇编来获取参数
- 静态扫描machO特定段和节里面所存储的符号以及函数数据【静态扫描,主要用来load方法,c++构造方法等】
- Clang插桩--完美!!!【swift oc都可以拿到】
下面我们将讲述Clang插桩方案!!!
7.2 Clang插桩
Clang插桩实现主要有两个思路,一个是编写clang插件,另一个利用clang本身已经提供了工具来实现获取所有符号的需求。
静态插桩的原理
**1->**新建一个空App项目Clang插桩Demo,来探索静态插桩代码的机制和原理
**2->**然后选择工程,Build Settings ->输入搜索Other C Flags,双击Other C Flags,输入
-fsanitize-coverage=trace-pc-guard
**3->**在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; // 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);
复制代码
整体代码如下:
#import "ViewController.h"
@interface ViewController ()
@end
@implementation ViewController
- (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);
}
@end
复制代码
**4->**将断点打在viewcontroller里面
**5->**运行代码,查看打印效果,如下:
代码命名INIT后面两个指针叫做start和stop,可以lldb指令来查看start 到 stop这个内存地址里面存储的到底是啥?
**6->**运行代码,查看打印效果,如下:
发现存储的是1-14序号,当我们给viewcontroller添加一个方法testOCFunc方法
- (void)testOCFunc{
NSLog(@"调用oc函数");
}
复制代码
**7->**再次运行代码,查看打印如上步骤一样
发现从0e变成了0f,也就是说存储从刚刚14变成了15。
再次将viewcontroller代码加入一个C函数testCFuncy以及testCFunc里面有一个block函数【相当于又增加了2个函数】
- (void)viewDidLoad {
[super viewDidLoad];
[self testOCFunc];
testCFunc();
}
- (void)testOCFunc{
NSLog(@"调用oc函数");
}
void testCFunc(){
ZXYBlock();
}
void(^ZXYBlock)(void) = ^(void){
NSLog(@"block");
};
复制代码
**8->**再次运行代码,查看打印如上步骤一样
存储序号增加到了0x11 = 17个,从而我们开始大胆猜想,这个内存区间保存的就是工程所有符号的个数
我们在viewcontroller里面添加屏幕点击功能,在其里面加入testCFunc()方法
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
testCFunc();
}
复制代码
将断点打在testFunc函数实现里面:然后打开汇编调试 Debug ->Debug WorkFlow 然后选择Always Show Disassembly
然后运行代码,点击屏幕【断点位置NSLog(@"block");】
通过汇编发现,在每个函数调用的第一句实际diamante,被添加到一个callq调用**__sanitizer_cov_trace_pc_guard
**这个函数中来
总结
静态插桩实际上在编译期就在每一个函数内部二进制源数据添加Hook代码【__sanitizer_cov_trace_pc_guard函数
】来实现全局的方法Hook的效果。
7.3 获取所有函数符号
7.3.1 概念
通过上面我们了解到,所有函数内部第一步都会先去调用__sanitizer_cov_trace_pc_guard函数。
例如:A函数中调用了B函数,在arm汇编中即bl + 0x****指令,该指令首先将下一条汇编指令的地址保存现在x30寄存器**【函数嵌套时,在跳转子函数时都会保存下一条指令的地址在X30- lr寄存器中】中,然后再跳转到bl后面传递的指定地址去执行。**
bl 能实现跳转到某个地址的汇编指令 , 其原理就是修改 pc 寄存器的值来指向到要跳转的地址 , 而且实际上 B 函数中也会对 x29 / x30 寄存器的值做保护防止子函数又跳转其他函数会覆盖掉 x30 的值 , 当然 , 叶子函数除外 .
复制代码
当B函数执行ret【返回指令】指令的时候,就会去读取x30寄存器的地址,跳转过去,因为也就回到了上一层函数的下一步。这种思路是可以的。
我们所写的__sanitizer_cov_trace_pc_guard函数中的这一句:
__builtin_return_address
复制代码
它的作用是读取x30中所存储的要返回时下一条指令的地址,所以叫做__builtin_return_address
现在可以在 __sanitizer_cov_trace_pc_guard
这个函数中 , 通过 __builtin_return_address
数拿到原函数调用 __sanitizer_cov_trace_pc_guard
这句汇编代码的下一条指令的地址 .
7.3.2 流程思路图【上面可能有点绕】
根据内存地址获取函数名称,拿到了函数内部一行代码的地址 , 如何获取函数名称呢 ?
想要获取函数名称,还需要了解逆向开发的fishhook ,如果想要了解逆向开发,可以参考本人另一平台上的博客专辑-逆向开发专辑
会利用dlopen打开动态库,拿到一个句柄,从而拿到函数的内存地址直接调用。
与dlopen相同,在dlfcn.h中有一个方法如下:我们点开#import <dlfcn.h>库文件
上面的结构体变量意义是什么呢?如下:
typedef struct dl_info {
const char *dli_fname; /* 所在文件 */
void *dli_fbase; /* 文件地址 */
const char *dli_sname; /* 符号名称 */
void *dli_saddr; /* 函数起始地址 */
} Dl_info;
//这个函数能通过函数内部地址找到函数符号
int dladdr(const void *, Dl_info *);
复制代码
7.3.3. 修改代码查看效果
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
if (!*guard) return; // Duplicate the guard check.
void *PC = __builtin_return_address(0);
Dl_info info;
dladdr(PC, &info);
printf("fname=%s \nfbase=%p \nsname=%s\nsaddr=%p \n",info.dli_fname,info.dli_fbase,info.dli_sname,info.dli_saddr);
char PcDescr[1024];
printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);
}
复制代码
运行代码查看效果如下:
显示出要找的函数符号啦,完美!!!
收集函数符号
收集函数符号,大家可能想到上面的:去工程里面拿到所有的符号,写到order文件中不就可以了嘛?其实这样是不对的,下面讲述这种方案的缺陷。
8.1 多线程问题
由于项目各个方法肯定有可能会在不同的函数执行,因此__sanitizer_cov_trace_pc_guard这个函数也有可能受多线程影响,所以不能简单的通过一个数组来接收所有的符号。
考虑这个方法会多次调用,使用锁会影响性能,这里使用苹果底层的原子队列【底层是个栈结构,利用队列结构 + 原子性来保证顺序】来实现。
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
//遍历出队
while (true) {
//offsetof 就是针对某个结构体找到某个属性相对这个结构体的偏移量
SymbolNode * node = OSAtomicDequeue(&symboList, offsetof(SymbolNode, next));
if (node == NULL) break;
Dl_info info;
dladdr(node->pc, &info);
printf("%s \n",info.dli_sname);
}
}
//原子队列
static OSQueueHead symboList = OS_ATOMIC_QUEUE_INIT;
//定义符号结构体
typedef struct{
void * pc;
void * next;
}SymbolNode;
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
if (!*guard) return; // Duplicate the guard check.
void *PC = __builtin_return_address(0);
SymbolNode * node = malloc(sizeof(SymbolNode));
*node = (SymbolNode){PC,NULL};
//入队
// offsetof 用在这里是为了入队添加下一个节点找到 前一个节点next指针的位置
OSAtomicEnqueue(&symboList, node, offsetof(SymbolNode, next));
}
复制代码
当你运行之后,发现死循环了
死循环的原因:通过汇编看到一个带有while循环的方法,会被静态加入多次__sanitizer_cov_trace_pc_guard调用,导致了死循环。
8.2 死循环问题
将第四步骤Other C Flags修改如下:
-fsanitize-coverage=func,trace-pc-guard
复制代码
再次运行代码,在点击屏幕touchBegen里面加入如下代码,打印出函数符号名称
while (true) {
//offsetof 就是针对某个结构体找到某个属性相对这个结构体的偏移量
SymbolNode * node = OSAtomicDequeue(&symboList, offsetof(SymbolNode, next));
if (node == NULL) break;
Dl_info info;
dladdr(node->pc, &info);
NSString * name = @(info.dli_sname);
// 添加 _
BOOL isObjc = [name hasPrefix:@"+["] || [name hasPrefix:@"-["];
NSString * symbolName = isObjc ? name : [@"_" stringByAppendingString:name];
//去重
if (![symbolNames containsObject:symbolName]) {
[symbolNames addObject:symbolName];
}
}
复制代码
当点击屏幕时,出现打印的符号表如下:
如果将order文件写入其中,当点击的屏幕【将order写入temp中】
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
NSMutableArray<NSString *> * symbolNames = [NSMutableArray array];
while (true) {
//offsetof 就是针对某个结构体找到某个属性相对这个结构体的偏移量
SymbolNode * node = OSAtomicDequeue(&symboList, offsetof(SymbolNode, next));
if (node == NULL) break;
Dl_info info;
dladdr(node->pc, &info);
NSString * name = @(info.dli_sname);
// 添加 _
BOOL isObjc = [name hasPrefix:@"+["] || [name hasPrefix:@"-["];
NSString * symbolName = isObjc ? name : [@"_" stringByAppendingString:name];
//去重
if (![symbolNames containsObject:symbolName]) {
[symbolNames addObject:symbolName];
}
}
//取反
NSArray * symbolAry = [[symbolNames reverseObjectEnumerator] allObjects];
NSLog(@"%@",symbolAry);
//将结果写入到文件
NSString * funcString = [symbolAry componentsJoinedByString:@"\n"];
NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"lb.order"];
NSData * fileContents = [funcString dataUsingEncoding:NSUTF8StringEncoding];
BOOL result = [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
if (result) {
NSLog(@"%@",filePath);
}else{
NSLog(@"文件写入出错");
}
}
复制代码
再次运行代码,查看order文件在如下:
拿到order文件路径,前往文件夹输入文件路径如下:
查看lb.order文件内容
哈哈哈,看到这一幕,你就可以随意的优化二进制链接方法的顺序啦!!!
Demo全部代码如下
//
// ViewController.m
// clang插桩Demo
//
// Created by 张小杨 on 2020/12/21.
//
#import "ViewController.h"
#import <dlfcn.h>
#import <libkern/OSAtomic.h>
@interface ViewController ()
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
[self testOCFunc];
testCFunc();
}
- (void)testOCFunc{
NSLog(@"调用oc函数");
}
void testCFunc(){
ZXYBlock();
}
void(^ZXYBlock)(void) = ^(void){
NSLog(@"block");
};
+ (void)load{
}
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)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
NSMutableArray<NSString *> * symbolNames = [NSMutableArray array];
while (true) {
//offsetof 就是针对某个结构体找到某个属性相对这个结构体的偏移量
SymbolNode * node = OSAtomicDequeue(&symboList, offsetof(SymbolNode, next));
if (node == NULL) break;
Dl_info info;
dladdr(node->pc, &info);
NSString * name = @(info.dli_sname);
// 添加 _
BOOL isObjc = [name hasPrefix:@"+["] || [name hasPrefix:@"-["];
NSString * symbolName = isObjc ? name : [@"_" stringByAppendingString:name];
//去重
if (![symbolNames containsObject:symbolName]) {
[symbolNames addObject:symbolName];
}
}
//取反
NSArray * symbolAry = [[symbolNames reverseObjectEnumerator] allObjects];
NSLog(@"%@",symbolAry);
//将结果写入到文件
NSString * funcString = [symbolAry componentsJoinedByString:@"\n"];
NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"lb.order"];
NSData * fileContents = [funcString dataUsingEncoding:NSUTF8StringEncoding];
BOOL result = [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
if (result) {
NSLog(@"%@",filePath);
}else{
NSLog(@"文件写入出错");
}
}
//原子队列
static OSQueueHead symboList = OS_ATOMIC_QUEUE_INIT;
//定义符号结构体
typedef struct{
void * pc;
void * next;
}SymbolNode;
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
//if (!*guard) return; // Duplicate the guard check.
void *PC = __builtin_return_address(0);
SymbolNode * node = malloc(sizeof(SymbolNode));
*node = (SymbolNode){PC,NULL};
//入队
// offsetof 用在这里是为了入队添加下一个节点找到 前一个节点next指针的位置
OSAtomicEnqueue(&symboList, node, offsetof(SymbolNode, next));
}
@end
复制代码
拓展
swift 工程怎么办呢?由于swift的编译器前端是自己的swift编译前端程序,配置稍微不同
搜索Other Swift Flags,添加两条配置即可:
-sanitize-coverage=func
-sanitize=undefined
复制代码
然后把代码放入进去,即可和OC尝试一下【大家尝试一下吧】
总结
本篇博客首先讲述了存储管理,缺页等知识储备问题,后面讲述二进制重排原理以及如何获取函数符号以此来达到二进制重排的目的,全部用Demo的形式,方便大家操作和实践,一步一步达到二进制重排优化时间的完整过程。大致如下:
-
利用 clang 插桩获得启动时期需要加载的所有 函数/方法 , block , swift 方法以及 c++构造方法的符号 ;
-
通过 order file 机制实现二进制重排 .
大致情况就是这样,欢迎点赞博客及关注本人,后期会继续分享更多的干货供大家分析参考点评!!!