使用 LLVM

前言

目前在做一些编译相关调研。先前写过篇《深入剖析 iOS 编译 Clang / LLVM》和《深入剖析 iOS 编译 Clang / LLVM 直播的 Slides》,内容偏理论。本篇着重对 LLVM 的使用,理论内容会很少,主要是说下如何使用 llvm 来做些事情,会有详细的操作步骤和工程示例。

代码新陈代谢

昨天看了昨天和今天 WWDC22 的 session,看到了苹果为包体积也做了很多工作,甚至不惜改 C ABI的 call convention 来达到此目的。

我很早前就做过一个方案,可以说是一个更好处理代码新陈代谢的方案,那就先说下这个。

方案总体介绍

静态检查无法分析真实使用场景里代码是不是真的用了,或用的是否多。

动态检查来说,以前检查的方式有通过埋点查看相应代码是否有用到,还可以通过类的 isInitialized 方法来统计类是否被用到。第一个方案成本高,第二个方案范围太大,如果类都很大,那么检查结果的意义就没了。因此,需要一个能够动态检查函数和代码块级别是否使用的方法。

一些现有方案和其不可用的地方

下面列两个已有可检查比类更小粒度的方案。

gcov

clang 使用 -fprofile-instr-generate -fcoverage-mapping ,swiftc 使用 -profile-generate -profile-coverage-mapping 生成 .profraw 文件。llvm-profdata merge 转成 .profdata。编译时每个文件会用 GCOVProfiling 生成 .gcno 包含计数和源码的映射关系,运行时用的是 GCDAProfiling 处理回调记录运行时执行了哪些代码。最后 llvm-cov 转成报告,生成工具是 gcov,生成的报告可以看到哪些代码有用到,哪些没有用。

gcov 对于线下测试够用,但无法放到线上使用。

SanitizerCoverage 插桩回调函数

SanitizerCoverage 是 libfuzzer 使用的代码覆盖技术,使用 -fsanitize-coverage=trace-pc-guard 这个编译 flag 插入不同级别的桩,会在程序控制流图的每条边插入__sanitizer_cov_trace_pc_guard

如果只对函数插桩,使用 -fsanitize-coverage=func,trace-pc-guard,只对基本块用 -fsanite-coverage=bb,no-prune,trace-pc-guard。swift 使用 -sanitize-coverage=func-sanitize=undefined 编译 flags。

在回调函数 __sanitizer_cov_trace_pc_guard_init__sanitizer_cov_trace_pc_guard 里实现自己要干的事情,比如对当前插桩地址符号化,运行后就可以得到运行时调用了哪些方法。

使用 SanitizerCoverage 插桩,一个是编译会很慢,另一个是插入范围难控制,上线后各方面影响不可控。SanitizerCoverage 本是用于 fuzzing 测试的一个 llvm pass,因此可以了解 SanitizerCoverage 使用的技术,自建一个专门用于代码新陈代谢的 pass 用来解决 SanitizerCoverage 和 gcov 不好用的问题。

自制可插入指令的 Pass

之所以在编译中间层插入指令而不在编译 frontend 插入代码的原因是,这样做的话能用类似 llvm-mctoll 二进制转中间层 IR 代码的方式,可对第三方这样没有 frontend 源码而只有生成的二进制产物的库进行分析。

在函数中插入执行指令执行自定功能的方法是,用 IRBuilder 使用 SetInsertPoint 设置位置,CreateCall 插入指令,插入在块的初始位置,用的是 dyn_cast(&I) 。CreateCall 调用 LLVMContextFunctionCallee 来自 F.getParent()->getOrInsertFunction,其第一个参数就是要执行我们自定义函数的函数名,第二个参数 FunctionType 是通过 paramTypesType::getVoidTy 根据 LLVMContext 而来。 使用编译属性可以指定要控制的函数,pass 可用 getGlobalVariable 取到 llvm.global.annotations ,也就是所有编译属性。

F.getName().front()\x01 表示的是 OC 方法,去掉这个前缀可得到方法名,.contains("_block") 是闭包函数。F.getName().startswith("_Z") 是 C++ 函数(_Z__Z___Z 都是)。使用 F.getName() 判读读取一个映射表进行对比,也可以达到通过编译属性设置控制指定函数的效果。映射表里设置需要线上验证的函数集合。然后,处理函数和块计数与源码的映射关系,编译加入处理自制 pass 记录运行时代码执行情况的回调。

使用

pass 代码编译生成 dylib 后,在 Xcode 中使用需要替换 clang 为编译 pass 的 clang,编译 pass 的版本也要对应上。在 xconfig 中设置构建命令选项 OTHER_CFLAGS OTHER_CPLUSPLUSFLAGS 是 -Xclang -load -Xclang $pass,CC CXX 设置为替换的 clang。调试是用的 opt,可换成 opt scheme,在 Edit Scheme 里设置 opt 的启动参数。

llvm 14 后只能使用 new pm,legcy pm(pass manager) 通过 Xlang 给 clang 传参,而 new pm 不行,new pm 的 pass 让 clang 加载,一种方法是使用 -fpass-plugin,另一种是把 pass 加到 clang 的 pipeline 里,重新构建对应版本的 clang。具体来说就是 PassBuilder 的回调 registerPipelineStartEPCallback 允许 ModulePassManager 使用 addPass 添加我们的 pass。

方案是这样,接下来的内容是偏实际的一些操作,你也可以跟着实践下,毕竟本篇是说怎么使用 LLVM 嘛。

先看看 gcov 的用法。

生成代码覆盖率报告

命令行中开启代码覆盖率的编译选项,参看官方指南:Source-based Code Coverage 。

通过一个例子实践下。

建个 C 代码文件 main.m :

  #include 

  int main(int argc, char *argv[])
  {
      printf("hi there!\n");
      return 0;
  }


  void foo() {
      return;
  }

加上代码覆盖率的编译参数进行编译。

xcrun clang -fprofile-instr-generate -fcoverage-mapping main.m -o mainCoverage

运行生成的 mainCoverage 会生成 default.profraw 文件,自定义文件名使用 LLVM_PROFILE_FILE="my.profraw" ./mainCoverage 命令。

对于 Swift 文件也没有问题,建一个 swift 文件 hi.swift

hi()

func hi() {
    print("hi")
}

func f1() {
    doNothing()
    func doNothing() {}
}

通过 swiftc 来编译

swiftc -profile-generate -profile-coverage-mapping hi.swift

从上面 clang 和 swiftc 的命令可以看出,clang 使用的是 -fprofile-instr-generate 和 -fcoverage-mapping 编译 flags,swiftc 使用的是 -profile-generate 和 -profile-coverage-mapping 编译 flags。

编译出的可执行文件 mainCoverage 和 hi 都会多出

生成代码覆盖率前建立索引,也就是生成 .profdata 文件。通过 xcrun 调用 llvm-prodata 命令。命令如下:

xcrun llvm-profdata merge -sparse my.profraw -o my.profdata

合并多个 .profdata 文件使用下面的命令:

llvm-profdata merge one.profdata two.profdata -output all.profdata

使用 llvm-cov 命令生成行的报告

xcrun llvm-cov show ./mainCoverage -instr-profile=my.profdata

输出:

    1|       |#include 
    2|       |
    3|       |int main(int argc, char *argv[])
    4|      1|{
    5|      1|    printf("hi there!\n");
    6|      1|    return 0;
    7|      1|}
    8|       |
    9|      0|void foo() {
   10|      0|  return;
   11|      0|}

上面的输出可以看到,9到11行是没有执行的。

从文件层面看覆盖率,可以通过下面的命令:

xcrun llvm-cov report ./mainCoverage -instr-profile=my.profdata

输出的报告如下:

Filename                                  Regions    Missed Regions     Cover   Functions  Missed Functions  Executed       Lines      Missed Lines     Cover    Branches   Missed Branches     Cover
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
/Users/mingdai/Downloads/PTest/main.m           2                 1    50.00%           2                 1    50.00%           7                 3    57.14%           0                 0         -
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
TOTAL                                           2                 1    50.00%           2                 1    50.00%           7                 3    57.14%           0                 0         -

生成 JSON 的命令如下:

xcrun llvm-cov export -format=text ./mainCoverage -instr-profile=my.profdata > my.json

从生成的 json 文件可以看到这个生成的报告有5个统计项,分别是函数、实例化、行、区域和分支。

更多报告生成选型参看 llvm-cov 官方说明 。

Xcode 配置生成代码覆盖率报告

在 Xcode 里开启代码覆盖率,先选择"Edit Scheme...",再在 Test 中的 Options 里勾上 Gather coverage for all targets 或 some targets。

在 Build Setting 中进行设置,添加 -profile-generate 和 -profile-coverage-mapping 编译 flags。

调用 llvm profile 的 c 函数生成 .profraw 文件。代码见:

// MARK: - 代码覆盖率
func codeCoverageProfrawDump(fileName: String = "cc") {
    let name = "\(fileName).profraw"
    let fileManager = FileManager.default
    do {
        let documentDirectory = try fileManager.url(for: .documentDirectory, in: .userDomainMask, appropriateFor:nil, create:false)
        let filePath: NSString = documentDirectory.appendingPathComponent(name).path as NSString
        __llvm_profile_set_filename(filePath.utf8String)
        print("File at: \(String(cString: __llvm_profile_get_filename()))")
        __llvm_profile_write_file()
    } catch {
        print(error)
    }
}

codeCoverageProfrawDump 函数放到 applicationWillTerminate 里执行,就可以生成在本次操作完后的代码覆盖率。

通过 llvm-cov report 命令将 .profraw 和生成的 Mach-O 文件关联输出代码覆盖率的报告,完整实现和调试看,参看 DaiMingCreationToolbox 里的 FundationFunction.swift 和 SwiftPamphletAppApp.swift 文件。

Fuzzing 介绍

另外,llvm 还提供另一种覆盖率输出,编译参数是 -fprofile-arcs -ftest-coverage 和链接参数 -lgcov,运行程序后会生成 .gcda 和 .gcno 文件,使用 lcov 或 gcovr 就可以生成一个 html 来查看覆盖率。

之所以能够输出代码覆盖率,主要是 llvm 在编译期间给函数、基本块(IDA 中以指令跳转当分界线的每块代码)和边界(较基本块多了执行边界信息)插了桩。插桩的函数也有回调,如果想使用插桩函数的回调,有源码可以使用 SanitizerCoverage, 官方说明见:SanitizerCoverage。

SanitizerCoverage 用的是 ModulePass,是 llvm 提供的 ModulePass、CallGraphSCCPass、FunctionPass、LoopPass、RegionPass 这几个插桩 pass 中的一种。SanitizerCoverage 还应用在 llvm 的 Fuzz 生成器 libfuzzer 上,libfuzzer 可以从硬件和 IR 层面进行插桩获取程序的覆盖率。

Fuzzing 生成器的概念最早是威斯康星大学 Barton Miller 教授在他的课上提出的,后应用于安全测试领域,比如 PROTOS 测试集项目、网络协议安全测试 SPIKE、最普遍应用的文件 Fuzzing 技术 Peach、语法模板 funfuzz 和 Dom fuzz 的 Domato、分析 llvm IR 符号执行平台 Klee、源码插桩和 QEMU 模式实现代码覆盖 fuzzing 的 AFL 和刚才我提到的 llvm 自带基于 SanitizerCoverage 的 libfuzzer、挖掘系统内核漏洞的系统函数调用模板 Fuzzing 库 syzkaller 和基于 libfuzzer 和 protobuf 做的 libprotobuf-mutator、组合了 libFuzzer,AFL++ 和 Honggfuzz 还有 ClusterFuzz 的平台 OSS-Fuzz。

其中 Spike 是网络协议开源 Fuzzing 工具,由 Dave Aitel 编写的,Dave Aitel 是《the Hacker's Handbook》(《黑客防范手册》)和《the Shellcoder's Handbook》(《黑客攻防技术宝典:系统实战篇》)的作者。网络协议分析工具主要是 WireShark 和应用层的 SockMon(特定进程、协议、IP、函数抓包),和 IDA、OD 等工具结合找到软件执行的网络命令分析数据包的处理过程。Spike 可以对数据发包收包,还可以构造数据包自动化做覆盖更大的测试。

QEMU 是 2003 年 Fabrice Bellard 做的虚拟机,包含很多架构和硬件设备的模拟执行,原理是 qemu TCG 模块把机器代码转成 llvm IR,这个过程叫做反编译,关于反编译可以参考这篇论文《An In-Depth Analysis of Disassembly on Full-Scale x86/x64 Binaries》。之所以可以做到反编译是因为机器指令和汇编指令是一一对应的,可以先将机器指令翻译成机器对应的汇编,IR 实际上就是一个不遵循硬件设计的指令集,和硬件相关的汇编会按照 IR 的设计翻译成机器无关的 IR 指令。这样做的好处就是无论是哪个机器上的可执行二进制文件都能够统一成一份标准的指令表示。IR 也可以设计成 DSL,比如 Ghidra 的 Sleigh 语言。

反编译后,再将得到的 IR 转成目标硬件设备可执行机器语言,IDA Pro 也是用的这个原理,IDA 的 IR 叫 microcode,IDA 的插件 genmc 专门用来显示 microcode,HexRaysDeob 是利用 microcode 来做混淆的库。

qemu 做的是没有源码的二进制程序的分析,是一个完整的虚拟机工具,其中只有 tcg 模块的一部分功能就可以实现模拟 CPU 执行,执行过程中插入分析的代码就能够方便的访问寄存器,对地址或指令 hook,实现这些功能的库是 Unicorn,还有功能更多些的 Qiling。Qiling 和 Unicorn 不同的是 Unicorn 只完成了 CPU 指令的仿真,而 Qiling 可以处理更高层次的动态库、系统调用、I/O 处理或 Mach-O 加载等,Qiling 还可以通过 Python 开发自己动态分析工具,运行时进行 hotpatch,支持 macOS。基于 qemu 还有可以访问执行的所有代码和数据做回放程序执行过程的 PANDA、虚拟地址消毒剂 QASan、组合 Klee 和 qemu 的 S2E。

能够使用 js 来开发免编译功能的 Frida 也可以用于 Fuzzing,在 iOS 平台上的 Fuzzing 参看1、2、3,使用工具见 iOS-messaging-tools。

更多 Fuzzing 资料可以参看 GitHub 上一份整理好的 Awesome-Fuzzing。

可见 Fuzzing 生成器应用范围非常广,除了获取代码覆盖率,还能够进行网络安全分析和安全漏洞分析。本文主要是基于源码插桩,源码插桩库主要是 libfuzzer、AFL++、honggfuzz、riufuzz(honggfuzz 二次开发)。

AFL++ 在有源码情况下原理和 libfuzzer 差不多,只是底层不是用的 SanitizerCoverage,而是自实现的一个 pass,没有源码时 AFL++ 用的就是 qemu 中 TCG 模块的代码,在反编译为 IR 时进行插桩。更多 AFL++ 应用参见《What is AFL and What is it Good for?》

Fuzzing 除了代码覆盖率,还需要又能够创建更多输出条件,记录执行路径,目标和方向是找出程序运行时在什么输入条件和路径下会有问题。但仅是检测哪些代码有用到,实际上只要用上 Fuzzing 的代码覆盖率就可以了。

SanitizerCoverage 插桩回调函数

那接下来实践下 libfuzzer 中实现代码覆盖率的 SanitizerCoverage 技术。

命令行执行

xcrun clang -fembed-bitcode main.m -save-temps -v -fsanitize-coverage=trace-pc-guard

使用 -fsanitize-coverage=trace-pc-guard 这个编译 flag 插入不同级别的桩,会在程序控制流图的每条边插入:

__sanitizer_cov_trace_pc_guard(&guard_variable)

如果只对函数插桩,使用 -fsanitize-coverage=func,trace-pc-guard,只对基本块用 -fsanite-coverage=bb,no-prune,trace-pc-guard。swift 使用 -sanitize-coverage=func-sanitize=undefined 编译 flags。

使用插桩函数回调,先在 Xcode 的 Other C Flags 里添加 -fsanitize-coverage=trace-pc-guard。swift 就是在 Other Swift Flags 里添加 -sanitize-coverage=func-sanitize=undefined

在回调函数里实现自己要干的事情,比如对当前插桩地址符号化,代码如下:

  #import 

  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) {
      if (!*guard) return;
      void *PC = __builtin_return_address(0);
      Dl_info info;
      dladdr(PC, &info);
      printf("调用了方法: %s \n", info.dli_sname);
  }

运行后就可以得到运行时调用了哪些方法。

有了这些数据就可以统计哪些方法调用了,调用了多少次。通过和全源码对比,取差集能够找到运行中没有执行的方法和代码块。其实利用 Fuzzing 的概念还可以做很多分析的工作,全面数据化观测代码执行情况。可以到我的 GCDFetchFeed 工程中,打开 AppDelegate.m 里的两个插桩回调方法的注释来试用。

停止试用插桩,可以用 __attribute__((no_sanitize("coverage"))) 编译属性。或者通过黑名单或白名单,分别是 -fsanitize-coverage-ignorelist=blocklist.txt-fsanitize-coverage-allowlist=allowlist.txt,范围可以试文件夹、单个文件或者单个方法。

allowlist.txt 示例:

  # 允许文件夹里所有文件
  src:bar/*
  # 特定源文件
  src:foo/a.cpp
  # 允许文件中所有函数
  fun:*

blocklist.txt 示例:

  # 禁用特定源文件
  src:bar/b.cpp
  # 禁用特定函数
  fun:*myFunc*

上线前检查出的没有用到的代码,并不表示上线后用户不会用到,比如 AB 实验、用户特殊设置、不常见 Case 等。这就可以利用 allowlist.txt 将部分不确定的代码放到线上去检测,或者通过自动插入埋点灰度检测,这些不确定的代码不是主链路的,因此检测影响范围会很低。

SanitizerCoverage 本身是一个 llvm pass,代码在 llvm 工程的 llvm-project/llvm/lib/Transforms/Instrumentation/SanitizerCoverage.cpp 路径下,那么怎么实现一个自定义的 pass 呢?

先把 llvm 装到本地。

安装 LLVM

使用 homebrew,命令如下:

brew install llvm@13

@13 表示 llvm 的版本。安装后使用路径在是 /usr/local/opt/llvm/,比如 cmake 构建编译环境可以使用下面的命令:

$LLVM_DIR=/usr/local/opt/llvm/lib/cmake/llvm cmake ..

可以用 Visual Studio Code 开发 pass,安装微软的 C/C++ 的 extension,在 C/C++ Configurations 里把 /usr/local/opt/llvm/include/ 加入到包含路径中。

llvm 的更新使用 brew upgrade llvm

llvm 也可以通过源码来安装,执行如下命令即可:

git clone https://github.com/llvm/llvm-project.git
cd llvm-project
git checkout release/14.x
mkdir build
cd build
cmake -DCMAKE_BUILD_TYPE=Release -DLLVM_TARGETS_TO_BUILD=host -DLLVM_ENABLE_PROJECTS=clang ../llvm
cmake --build .

这里的 cmake 参数 -DLLVM_ENABLE_PROJECTS=clang 表示也会构建 clang 工具。如果还要加上 lld 以在构建时能够用自己的 pass,可以直接加成 -DLLVM_ENABLE_PROJECTS="clang;lld"

自定义安装目录的话,增加 -DCMAKE_INSTALL_PREFIX=/home/user/custom-llvm 。然后在设置路径 export PATH=$PATH:/home/user/custom-llvm/bin

-G 编译选项选择 Ninja 编译速度快。

各种设置整到一起:

cmake -G "Ninja" -DCMAKE_BUILD_TYPE=Release -DLLVM_TARGETS_TO_BUILD=host -DLLVM_ENABLE_PROJECTS="clang;lld" -DCMAKE_INSTALL_PREFIX=/Users/mingdai/Downloads/PTest/my-llvm-bin ../llvm

自制 Pass

Pass 介绍

llvm 属于 multi-pass 编译器,LLVM Pass 管理器是处理 pass 执行的注册和时序安排。曾有两个 pass 管理器,一个是 New Pass 管理器也叫 Pass 管理器,另一个是 Legacy Pass 管理器。New Pass 目前是默认的管理器,Legacy Pass 在 LLVM 14 中被废弃。Legacy 和 New 两个 pass 管理器在使用上最大的区别就是,Legacy 会注册一个新的命令选项,而 New Pass 只用定义一个 pass。另外 Legacy 需要实现 print 成员方法来打印,需要在通过 opt 通过传递 -analyze 命令行选项来运行,而 New Pass 管理器是不用的,只需要实现 printing pass。

总的来说
Legacy

  • 基于继承性
  • 分析和打印 pass 之间没有区别
  • 注册时加载所有需要的 pass
  • 不变的 pass 执行调度
  • Transformation passes 定义了它们在执行前保证保留的内容

Legacy 的 pass 类

  • llvm::Pass
    • llvm::ModulePass
    • llvm::FunctionPass
    • llvm::PassRegistry

New

  • 基于 CRTP、mixin 和 concept-model 的 idiom-based
  • 在执行过程中,根据需要有条件的加载依赖的 pass(更快、更有效)
  • Transformation passes 在执行后返回它们所保留的内容

New 的 pass 类

  • llvm::PassInfoMixin
  • llvm::AnalysisInfoMixin
  • llvm::FunctionAnalysisManager
    • 别名类型 llvm::AnalysisManager
  • llvm::ModuleAnalysisManager
    • 别名类型 llvm::AnalysisManager
  • llvm::PreservedAnalysis

LLVM Pass 可以对 LLVM IR 进行优化。优化表现在 Pass 可以对 IR 进行分析和转换,因此 Pass 主要也是分为分析(analysis)和转换(transform)两类。

分析里有数据流分析技术,分为以下三种:

  • Reaching-Definition Analysis 到达定值分析
  • Live-Variable Analysis 活跃变量分析
  • Available-Expression Analysis 可用表达式分析

一些常用的优化方法,比如删除计算结果不会使用的语句、删除归纳变量、删除公共子表达式、进入循环前就对不管循环多少次都是同样结果的表达式进行求值、快的操作替换慢操作、用可推导出值是常量的表达式来替代表达式等。

编写优化的几个方法。完整代码参看这里。

插入新指令:

  • 直接通过类或命名的构造函数。
  • 使用 llvm::IRBuilder<> 模板类。

删除指令:

  • llvm::Instruction::eraseFromParent() 成员函数

替换存在指令:

  • llvm::ReplaceInstWithInst() 函数
    • ~#include "llvm/Transforms/Utils/BasicBlockUtils.h"~

直接改指令

  • llvm::User::setOperand() 成员函数

Value ⇒ ConstantInt 类型转换:

  Type _t;
  ConstantInt* val = dyn_cast(_t);

获取 ConstantInt 类的值

  ConstantInt* const_int;
  uint64_t val = const_int->getZExtValue();

替换某个指令

  Instruction inst;
  // 替换,只是替换了引用,并没删
  inst.replaceAllUsesWith(val);

  // 删除
  if(inst->isSafeToRemove())
      inst->eraseFromParent();

对应的 IR 代码

  ; 执行前
  %12 = load i32, i32* %2, align 4
  %13 = add nsw i32 %12, 0
  store i32 %13, i32* %3, align 4
  ; 只替换指令引用
  %12 = load i32, i32* %2, align 4
  %13 = add nsw i32 %12, 0          
  store i32 %12, i32* %3, align 4
  %12 = load i32, i32* %2, align 4
  store i32 %12, i32* %3, align 4
  Instruction referencing instruction not embedded in a basic block!
    %12 = load i32, i32* %2, align 4
     = add nsw i32 %12, 0

建立新指令

  // 取出第一个操作数
  Value* val = inst.getOperand(0);
  // 确定新指令的插入位置
  IRBuilder<> builder(&inst);
  // val << 1
  Value* newInst = builder.CreateShl(val, 1);
  // 替换指令
  inst.replaceAllUsesWith(newInst);

Analysis pass 的 print pass 是基于一个 Transformation pass,会请求原始 pass 分析的结果,并打印这些结果。会注册一个命令行选项 print

实现 pass 要选择是 Analysis 还是 Transformation,也就是要对进行输入 IR 的分析还是进行转换来决定采用哪种。选择 Transformation 通常继承 PassInfoMixin。Analysis 继承 AnalysisInfoMixin。

pass 生成的插件分为动态和静态的。静态插件不需要在运行时用 -load-pass-plugin 选项进行加载,但需要在 llvm 工程中设置 CMake 重新构建 opt。

做自己 pass 前可以先了解下 llvm 内部的 pass 示例,可以先从两个最基本的 Hello 和 Bye 来。比较实用的是一些做优化的 pass,这些 pass 也是学习写 pass ,了解编译器如何工作的重要资源。许多 pass 都实现了编译器开发理论中著名的概念。比如优化 memcpy 调用(比如用 memset 替换)的 memcpyopt 、简化 CFG IRTransforms、总是内联用 alwaysinline 修饰的函数的 always-inline 、死代码消除的 dce 和删除未使用的循环的 loop-deletion。

自制插入指令 pass

接下来,怎么在运行时插入指令来获取我们需要代码使用情况。完整代码可以在这里 MingPass 拉下代码参考进行修改调试。

个 pass 功能是在运行时环境直接在特定位置执行指定的函数。先写个要执行的函数,新建个文件 loglib.m,代码如下:

  #include 

  void runtimeLog(int i) {
    printf("计算结果: %i\n", i);
  }

再到 MingPass.cpp 中包含模块头文件

  #include "llvm/IR/Module.h"

会用到 Module::getOrInsertFunction 函数来给 loglib.m 的 runtimeLog 做声明。

更改 runOnFunction 函数,代码如下:

  virtual bool runOnFunction(Function &F) {
      // 从运行时库中获取函数
      LLVMContext &Context = F.getContext();
      std::vector paramTypes = {Type::getInt32Ty(Context)};
      Type *retType = Type::getVoidTy(Context);
      FunctionType *funcType = FunctionType::get(retType, paramTypes, false);
      FunctionCallee logFunc = F.getParent()->getOrInsertFunction("runtimeLog", funcType);
    
      for (auto &BB : F) {
      for (auto &I : BB) {
          if (auto *op = dyn_cast(&I)) {
          IRBuilder<> builder(op);
                
          // 在 op 后面加入新指令
          builder.SetInsertPoint(&BB, ++builder.GetInsertPoint());
          // 在函数中插入新指令
          Value* args[] = {op};
          builder.CreateCall(logFunc, args);

          return true;
          } // end if
      }
      }
      return false;
  }

在 build 目录下 make 出 pass 的 so 后,链接 main.m 和 loglib.m 的产物成可执行文件,命令如下:

clang -c loglib.m
/usr/local/opt/llvm/bin/clang -flegacy-pass-manager -Xclang -load -Xclang build/src/libMingPass.so -c main.m
clang main.o loglib.o
./a.out

输入数字4后,打印如下:

4
计算结果: 6
6

更多自制 pass

可以在这里查看,代码里有详细注释。这里先留个白,后面再添加内容。

IR

你会发现开发 pass 需要更多的了解 IR,才可以更好的控制 LLVM 前端处理的高级语言。接下来我会说下那些高级语言的特性是怎么在 IR 里表现的。先介绍下 IR。

IR 介绍

LLVM IR(Intermediate Representation) 可以称为中间代码,是 LLVM 整个编译过程的中间表示。

llvm ir 的基础块里的指令是不可跳转到基础块的中间或尾部,只能从基础块的第一个指令进入基础块。

下面是 ir 的几个特点:

  • llvm ir 不是机器代码而是生成机器代码之前的一种有些看起来像高级语言的,比如函数和强类型,有些看起来像低级程序集,比如分支和基本块。
  • llvm ir 是强类型。
  • llvm 没有 sign 和 unsign 整数区别。
  • 全局符号用 @ 符号开头。
  • 本地符号用 % 符号开头。
  • 必须定义和声明所有符号。

IR 指令

常用指令

  • alloca:分配栈空间
  • load:从栈和全局内存读值
  • store:将值写到栈或全局内存
  • br:分支(条件或非条件)
  • call:调用函数
  • ret:从一个函数返回,可能会带上一个返回值
  • icmp/fcmp:比较整型或浮点值
  • add/sub/mul:整数二进制算术运算
  • fadd/fsub/fmul:浮点二进制算术运算
  • sdiv/udiv/fdiv:有符号位整数/无符号位整数/浮点除法
  • shl/shr:位向左/向右
  • lshr/ashr:逻辑/算术右移
  • and/or/xor:位逻辑运算(没有 not!)

常用特殊 ir 指令

  • select:根据一个没有 IR 级别分支的条件选择一个值。
  • phi:根据当前基本块前身选择一个值。
  • getelementpointer:获取数组或结构体里子元素的地址(不是值)。官方说明[[https://llvm.org/docs/GetElementPtr.html][The Often Misunderstood GEP Instruction]]。
  • extractvalue:从一个数组或结构体中提取一个成员字段的值(不是地址)。
  • insertvalue:将一个值添加给数组或结构体的成员字段。

ir 转换指令

  • bitcast:将一个值转成给定类型而不改变它的位。
  • trunc/fptrunc:将一个类型的整数/浮点值截断为一个更小的整数/浮点类型。
  • zext/sext/fpext:将一个值扩展到一个更大的整数/浮点类型上。
  • fptoui/fptosi:将一个浮点值转换为无符号/有符号位的整数类型。
  • uitofp/sitofp:将一个无符号/有符号位整数值转换为浮点类型。
  • ptrtoint:将指针转成整数。
  • inttoptr:将整数值转成指针类型。

ir 库的 header 地址在 include/llvm/IR ,源文件在 lib/IR ,文档 llvm Namespace Reference。所有类和函数都在 llvm 命名空间里。

主要基础类的说明如下:

  • llvm::Module:ir 的容器类的最高级。
  • llvm::Value:所有可作为其他值或指令操作数的基类。
    • llvm::Constant
      • llvm::ConstantDataArray (Constants.h)
      • llvm::ConstantInt (Constants.h)
      • llvm::ConstantFP (Constants.h)
      • llvm::ConstantStruct (Constants.h)
      • llvm::ConstantPointerNull (Constants.h)
      • llvm::Function
      • llvm::GlobalVariable
    • llvm::BasicBlock
    • llvm::Instruction
      • Useful X-macro header: Instruction.def
      • llvm::BinaryOperator (InstrTypes.h)
        • add, sub, mul, sdiv, udiv, srem, urem
        • fadd, fsub, fmul, fdiv, frem
        • shl, lshr, ashr, and, or, xor
      • llvm::CmpInst (InstrTypes.h)
        • llvm::ICmpInst (Instructions.h)
        • llvm::FCmpInst (Instructions.h)
      • llvm::UnaryInstruction (InstrTypes.h)
        • llvm::CastInst (Instrtypes.h)
      • llvm::BitCastInst (Instructions.h)
  • llvm::Type:代表所有的 IR 数据类型,包括原始类型,结构类型和函数类型。

C 调用 LLVM 接口

项目在:CLLVMCase

这是代码:

  /*
  int sum(int a, int b) {
      return a + b;
  }
  ,*/
  void csum() {
      LLVMModuleRef module = LLVMModuleCreateWithName("sum_module");
      LLVMTypeRef param_types[] = {LLVMInt32Type(), LLVMInt32Type()};
    
      // 函数参数依次是函数的类型,参数类型向量,函数数,表示函数是否可变的布尔值。
      LLVMTypeRef ftype = LLVMFunctionType(LLVMInt32Type(), param_types, 2, 0);
      LLVMValueRef sum = LLVMAddFunction(module, "sum", ftype);
    
      LLVMBasicBlockRef entry = LLVMAppendBasicBlock(sum, "entry");
    
      LLVMBuilderRef builder = LLVMCreateBuilder();
      LLVMPositionBuilderAtEnd(builder, entry);
    
      // IR 的表现形式有三种,一种是内存中的对象集,一种是文本语言,比如汇编,一种是二进制编码字节 bitcode。
    
      LLVMValueRef tmp = LLVMBuildAdd(builder, LLVMGetParam(sum, 0), LLVMGetParam(sum, 1), "tmp");
      LLVMBuildRet(builder, tmp);
    
      char *error = NULL;
      LLVMVerifyModule(module, LLVMAbortProcessAction, &error);
      LLVMDisposeMessage(error);
    
      // 可执行引擎,如果支持 JIT 就用它,否则用 Interpreter。
      LLVMExecutionEngineRef engine;
      error = NULL;
      LLVMLinkInMCJIT();
      LLVMInitializeNativeTarget();
      if (LLVMCreateExecutionEngineForModule(&engine, module, &error) != 0) {
      fprintf(stderr, "Could not create execution engine: %s\n", error);
      return;
      }
      if (error)
      {
      LLVMDisposeMessage(error);
      return;
      }
    
      long long x = 5;
      long long y = 6;
    
      // LLVM 提供了工厂函数来创建值,这些值可以被传递给函数。
      LLVMGenericValueRef args[] = {LLVMCreateGenericValueOfInt(LLVMInt32Type(), x, 0), LLVMCreateGenericValueOfInt(LLVMInt32Type(), y, 0)};
    
      LLVMInitializeNativeAsmPrinter();
      LLVMInitializeNativeAsmParser();
    
      // 函数调用
      LLVMGenericValueRef result = LLVMRunFunction(engine, sum, 2, args);
      printf("%lld\n", LLVMGenericValueToInt(result, 0));
    
      // 生成 bitcode 文件
      if (LLVMWriteBitcodeToFile(module, "sum.bc") != 0) {
      fprintf(stderr, "Could not write bitcode to file\n");
      return;
      }
    
      LLVMDisposeBuilder(builder);
      LLVMDisposeExecutionEngine(engine);
  }

Swift 调用 LLVM 接口

llvm 的接口还可以通过 swift 来调用。

先创建一个 module.modulemap 文件,创建 LLVMC.h 和 LLVMC.c 文件,自动生成 SwiftLLVMCase-Bridging-Header.h。设置 header search paths 为 llvm 所在路径 /usr/local/opt/llvm/include ,library search paths 设置为 /usr/local/opt/llvm/lib 。将 /usr/local/opt/llvm/lib/libLLVM.dylib 加到 Linked Frameworks and Libraries 里。

module.modulemap 内容

  module llvm [extern_c] {
      header "LLVMC.h"
      export *
  }

LLVMC.h 里设置要用到的 llvm 的头文件,比如:

  #ifndef LLVMC_h
  #define LLVMC_h

  #include 
  #include 
  #include 
  #include 
  #include 
  #include 
  #include 
  #include 
  #include 
  #include 
  #include 
  #include 
  #include 
  #include 
  #include 
  #include 
  #include 
  #include 
  #include 

  #endif /* LLVMC_h */

在 swift 中写如下代码试试

  import Foundation
  import llvm

  func hiIR() {
      let module = LLVMModuleCreateWithName("HiModule")
      LLVMDumpModule(module)
      LLVMDisposeModule(module)
  }

  hiIR()

执行结果如下:

; ModuleID = 'HiModule'
source_filename = "HiModule"

下面一个简单的 c 函数

  int sum(int a, int b) {
    return a + b;
  }

使用 llvm 的接口写对应的 IR 代码如下:

  func cSum() {
      let m = Module(name: "CSum")
      let bd = IRBuilder(module: m)
      let f1 = bd.addFunction("sum", type: FunctionType([IntType.int32, IntType.int32], IntType.int32))
    
      // 添加基本块
      let entryBB = f1.appendBasicBlock(named: "entry")
      bd.positionAtEnd(of: entryBB)
    
      let a = f1.parameters[0]
      let b = f1.parameters[1]
    
      let tmp = bd.buildAdd(a, b)
      bd.buildRet(tmp)
    
      m.dump()
    
  }

dump 出对应 IR 如下:

; ModuleID = 'CSum'
source_filename = "CSum"

define i32 @sum(i32 %0, i32 %1) {
entry:
%2 = add i32 %0, %1
ret i32 %2
}

对于控制流函数,比如下面的 swift 函数:

  func giveMeNumber(_ isBig : Bool) -> Int {
      let re : Int
      if !isBig {
      // the fibonacci series (sort of)
      re = 3
      } else {
      // the fibonacci series (sort of) backwards
      re = 4
      }
      return re
  }

使用 llvm 接口编写 IR,代码如下:

  func controlFlow() {
      let m = Module(name: "CF")
      let bd = IRBuilder(module: m)
      let f1 = bd.addFunction("calculateFibs", type: FunctionType([IntType.int1], FloatType.double))
      let entryBB = f1.appendBasicBlock(named: "entry")
      bd.positionAtEnd(of: entryBB)
    
      // 给本地变量分配空间 let retVal : Double
      let local = bd.buildAlloca(type: FloatType.double, name: "local")
    
      // 条件比较 if !backward
      let test = bd.buildICmp(f1.parameters[0], IntType.int1.zero(), .equal)
    
      // 创建 block
      let thenBB = f1.appendBasicBlock(named: "then")
      let elseBB = f1.appendBasicBlock(named: "else")
      let mergeBB = f1.appendBasicBlock(named: "merge")
    
      bd.buildCondBr(condition: test, then: thenBB, else: elseBB)
    
      // 指到 then block
      bd.positionAtEnd(of: thenBB)
      let thenVal = FloatType.double.constant(1/89)
      bd.buildBr(mergeBB) // 到 merge block
    
      // 指到 else block
      bd.positionAtEnd(of: elseBB)
      let elseVal = FloatType.double.constant(1/109)
      bd.buildBr(mergeBB) // 到 merge block
    
      // 指到 merge block
      bd.positionAtEnd(of: mergeBB)
      let phi = bd.buildPhi(FloatType.double, name: "phi_example")
      phi.addIncoming([
      (thenVal, thenBB),
      (elseVal, elseBB)
      ])
      // 赋值给本地变量
      bd.buildStore(phi, to: local)
      let ret = bd.buildLoad(local, type: FloatType.double, name: "ret")
      bd.buildRet(ret)
    
      m.dump()    
  }

输出对应 IR 代码:

; ModuleID = 'CF'
source_filename = "CF"

define double @giveMeNumber(i1 %0) {
entry:
  %local = alloca i32, align 4
  %1 = icmp eq i1 %0, false
  br i1 %1, label %then, label %else

then:                                             ; preds = %entry
  br label %merge

else:                                             ; preds = %entry
  br label %merge

merge:                                            ; preds = %else, %then
  %phi_example = phi i32 [ 3, %then ], [ 4, %else ]
  store i32 %phi_example, i32* %local, align 4
  %ret = load i32, i32* %local, align 4
  ret i32 %ret
}

这里有完整代码 SwiftLLVMCase。

解释执行 bitcode(IR)

IR 的表现形式有三种,一种是内存中的对象集,一种是文本语言,一种是二进制编码字节 bitcode。

对于 Intel 芯片可以通过 Pin,arm 架构可以用 DynamoRIO,目前 DynamoRIO 只支持 Window、Linux 和 Android 系统,对 macOS 的支持还在进行中。另一种方式是通过基于 llvm 的 interpreter 开发来实现解释执行 bitcode,llvm 用很多 C++ 的接口在内存中操作,将可读的文本文件解析到内存中,编译过程文本的 IR 不会生成,只会生成一种紧凑的二进制表示,也就是 bitcode。下面具体说下怎么做。

先构建一个支持 libffi 的 llvm。编译 llvm 源码时加上 libffi 的选项来打开 DLLVM_ENABLE_FFI 的选项打开 libffi,编译命令如下:

cmake -G Ninja -DLLVM_ENABLE_FFI:BOOL=ON ../llvm

创建一个项目。cmake 文件里注意设置自己的编译生成的 llvm 路径,还有 llvm 源码路径,设置这个路径主要是为了用安装 llvm 时没有包含的 ExecutionEngine/Interpreter/Interpreter.h 头文件。

实现方式是通过访问 llvm 的 ExcutionEngine 进行 IR 指令解释执行。声明一个可访问 ExcutionEngine 内部的类 PInterpreter,代码如下:

  // 使用 public 访问内部
  class PInterpreter : public llvm::ExecutionEngine,
               public llvm::InstVisitor {
      public:
      llvm::GenericValue ExitValue;
      llvm::DataLayout TD;
      llvm::IntrinsicLowering *IL;
      std::vector ECStack;
      std::vector AtExitHandlers;
  };

然后声明要用的方法。

  class MInterpreter : public llvm::ExecutionEngine {
      public:
      llvm::Interpreter *interp;
      PInterpreter *pItp;
      llvm::Module *module;
    
      explicit MInterpreter(llvm::Module *M);
      virtual ~MInterpreter();
    
      virtual void run();
      virtual void execute(llvm::Instruction &I);
    
      // 入口
      virtual int runMain(std::vector args,
              char * const *envp = 0);
    
      // 遵循 ExecutionEngine 接口
      llvm::GenericValue runFunction(
      llvm::Function *F,
      const std::vector &ArgValues
      );
      void *getPointerToNamedFunction(const std::string &Name,
                      bool AbortOnFailure = true);
      void *recompileAndRelinkFunction(llvm::Function *F);
      void freeMachineCodeForFunction(llvm::Function *F);
      void *getPointerToFunction(llvm::Function *F);
      void *getPointerToBasicBlock(llvm::BasicBlock *BB);
  };

如上面代码所示,因为要执行 IR,所以用到获取 IR 的函数和基本块地址的方法,getPointerToFunction 和 getPointerToBasicBlock。最后再执行指令时,先打印出指令,然后进行执行,代码如下:

  class MingInterpreter : public MInterpreter {
      public:
      MingInterpreter(Module *M) : MInterpreter(M) {};
      virtual void execute(Instruction &I) {
      I.print(errs());
      MInterpreter::execute(I);
      }
  };

完整代码参看 MingInterpreter。

项目是基于 c 语言,可以使用 llvm include 里的 llvm-c/ExecutionEngine.h 接口头文件,使用 c 来编写。OC 和 Swift 项目还需要根据各自语言特性进行开发完善解释功能。

你可能感兴趣的:(使用 LLVM)