飞道的博客

llvm 编译器高级用法:第三方库插桩

320人阅读  评论(0)

本文字数:3141

预计阅读时间:25分钟

一、背景

最近看到一篇有意思的技术文章:《抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15%》。

原文结尾提到该方案无法覆盖100%的符号:


   
  1. 基于静态扫描+运行时trace的方案仍然存在少量瓶颈:
  2. initialize hook不到
  3. 部分block hook不到
  4. C++通过寄存器的间接函数调用静态扫描不出来
  5. 目前的重排方案能够覆盖到 80%~ 90%的符号,未来我们会尝试编译期插桩等方案来进行 100%的符号覆盖,让重排达到最优效果。

实际上,除上面的场景外,抖音研发团队的方案还存在一些无法覆盖的场景:

  • 无法覆盖代码行级别的检测

    • 当某些复杂的函数存在 if/else/switch 等场景时,开发者可以将函数拆成多个子函数进行优化

  • OC/C 语言的函数调用同样很难被静态扫描

  • 无法对第三方的静态库或者动态库进行有效处理

  • 无法检测 __attribute__((constructor)) 修饰的函数

今天我们将尝试通过 llvmIR 配合实现解决上面提到的各类场景。

二、效果展示

本质上,上面提到的各类场景,都可以通过 对代码进行 基本块(BasicBlock-Level) 级别插桩 的方式解决。

为了方便读者能够继续将本文全部阅读下去,我们先看看一个给 微信SDK 插桩的实际效果。

基本块(BasicBlock-Level)  的概念会在下一章节进行讲解

1、微信SDK

微信SDK(OpenSDK1.8.7.1)[1] 提供了3个公开的头文件,其中 WXApi.h 的暴露了一个类方法 [WXApi registerApp: universalLink:]


   
  1. /*! @brief 微信Api接口函数类
  2.  *
  3.  * 该类封装了微信终端SDK的所有接口
  4.  */
  5. @ interface WXApi : NSObject
  6. /*! @brief WXApi的成员函数,向微信终端程序注册第三方应用。
  7.  *
  8.  * 需要在每次启动第三方应用程序时调用。
  9.  * @attention 请保证在主线程中调用此函数
  10.  * @param appid 微信开发者ID
  11.  * @param universalLink 微信开发者Universal Link
  12.  * @return 成功返回YES,失败返回NO。
  13.  */
  14. + (BOOL)registerApp:(NSString *)appid universalLink:(NSString *)universalLink;
  15. @end

2、 main.m

新建一个工程,添加回调函数并增加对微信SDK的接口调用:


   
  1. @ import Darwin;
  2. int main( int argc, char * argv[]) {
  3.    // 调用微信SDK
  4.     [WXApi registerApp:@ "App" universalLink:@ "link"];
  5.      return  0;
  6. }
  7. // 提供回调函数
  8. void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
  9.     Dl_info info;
  10.    // 获取当前函数的返回地址 
  11. )
  12.     void *PC = __builtin_return_address( 0);
  13.     // 根据返回地址,获取相关的信息 
  14.     dladdr(PC, &info);
  15.     // 打印与 PC 最近的符号名称
  16.     printf( "guard:%p 开始执行:%s \n", PC, info.dli_sname);
  17. }

更多内容,可以阅读参考资料的相关链接 dladdr[2] __builtin_return_address[3]

3、运行

通过在 __sanitizer_cov_trace_pc_guard 函数增加断点,我们可以看到下面的调用栈:

整理后的流程图如下所示:

我们可以很容易地从流程图看出来:

微信SDK 调用了开发者提供的回调函数 __sanitizer_cov_trace_pc_guard


下面,我们开始进入正题。

三、插桩与代码覆盖率

为了强调一下本文与抖音技术方案的区别,我们需要先了解一下插桩中常用的代码覆盖率计量单位。

通常情况下,代码覆盖率有 3 种计量单位:

  • 函数(Fuction-Level)

  • 基本块(BasicBlock-Level)

  • 边界(Edge-Level)

1、函数(Fuction-Level)

函数(Fuction-Level) 比较容易理解,就是记录哪些函数执行过。是一种粗糙但高效的统计方式。

从抖音的技术文章看,他们勉强算是做到了这个级别的代码覆盖率检测。

2、基本块(BasicBlock-Level)

基本块(BasicBlock) 通常是只包含顺序执行的代码块。

以下面的代码为例:


   
  1. void foo( int *a) {
  2.    if (a)
  3.     *a =  0;
  4. }

通过编译器将代码转为汇编时,它会被拆成3个部分:

每个部分都是一个 基本块(BasicBlock)

代码行覆盖率可以通过 基本块(BasicBlock-Level) 级别的代码插桩实现。

3、边界(Edge-Level)

边界(Edge) 的概念比较难理解,我们仍然以上面的代码为例进行说明。

上面的代码包含3个 基本块(BasicBlock)ABC

即使代码行覆盖测试报告显示 ABC 三块都被执行过,我们仍然无法得到以下结论:

路径A-->C 出现过

此时,我们可以添加一个虚拟路径 D

如果测试报告显示 虚拟路径 D 被执行过,则 路径A-->C  就一定出现过;反之, 路径A-->C 就一定没有出现过。

路径覆盖率可以通过 边界(Edge) 级别的代码插桩实现。

三、SanitizerCoverage

根据 llvm 的官方文档 SanitizerCoverage[4],我们可以搭配 -fsanitize-coverage=trace-pc-guard 或者其它编译参数控制编译器插入不同级别的

下面,我们以 -fsanitize-coverage=trace-pc-guard 为例进行演示效果:

1、配置 编译开关

2、准备源码文件


   
  1. // 文件 A
  2. int f(void) __attribute__((constructor));
  3. int f(void) {
  4.     NSLog(@ " int f() __attribute__((constructor)) 被调用");
  5.      return  0;
  6. }

   
  1. // 文件 ViewController.mm
  2. # import < string>
  3. static std:: string cxx_static_str( "cxx_static_str");
  4. + (void)load {
  5.     NSLog(@ "load 被执行");
  6. }

   
  1. // 文件 main.m
  2. @ import Darwin;
  3. void __sanitizer_cov_trace_pc_guard_init(uint32_t *start,
  4.                                          uint32_t *stop) {
  5.     static uint32_t N;   // Counter for the guards.
  6.      if (start == stop || *start)  return;   // Initialize only once.
  7.     printf( "INIT: %p %p\n", start, stop);
  8.      for (uint32_t *x = start; x < stop; x++)
  9.     *x = ++N;
  10. }
  11. void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
  12.     Dl_info info;
  13.     
  14.     void *PC = __builtin_return_address( 0);
  15.     dladdr(PC, &info);
  16.     printf( "guard:%p 开始执行:%s \n", PC, info.dli_sname);
  17. }
  18. void foo( int *a) {
  19.      if (a)
  20.         *a =  0;
  21. }
  22. int main( int argc, char * argv[]) {
  23.     dispatch_async(dispatch_get_main_queue(), ^{
  24.         NSLog(@ "main block");
  25.     });
  26.      int i= 0;
  27.     foo(&i);
  28.     
  29.     NSString * appDelegateClassName;
  30.     @autoreleasepool {
  31.          // Setup code that might create autoreleased objects goes here.
  32.         appDelegateClassName = NSStringFromClass([AppDelegate class]);
  33.     }
  34.      return UIApplicationMain(argc, argv,  nil, appDelegateClassName);
  35. }

3、运行

运行日志如下所示,我们可以发现以下场景都能够被正常覆盖:

  • load 方法

  • c++ 变量

  • __attribute__((constructor)) 修饰的函数

  • 函数 foo 的两个 基本块(BasicBlock-Level)

  • block

四、编译流程简析

我们先通过一个简单例子,看看源码是如何成为二进制文件的。

1、准备源码文件

命令行输入:


   
  1. cat <<EOF > main.m
  2. int main() {
  3.    return  0;
  4. }
  5. EOF

2、打印构建顺序

命令行输入:

xcrun clang main.m -save-temps -v -mllvm -debug-pass=Structure -fsanitize-coverage=trace-pc-guard

输出如下所示(有删减):


   
  1. clang -cc1 -E --fsanitize-coverage- type= 3 -fsanitize-coverage-trace-pc-guard main.mi -x objective-c main.m
  2. clang -cc1 -emit-llvm-bc -disable-llvm-passes -fsanitize-coverage- type= 3 -fsanitize-coverage-trace-pc-guard -o main.bc -x objective-c-cpp-output main.mi
  3. clang -cc1 -S -fsanitize-coverage- type= 3 -fsanitize-coverage-trace-pc-guard -o main.s -x ir main.bc
  4. clang -cc1as -o main.o main.s
  5. ld -o a.out -L/usr/local/lib main.o

整理后,如下图所示:


   
  1. graph LR
  2. subgraph 示例
  3. 流程:::流程
  4. 文件:::文件
  5. classDef 流程 fill:#f96;
  6. end
  7. main.m-->preprocessor:::流程-->main.mi
  8. --> compiler:::流程--> main.bc
  9. main.bc_fake[main.bc] --> backend:::流程 --> main.s
  10. --> assembler:::流程 --> main.o
  11. --> linker:::流程 --> a.out

因为 main.bc 是二进制版本的 bitcode,可读性比较差。

开发者可以通过 llvm-dis main.bc -o - 命令转为更具有可读性的版本:


   
  1. ; ModuleID =  'main.bc'
  2. source_filename =  "~/main.m"
  3. target datalayout =  "e-m:o-p270:32:32-p271:32:32-p272:64:64-i64:64-f80:128-n8:16:32:64-S128"
  4. target triple =  "x86_64-apple-macosx10.15.0"
  5. ; Function Attrs: noinline nounwind optnone ssp uwtable
  6. define i32 @main() # 0 {
  7.   % 1 = alloca i32, align  4
  8.   store i32  0, i32* % 1, align  4
  9.   ret i32  0
  10. }
  11. attributes # 0 = { noinline nounwind optnone ssp uwtable  "correctly-rounded-divide-sqrt-fp-math"= "false"  "darwin-stkchk-strong-link"  "disable-tail-calls"= "false"  "frame-pointer"= "all"  "less-precise-fpmad"= "false"  "min-legal-vector-width"= "0"  "no-infs-fp-math"= "false"  "no-jump-tables"= "false"  "no-nans-fp-math"= "false"  "no-signed-zeros-fp-math"= "false"  "no-trapping-math"= "false"  "probe-stack"= "___chkstk_darwin"  "stack-protector-buffer-size"= "8"  "target-cpu"= "penryn"  "target-features"= "+cx16,+cx8,+fxsr,+mmx,+sahf,+sse,+sse2,+sse3,+sse4.1,+ssse3,+x87"  "unsafe-fp-math"= "false"  "use-soft-float"= "false" }
  12. !llvm.module.flags = !{! 0, ! 1, ! 2, ! 3, ! 4, ! 5, ! 6, ! 7}
  13. !llvm.ident = !{! 8}
  14. ! 0 = !{i32  2, ! "SDK Version", [ 3 x i32] [i32  10, i32  15, i32  6]}
  15. ! 1 = !{i32  1, ! "Objective-C Version", i32  2}
  16. ! 2 = !{i32  1, ! "Objective-C Image Info Version", i32  0}
  17. ! 3 = !{i32  1, ! "Objective-C Image Info Section", ! "__DATA,__objc_imageinfo,regular,no_dead_strip"}
  18. ! 4 = !{i32  1, ! "Objective-C Garbage Collection", i8  0}
  19. ! 5 = !{i32  1, ! "Objective-C Class Properties", i32  64}
  20. ! 6 = !{i32  1, ! "wchar_size", i32  4}
  21. ! 7 = !{i32  7, ! "PIC Level", i32  2}
  22. ! 8 = !{! "Apple clang version 12.0.0 (clang-1200.0.32.21)"}

再与 main.s 文件的内容对照一下:


   
  1. .p __TEXT,__text,regular,pure_instructions
  2. .build_version macos, 10, 15 sdk_version 10, 15, 6
  3. .globl _main ## -- Begin function main
  4. .p2align 4, 0x90
  5. _main: ## @main
  6. .cfi_startproc
  7. ## %bb .0:
  8. pushq %rbp
  9. .cfi_def_cfa_offset 16
  10. .cfi_offset %rbp, -16
  11. movq %rsp, %rbp
  12. .cfi_def_cfa_register %rbp
  13. subq $ 16, %rsp
  14. leaq l___sancov_gen_(%rip), %rdi
  15. callq ___sanitizer_cov_trace_pc_guard
  16. ## InlineAsm Start
  17. ## InlineAsm End
  18. xorl %eax, %eax
  19. movl $ 0, -4(%rbp)
  20. addq $ 16, %rsp
  21. popq %rbp
  22. retq
  23. .cfi_endproc
  24. ## -- End function
  25. .p2align 4, 0x90 ## -- Begin function sancov.module_ctor_trace_pc_guard
  26. _sancov.module_ctor_trace_pc_guard: ## @sancov.module_ctor_trace_pc_guard
  27. .cfi_startproc
  28. ## %bb .0:
  29. pushq %rax
  30. .cfi_def_cfa_offset 16
  31. leaq p$start$__DATA$__sancov_guards(%rip), %rax
  32. leaq p$end$__DATA$__sancov_guards(%rip), %rcx
  33. movq %rax, %rdi
  34. movq %rcx, %rsi
  35. callq ___sanitizer_cov_trace_pc_guard_init
  36. popq %rax
  37. retq
  38. .cfi_endproc
  39. ## -- End function
  40. .p __DATA,__sancov_guards
  41. .p2align 2 ## @__sancov_gen_
  42. l___sancov_gen_:
  43. .space 4
  44. .p __DATA,__mod_init_func,mod_init_funcs
  45. .p2align 3
  46. .quad _sancov.module_ctor_trace_pc_guard
  47. .no_dead_strip l___sancov_gen_
  48. .p __DATA,__objc_imageinfo,regular,no_dead_strip
  49. L_OBJC_IMAGE_INFO:
  50. .long 0
  51. .long 64
  52. .subps_via_symbols

通过两份文件对比,我们可以发现经过 backend 流程后,___sanitizer_cov_trace_pc_guard 相关的调用才开始出现。

所以,我们可以得到第一个重要的结论:

在具有 bc 文件 的情况下,就可以通过 backend 流程 进行插桩处理。

再结合我们之前发过的公众号文章: 检查第三方库是否包含 bitcode 信息,我们可以得到第二个结论:

通过导出第三方库的 bitcode,我们可以实现任意 cpu 架构下的插桩。

五、实战

讲解完基础知识后,我们开始以 微信SDKOpenSDK1.8.7.1) 为例进行实际讲解。

1、对微信SDK进行处理

  • 检测  微信SDK 的文件类型

    命令行输入:

    file ~/Downloads/OpenSDK1.8.7.1/libWeChatSDK.a
    

输出如下:


   
  1. ~/Downloads/OpenSDK1 .8 .7 .1/libWeChatSDK.a: Mach-O universal binary with  4 architectures: [i386:current ar archive] [arm_v7] [x86_64] [arm64]
  2. ~/Downloads/OpenSDK1 .8 .7 .1/libWeChatSDK.a ( for architecture i386): current ar archive
  3. ~/Downloads/OpenSDK1 .8 .7 .1/libWeChatSDK.a ( for architecture armv7): current ar archive
  4. ~/Downloads/OpenSDK1 .8 .7 .1/libWeChatSDK.a ( for architecture x86_64): current ar archive
  5. ~/Downloads/OpenSDK1 .8 .7 .1/libWeChatSDK.a ( for architecture arm64): current ar archive
  • 因为 微信SDK包含多个架构,所以需要先用 lipo 命令导出一份单架构文件

    lipo -thin armv7 ~/Downloads/OpenSDK1.8.7.1/libWeChatSDK.a -o ~/Downloads/OpenSDK1.8.7.1/libWeChatSDK_armv7.a
    
  • 检测单架构文件的类型

    命令行输入:

    file -b ~/Downloads/OpenSDK1.8.7.1/libWeChatSDK_armv7.a
    

    输出如下:

    current ar archive
    
  • 因为 libWeChatSDK_armv7.aar 文件,通过 tar 命令解压缩

    tar -xf ~/Downloads/OpenSDK1.8.7.1/libWeChatSDK_armv7.a
    

    产出12个 .o 文件

    
         
    1. tree
    2. .
    3. ├── AppCommunicate.o
    4. ├── AppCommunicateData.o
    5. ├── WXApi+ExtraUrl.o
    6. ├── WXApi+HandleOpenUrl.o
    7. ├── WXApi.o
    8. ├── WXApiObject.o
    9. ├── WXLogUtil.o
    10. ├── WapAuthHandler.o
    11. ├── WeChatApiUtil.o
    12. ├── WeChatIdentityHandler.o
    13. ├── WechatAuthSDK.o
    14. └── base64.o
    15. 0 directories,  12 files
  • 依次判断 .o 文件的类型并进行处理 命令行输入:

    file -b AppCommunicate.o
    

    输出:

    Mach-O object arm_v7
    
  • 通过 segedit 命令导出 bitcode

    segedit AppCommunicate.o -extract __LLVM __bitcode .AppCommunicate.bc
    
  • 通过 clangbitcode 转为 .s 文件

    注意事项:

    为了避免编译器错误: fatal error: error in backend: Cannot select: intrinsic %llvm.objc.clang.arc.use,这里需要传入 -O1 或者更高级别的优化开关,以启用 -objc-arc-contract Pass

    xcrun clang -O1 -target armv7-apple-ios7 -S AppCommunicate.bc -o AppCommunicate.s -fsanitize-coverage=trace-pc-guard -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS14.1.sdk
    

截取 AppCommunicate.s 部分内容如下:


   
  1. Ltmp0:
  2. .loc 9 16 0 prologue_end ; AppCommunicate/AppCommunicate.m: 16: 0
  3. Lloh0:
  4. adrp x0, l___sancov_gen_@PAGE
  5. Ltmp1:
  6. ;DEBUG_VALUE: +[AppCommunicate getDataPasteboardName]:self <- [DW_OP_LLVM_entry_value 1] $x0
  7. Lloh1:
  8. add x0, x0, l___sancov_gen_@PAGEOFF
  9. bl ___sanitizer_cov_trace_pc_guard
  10. Ltmp2:
  11. ;DEBUG_VALUE: +[AppCommunicate getDataPasteboardName]:_cmd <- [DW_OP_LLVM_entry_value 1] $x1

2、Demo

将处理后的文件直接放到工程中:

3、运行

我们仍然用本文开头的代码进行演示。

如下所示,可以通过 console 区域看到微信SDK内部的执行流程

总结

首先,我们先回顾一下本文的重点知识:

  • 代码覆盖率 分为 函数(Fuction-Level)基本块(BasicBlock-Level)边界(Edge-Level) 三种级别。

  • llvm 编译器 通过 SanitizerCoverage 支持以上三种级别的代码覆盖率插桩。

  • 通过导出第三方库的 bitcode,我们可以实现任意cpu架构下的插桩。

本文通过介绍 代码覆盖率SanitizerCoverage编译流程 ,并以 微信SDK 为例,对如何实现第三方SDK插桩进行了详细的讲解。

参考资料

[1]

微信SDK(OpenSDK1.8.7.1): https://developers.weixin.qq.com/doc/oplatform/Downloads/iOS_Resource.html

[2]

dladdr: https://gcc.gnu.org/onlinedocs/gcc/Return-Address.html

[3]

__builtin_return_address: https://gcc.gnu.org/onlinedocs/gcc/Return-Address.html

[4]

SanitizerCoverage: https://releases.llvm.org/10.0.0/tools/clang/docs/SanitizerCoverage.html#instrumentation-points

上期获奖名单公布

恭喜“盖上被子的...”、“阿策~”、“beat you”!以上读者请及时添加小编微信:sohu-tech20兑书~

也许你还想看

(▼点击文章标题或封面查看)

iOS插件化架构探索

2020-11-05

SwiftUI数据流之State&Binding

2020-10-01

【文末有惊喜!】DLNA技术初探

2020-09-17

探秘 App Clips

2020-09-10

【周年福利Round2】都0202年了,您还不会Elasticsearch?

2020-08-13

加入搜狐技术作者天团

千元稿费等你来!

???? 戳这里!


转载:https://blog.csdn.net/SOHU_TECH/article/details/111027153
查看评论
* 以上用户言论只代表其个人观点,不代表本网站的观点或立场