[llvm] 函数插桩的两种方式

函数插桩指利用编译器,在程序中特定的位置插入函数调用。比如当我们希望统计某个函数运行所花的时间时,可以使用如下的Routine

void some_function(int arg1, int arg2)
{
  unsigned long long tik,tok;
  tik = gettimeofday();
  /*** do_something  ***/ 
  tok = gettimeofday();
  printf("elipsed %lld seconds\n", tok-tik);
}

其中增加了tik, tok两个变量用于记录函数开始与结束的时刻,而两次gettimeofday()函数的调用被插入到了原来的程序段前后,完成测量函数运行时间的目的。 这种做法适用于插入少量函数的情况,当大量位置需要插桩时就比较麻烦。当然,也可以将需要插桩的程序段当作函数封装起来,这样只需要修改库函数就可以。视情况而定,在库函数中插桩也不失为一种简便的方法。

然而,有时需要插桩的位置不是函数,或者这些位置之间没有什么共性不能抽象为函数:举例来说,只在函数名中带“test”的函数之前插入函数(C语言中如何检测函数名?);或者在某个特定的指令之前插入函数。这些情况下,要如何继续插桩呢?

显然,我们需要一些比编程语言本身提供的范式更底层的结构才能做到,这就需要在编译器层面完成函数插桩。

LLVM-IR的结构

Cmake

cmake_minimum_required(VERSION 3.10)
project(proj DESCRIPTION "manual build of LLVM IR")
set(CMAKE_BUILD_TYPE Debug)

set(LLVMLibPath "/home/zhao/Disk/build_llvm1405/lib")

function(find_and_add_library target lib path)
  find_library(var-${lib} ${lib} ${path})
  #message(STATUS "lib ${var-${lib}}")
  if(NOT var-${lib})
  message(FATAL_ERROR "cannot find ${lib} in path ${path}!")
  endif() 
  target_link_libraries(${target} PRIVATE ${var-${lib}})
endfunction()


add_executable(my_ir manual_ir.cpp)
target_include_directories(my_ir PRIVATE /home/zhao/Disk/llvm1405/llvm/include)
target_include_directories(my_ir PRIVATE /home/zhao/Disk/build_llvm1405/include)

list(APPEND compile_libs LLVMSupport LLVMIRReader LLVMMIRParser LLVMCore)
foreach(lib ${compile_libs})
find_and_add_library(my_ir ${lib} ${LLVMLibPath})
endforeach()

源码

#include "llvm/ADT/SmallVector.h"
#include "llvm/ADT/StringRef.h"

//#include "llvm/Analysis/Verifier.h"

#include "llvm/IR/CallingConv.h"
#include "llvm/IR/LLVMContext.h"
#include "llvm-c/Core.h"
#include "llvm/IR/Module.h"
#include "llvm/IR/BasicBlock.h"
#include "llvm/IR/Function.h"
#include "llvm/IR/Instructions.h"
#include "llvm/IR/IRBuilder.h"
#include "llvm/Bitcode/BitcodeReader.h"
#include "llvm/Bitcode/BitcodeWriter.h"
#include "llvm/Support/ToolOutputFile.h"
#include "llvm/Support/raw_ostream.h"

using namespace llvm;

LLVMModuleRef createModule()
{
  LLVMModuleRef mod = LLVMModuleCreateWithName("sum.ll");
  //LLVMSetDataLayout(mod, "e-m:e-p270:32:32-p271:32:32-p272:64:64-i64:64-f80:128-n8:16:32:64-S128");
  //LLVMSetTarget(mod, "x86_64-unknown-linux-gnu");

  LLVMSetDataLayout(mod,"e-m:e-p:64:64-i64:64-i128:128-n64-S128");
  LLVMSetTarget(mod,"riscv64");
  return mod;
}

void createFunction(Module *mod){
  LLVMContext &context = mod->getContext();
  FunctionType *FcType = FunctionType::get(Type::getInt32Ty(context),
  {Type::getInt32Ty(context),Type::getInt32Ty(context)},false);

  Function *funcSum = Function::Create(FcType, GlobalValue::ExternalLinkage,"sum",mod);
  funcSum->setCallingConv(CallingConv::C);

  //FunctionCallee FcCallee = mod->getOrInsertFunction("sum", FcType);
  Function::arg_iterator args = funcSum->arg_begin();
  Value *int32_a = args++;
  //int32_a->setName("a");
  Value *int32_b = args++;
  //int32_b->setName("b");

  BasicBlock *labelEntry = BasicBlock::Create(context, "entry", funcSum, 0);
  //IRBuilder<> builder(labelEntry);
  //builder.CreateAlloca();
  AllocaInst *ptrA = new AllocaInst(IntegerType::get(context,32),0, "addr.a",labelEntry);
  //ptrA->setAlignment(Align(8));
  AllocaInst *ptrB = new AllocaInst(IntegerType::get(context,32),0, "addr.b",labelEntry);
  //ptrB->setAlignment(Align(8));
  StoreInst *st0 = new StoreInst(int32_a, ptrA, false, labelEntry);
  //st0->setAlignment(Align(8));
  StoreInst *st1 = new StoreInst(int32_b, ptrB,false, Align(8), labelEntry);

  LoadInst *ld0 = new LoadInst(IntegerType::get(context,32), ptrA, "val_a", false, Align(8), labelEntry);
  LoadInst *ld1 = new LoadInst(IntegerType::get(context,32), ptrB, "val_b", false, Align(8), labelEntry);

  BinaryOperator *addRes = BinaryOperator::Create(Instruction::Add, ld0, ld1,"add", labelEntry);
  ReturnInst *ret = ReturnInst::Create(context, addRes,labelEntry);

}

int main()
{
  Module *mod = unwrap(createModule());
  createFunction(mod);
  mod->print(outs(),nullptr);
  //std::error_code EC;

  //WriteBitcodeToFile(*mod, raw_fd_ostream("sum.bc",EC));


  return 0;
}

LLVM RTTI

runtime type identification

什么是RTTI——从C++的原生RTTI谈起

virtual function与RTTI,什么时候用哪个

IR层插桩

LLVM-IR虽然是比源语言更底层的一种程序表示,但是它在设计上大量参考C语言,仍保留了编程语言中常见的程序流控制语法。在LLVM-IR的文本形式中,函数调用写作如下形式

; 类比为C语言中 int32_t ret = func_call(int32_t arg1, float arg2);
%ret = call i32 @func_call(i32 %arg1, float %arg2)

在LLVM-IR文本形式中,被调用的函数必须在同一文件中已经声明或定义,即必须存在如下两段代码之一,否则编译会出错。

 declare i32 @func_call(i32, float)
 ;或者
 define i32 @func_call(i32, float) 
 {
   %result = ...
   ret i32 result
 }

IR层的函数插桩,只不过是在特定位置增加这样一条函数调用,(以及它必要的声明或定义)而已。

复习一下,LLVM中程序嵌套结构依次为Module→Function→BasicBlock(→InstrBundle)→Instr

调用API

之前用LLVM-IR的文本形式示例只是为了方便理解。实际插桩时,是在LLVM-IR的内存形式中进行的。LLVM框架为我们提供了多种Pass接口,虽然这些Pass一般是用作实现优化的,但由于函数插桩的确改变了程序流以及数据流,也可以当作一个优化Pass实现。
函数插桩的大致流程如下:

  1. 遍历每一条Instr,检查是否为插桩的触发条件。至于什么是触发条件就由需求决定了,比如想在每一个函数之前插桩,只需要检查目前所在Instr是否为CallInstr;如果只想在函数名含有“test”的函数之前插桩,就还需要检查该CallInstr
  2. 当条件成立时,创建一条新的CallInstr,赋予其合适的FunctionType、按上下文补齐参数。这就是一条合法的Instr了
  3. 将新建的Instr插入当前所在Instr之前

TODO:LLVM-IR的内存表示 从上图中可以理解很重要的一点:LLVM-IR的内存表示有Type与Value两大类。前者指抽象的数据类型,后者指在内存中占据位置的“值”。像BasicBlock,Instrunction等由于都含有被处理的程序的数据因此它们都是值,而FunctionType只是一个函数的声明因此是类型。

核心的程序段示例如下

/* 实现一个FunctionPass 
bool runOnFunction(Function &F) override{
*/
LLVMContext &context = F.getParent()->getContext();
// 创建一个类型为 void (*func)(int32) 的函数声明
FunctionType *type = FunctionType::get(
  Type::getVoidTy(context),
  {Type::getInt32Ty(context)},
  false);
//给将要插入的函数命名,并声明为刚才定义的类型
FunctionCallee fc = F.getParent()->getOrInsertFunction("__inserted_func", type);
if(Function *fun = dyn_cast<Function>(fc.getCallee())){
  // 参数输入为32位整型常数42
  CallInst *inst = CallInst::Create(fun, {ConstanInt::get(Type::getInt32Ty(context),42,false)});
}

这个示例中最重要的是FunctionType::get()与CallInst::Create()两个API,它们都大量使用了Type::get...Ty(context)获取当前SubTarget中数据类型。 完整代码示例如下: TODO

以上给出的代码片段实现了函数插桩Pass,但要让LLVM框架调用它,还需要增加如下代码 TODOC

MI层插桩

在IR层插桩是非常方便的,因为LLVM-IR几乎就相当于不包含类型推导,流程控制只能靠goto的C语言,高级语言所有的函数封装、类型系统等在LLVM-IR中均存在等价语法。
经过指令选择后,IR语言就变成了三地址形式的MachineInstruction,此时虽然还没有进行寄存器分配,程序的结构已经大致是汇编的形式,即线性的指令序列。而在MachineFunction中也不存在函数的概念了——和汇编一样,只是存放在某个特定位置的一段指令序列,函数调用也就变成了跳转到这段指令序列的入口。

想要在MI层新增一次函数调用因此变得非常麻烦:如果函数有返回值,需要显式地写寄存器(即便在这个阶段物理寄存器还没开始分配,只需要使用虚拟寄存器)接受函数返回值的指令;如果函数有参数,同样需要写将参数传入寄存器的指令。而到底是从哪个寄存器取数据、往哪个寄存器存数据,这是由目标架构决定的,需要查阅手册;另外,用于传参的寄存器必须要提前lowering,只使用特定的几个寄存器(如在RISCV上,X1寄存器存放返回地址,X10、X11等寄存器存放参数),这又给代码引入了非常底层的信息,手敲底层汇编代码非常容易出错。

可能在MI层插桩比较好的场景只有void (*f)(void)类型的函数吧。这样只需要插入一句调用指令+标号就能完成。
示例代码如下(RISCV后端)

// bool runOnMachineFunction(MachineFunction& MF)
MachineModuleInfo &MMI = MF.getMMI();
const Module *M = MMI.getModule();
auto *to_insert = M->getFunction("my_function");
if(nullptr = to_insert){
  errs()<<"there is no function has the name my_function\n";  
  return;
}
MachineInstrBuilder call = BuildMI(MBB, MI, MI.getDebugLoc(), ST.getInstrInfo()->get(RISCV::PseudoCALL));
call.addGlobalAddress(M->getNamedValue(to_insert->getName()),0,RISCVII::MO_CALL);
errs()<<"insert a call to function my_function\n";

这里要注意的是,为跳转指令增加标号应该使用addGlobalAddress()。

题外话,关于我曾经花了两天寻找PseudoCALL这条MachineInstr 后面接的MachineOperand的类型是哪个,就靠着读文档、望文生义,试过许多数据类型。比如ExternalSymbol,还有MCSymbol,然而没一条对的。生成的PseudoCALL 指令不能被后续的Assember(即llvm-mc)成功汇编。 最后是通过在整个LLVM源码目录中搜索创建PseudoCALL指令,找到了参考: 源码在 llvm-project/llvm/lib/Target/RISCV/RISCVInstrInfo.cpp, line 1133, in RISCVInstrInfo::insertOutlinedCall

MBB.insert(It, BuildMI(MF,DebugLoc(),get(RISCV::PsuedoCALLReg),RISCV::X5)
.addGlobalAddress(M.getNamedValue(MF.getName(),0,RISCVII::MO_CALL));

看了这个例子,才知道CALL指令后面跟的标号的类型是GlobalAddress。回头一想又觉得很自然,是啊,跳转地址不是GlobalAddress还能是什么呢?然而,因为对LLVM-IR中各级结构的表示不熟悉,走了很多弯路。 最后我个人的体会就是: 百闻不如一见。在开发少量功能时,阅读llvm doxgen文档是一个快速的方法,但是当开发涉及多种LLVM结构体时,还是应该要先查阅一下别人是怎么写的,才能事半功倍。