iOS底层探索-Clang插桩

2,864 阅读5分钟

在上篇 启动优化 中我们最后使用二进制重排方法,将启动相关的符号方法提前加载到内存,从而减少 缺页中断(Page Fault) 来提高启动速度,但我们如何确定需要将哪些方法提前呢?本篇就来介绍寻找这些符号的方法 -- Clang插桩

1、Clang插桩配置

  • LLVM 内置了一个简单的代码覆盖率检测工具(SanitizerCoverage),它在 函数级基本块级边缘级 上插入对用户定义函数的调用,通过这种方式,可以顺利对 OC 方法C函数BlockSwift 的方法/函数进行全面HOOK(objc_msgSend的Hook仅试用于OC)
  • 使用 SanitizerCoverage 需要到 Clang官网 查找帮助

1.1、环境配置

在官网中我们需要找到Tracing PCs跟踪CPU执行到的代码image.png

  • 跟踪时需要向Clang中添加下边的标记

    -fsanitize-coverage=trace-pc-guard

  • Build Settings --> Other C Flags 中添加上边的标记 image.png

  • 编译一下,会报错 image.png

  • 原因就是这两个符号的方法没有对应的实现,官网的Example中给出了解决方式,我们只需把这一片代码copy进项目,并做一些微调即可 image.png

    #import "ViewController.h"
    // 按照官网示例添加3个头文件
    #include <stdint.h>
    #include <stdio.h>
    #include <sanitizer/coverage_interface.h>
    
    @interface ViewController ()
    @end
    
    @implementation ViewController
    
    - (void)viewDidLoad {
        [super viewDidLoad];
        // Do any additional setup after loading the view.
    }
    
    // 此处不需要 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.
    }
    
    // 此处不需要 extern "C",删掉 
    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
    

1.2、__sanitizer_cov_trace_pc_guard_init

  • 记录了项目中符号的个数
  • 当开启跟踪配置后,系统会回调 __sanitizer_cov_trace_pc_guard_init 函数,并开辟一段连续空间,空间起始位置为 start、结束位置为 stop,它们都是 uint32_t 类型,占 4 字节
  • ++x 进行的指针运算,start 每次步长+1向 stop 逼近1指针位置相当于+4字节,所以最后一个值记录了一共有多少个方法、函数、block,且应该是 stop 地址的基础上减去 4字节
验证
  • 不用进行断点,运行结束后点这个暂停符号进入lldb image.png
  • 打印地址用 x命令 或者 x/ngx 都可以,能看到每4字节记录的数+1代表有一个方法、函数、block被记录到start~stop的空间中最后在stop-4位置数值不再增长,表示数量全部被记录了 image.png

1.3、__sanitizer_cov_trace_pc_guard

  • 拦截当前项目中的所有 方法、函数、block(包括 settergetter),二进制重排排的也是本项目的,不会去排外部符号 image.png image.png

1.4、插桩原理

  • 修改了二进制文件,在所有的 方法、函数、block 的实现的第一句插入一句 __sanitizer_cov_trace_pc_guard() 代码

2、获取符号

通过函数地址可以通过dladdr方法得到Dl_info结构体,然后获取函数信息

// 需要导入 dlfcn.h 头文件
#import <dlfcn.h>

void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
    // 被插入标记的函数的地址
    void *PC = __builtin_return_address(0);
    
    Dl_info info;
    dladdr(PC, &info);
    printf("fname:%s\nfbase:%p\nsname:%s\nsaddr:%p\n\n\n\n",info.dli_fname,info.dli_fbase,info.dli_sname,info.dli_saddr);
}
  • __builtin_return_address(0):返回被插入标记的函数的地址
/*
 * Structure filled in by dladdr().
 */
typedef struct dl_info {
        const char      *dli_fname;     /* Pathname of shared object */
        void            *dli_fbase;     /* Base address of shared object */
        const char      *dli_sname;     /* Name of nearest symbol */
        void            *dli_saddr;     /* Address of nearest symbol */
} Dl_info;
  • dli_fname:当前MachO路径
  • dli_fbase:当前MachO基地址
  • dli_sname:函数名称
  • dli_saddr:函数地址

image.png

  • 因为插入的位置是函数实现的第0行,所以先打印Hook中自定义的内容再打印了函数具体实现

小结

验证打印

image.png

  • 部分函数会被重复调用,产生多余符号,因此还需要去重
  • 个人验证打印函数个数会比打印的具体函数名多,试了几次多出的暂时固定为3个,因此考虑可能有几个函数被记入Hook函数数,但是未走 __sanitizer_cov_trace_pc_guard 回调,仅供参考

3、收集保存符号

__sanitizer_cov_trace_pc_guard 中的打印是跟着函数的线程走的,如果函数在子线程,那么这个回调也会在子线程中,因此收集符号时需要保证线程安全

#import "ViewController.h"
#include <stdint.h>
#include <stdio.h>
#include <sanitizer/coverage_interface.h>

#import <dlfcn.h>
// 创建原子队列需要导入头文件
#import <libkern/OSAtomic.h>

@interface ViewController ()
@property(nonatomic,copy)NSString *name;
@end

@implementation ViewController

void (^myBlock)(void) = ^(void) {
    NSLog(@"myBlock函数");
};

+ (void)load {
    NSLog(@"load方法");
    myBlock();
}

- (void)viewDidLoad {
    [super viewDidLoad];
    NSLog(@"viewDidLoad方法");
    self.name = @"lz";
}

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.
}

// 定义原子队列
static OSQueueHead symbolist = OS_ATOMIC_QUEUE_INIT;
// 定义符号结构体
typedef struct {
    void *pc;
    void *next;
} SYNode;

void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
    void *PC = __builtin_return_address(0);

    SYNode *node = malloc(sizeof(SYNode));
    // 结构体指针指向 SYNode 结构体(pc属性赋值PC,next赋值NULL)
    *node = (SYNode){PC,NULL};
    // 结构体入栈:参数1:链表地址,参数2:存入的节点、参数3:offsetof(类型,参数2的属性)
    OSAtomicEnqueue(&symbolist, node, offsetof(SYNode, next));
}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    while (YES) {
        // 从链表中取出节点(取一个少一个)
        SYNode *node = OSAtomicDequeue(&symbolist, offsetof(SYNode, next));
        // 链表被取空则跳出while循环
        if (node == NULL) {
            break;
        }
        Dl_info info;
        dladdr(node->pc, &info);
        printf("%s\n",info.dli_sname);
    }
}

@end
  • 使用原子队列 OSQueueHead,一是为了原子线程相对安全,二是为了队列方便后续 去重 操作

  • OSAtomicEnqueue(&链表, 待存入节点, offsetof(待存入节点类型, 待存入节点的属性))

    • OSAtomicEnqueue:入队列
    • offsetof:宏,参数1:传入类型来计算类型大小,参数2:将下一个节点的地址返回给 OSAtomicEnqueue的第二个参数(因为是链表不用能角标,所以通过计算类型大小来算出尾部,然后赋值给 OSAtomicEnqueue 第二个节点的 用于指向下个节点的属性(一般都自定义为next)

3.1、无限循环BUG坑点

  • 点击屏幕会出现无限调用touchesBegan方法的情况,是因为SanitizerCoverage不但拦截 方法、函数、Block还会对循环进行 HOOK
  • 像例子中写为 while(YES),出循环条件为链表被取空,但while被hook导致每执行一次while的判断进入一次回调,链表又被追加延长了,因此永远无法将链表取空;而且回调函数中存入队列的还是 touchesBegan 的函数地址
解决方案
  • 修改插桩标记,限定为funcBuild Settings --> Other C Flags

    -fsanitize-coverage=func,trace-pc-guard image.png

3.2、取反、去重

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    NSMutableArray<NSString *> * symbolNames = [NSMutableArray array];

    while (YES) {
        SYNode *node = OSAtomicDequeue(&symbolist, offsetof(SYNode, next));
        if (node == NULL) {
            break;
        }
        Dl_info info;
        dladdr(node->pc, &info);
        // 转OC字符串
        NSString *name = @(info.dli_sname);
        // 非OC方法添加下划线"_"再加入到数组中
        BOOL isObjc = [name hasPrefix:@"+["] || [name hasPrefix:@"-["];
        NSString * symbolName = isObjc ? name : [@"_" stringByAppendingString:name];
        [symbolNames addObject:symbolName];
    }
    // 反向遍历数组
    //symbolNames = (NSMutableArray<NSString *> *)[[symbolNames reverseObjectEnumerator] allObjects];
    //NSLog(@"%@",symbolNames);

    // 反向遍历迭代器
    NSEnumerator *em = [symbolNames reverseObjectEnumerator];
    NSMutableArray *funcs = [NSMutableArray arrayWithCapacity:symbolNames.count];
    NSString *name;
    while (name = [em nextObject]) {
    // 已包含的符号不再入组
        if (![funcs containsObject:name]) {
            [funcs addObject:name];
        }
    }
    // 因为是在touchBegan这个方法中实现的功能,但我们启动优化并不需要touchBegan方法,因此去掉
    [funcs removeObject:[NSString stringWithFormat:@"%s", __func__ ]];
}

4、生成.order文件

紧接着上边的 去重、取反 操作后,将符号集生成 .order 文件

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    ......
    
    // 将数组转换为string字符串
    NSString *funcStr = [funcs componentsJoinedByString:@"\n"];
    NSString *filePath = [NSTemporaryDirectory() stringByAppendingString:@"/pagefault.order"];
    NSData *file = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
    [[NSFileManager defaultManager] createFileAtPath:filePath contents:file attributes:nil];
}

5、配置.order文件

  • 拿到 .order 文件,选择Add Additional Simulators... image.png

  • 选中案例 App,点击Downlad Container... image.png

  • 选择路径,下载.xcappdata文件,右键显示包内容,在AppData/tmp目录下,找到 .order 文件,将 .order 文件拷贝到工程根目录,在 Build Setting --> Order File进行配置(图中.order文件名搞错了) image.png

  • Build Settings --> Write Link Map File,设置为YES image.png

    • Write Link Map File:在Xcode生成可执行文件的同时生成的链接信息文件,用于描述可执行文件的构造部分,包括了代码段和数据段的分布情况
  • 编译项目,打开LinkMap文件,查看是否配置成功

  • 找到生成的文件配置到 Order File 中即可,然后上架前删除除.order外的这些插桩内容,因为会大幅损耗性能

6、收集Swift符号

OC 配置 Other C Flags 不同,Swift 因为使用的是swiftc编译器,因此要配置 Other Swift Flags,配置内容也稍有不同,需要配置两个

-sanitize-coverage=func
-sanitize=undefined image.png

Swift收集符号检验

image.png

image.png