学习一下LLVM的hello world。

LLVM简介

简单编译原理

学过编译原理的人都知道(然而我没学过),编译过程主要可以划分为前端与后端:

  • 前端(Front End)会把高级语言源代码翻译成中间表示(IR)。
  • 后端(Back End)将IR翻译成目标平台的机器码。

对于现在大部分编译器来说,中间表示即汇编语言,并且前端与后端之间强耦合,不会给你接口操作 IR。LLVM 提供了 LLVM IR 这样的中间表示,它介于编译器的前端和后端之间,我们且把它叫做中端(Middle End)。高级语言的翻译流程变成了:.c 经过 LLVM 翻译之后形成 LLVM IR,LLVM IR 经过中断处理变为目标平台的中间表示,最后再由后端翻译成机器码(目前个人浅薄的理解,难免有错)。

强耦合的优点便是提供一条龙服务,.c -> .exe 一气呵成,但是缺点也是很明显的,那就是不同的目标平台和不同的高级语言之间,需要提供不同的编译器。如果由 n 种高级语言,m个目标平台,那么我们需要有 n*m 种不同的编译器,并且这些编译器之间,会有很大的冗余,比如都是 x86 平台的编译器,那么它们将汇编语言翻译成机器语言的部分应当是一样的。

LLVM 将前端和后端进行了分离,出了一个新的中间表示,那么对于高级语言设计者来说,我只需要关心我的语言怎么翻译成 LLVM IR 即可,LLVM 提供了 LLVM IR 翻译成其它所有平台 IR 的方式。这样 n 个前端,m 个后端,可以让它们自由组合形成我想要的编译器,十分灵活。

Clang

Clang 是基于 LLVM 的C语言编译器,和 GCC 差不多可以这么理解。因为是基于LLVM的,当然它也能提供从 C 到 LLVM IR 的翻译。为了方便,我们可以把编译结果加入 PATH 变量,这样就可以在任意一个目录下面引用 clang 了。

写一个很简单的程序:

1
2
3
4
5
6
7
8
9
10
11
12
#include<stdio.h>

int main(){
int a;
scanf("%d",&a);
if(a==1){
puts("WIN");
}
else{
puts("FAIL");
}
}

LLVM IR有两种格式的表示,一种是适合我们阅读的 .ll 文本格式,另一种就是适合存储的 .bc,我觉得可以类比为 Linux 下面的 .s 和 .o 文件,它们相互转化也很方便。

  • .c -> .ll:clang -emit-llvm -S a.c -o a.ll
  • .c -> .bc: clang -emit-llvm -c a.c -o a.bc
  • .ll -> .bc: llvm-as a.ll -o a.bc
  • .bc -> .ll: llvm-dis a.bc -o a.ll
  • .bc -> .s: llc a.bc -o a.s

这些只要按照之前的方法编译并将二进制文件的目录写进 PATH 之后都是会有这些工具的,类比之后其实也很容易看出来 llvm-as 就是汇编,将文本格式转二进制格式,llvm-dis 就是反汇编,将二进制格式反汇编为文本格式。

LLVMHello

在编译的 600 多个项目当中有一个项目叫 LLVMHello,在Loadable Modules目录下面,以此学习一下 pass 以及加载 pass 的流程。编译完成之后应该会有一个 LLVMHello.dll\Debug\bin\ 目录下面,也就是之前和 clang 在同个目录下的。官方给的默认源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
#include "llvm/ADT/Statistic.h"
#include "llvm/IR/Function.h"
#include "llvm/Pass.h"
#include "llvm/Support/raw_ostream.h"

#include<iostream>
using namespace llvm;

#define DEBUG_TYPE "hello"

STATISTIC(HelloCounter, "Counts number of functions greeted");

namespace {
// Hello - The first implementation, without getAnalysisUsage.
struct Hello : public FunctionPass {
static char ID; // Pass identification, replacement for typeid
Hello() : FunctionPass(ID) {}

bool runOnFunction(Function &F) override {
++HelloCounter;
std::cout << "Hello: ";
int fd;
errs().write_escaped(F.getName()) << '\n';
return false;
}
};
}

char Hello::ID = 0;
static RegisterPass<Hello> X("hello", "Hello World Pass");

namespace {
// Hello2 - The second implementation with getAnalysisUsage implemented.
struct Hello2 : public FunctionPass {
static char ID; // Pass identification, replacement for typeid
Hello2() : FunctionPass(ID) {}

bool runOnFunction(Function &F) override {
++HelloCounter;
std::cout << "Hello: ";
__debugbreak;
errs().write_escaped(F.getName()) << '\n';
return false;
}

// We don't modify the program, so we preserve all analyses.
void getAnalysisUsage(AnalysisUsage &AU) const override {
AU.setPreservesAll();
}
};
}

char Hello2::ID = 0;
static RegisterPass<Hello2> Y("hello2", "Hello World Pass (with getAnalysisUsage implemented)");

我是怎么都加载不上的,经了解,可能跟这个原因有关:LLVM17已经开始逐渐淘汰之前的 Legacy Pass 逐渐转用 New pass了,通过 Github 上的 Commit 记录也可以发现该文件最近一次修改在 6 年前。

这里我参考了一篇文章上的源码,写出了 First Pass 并成功加载。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include "llvm/IR/PassManager.h"
#include "llvm/Passes/PassBuilder.h"
#include "llvm/Passes/PassPlugin.h"
#include "llvm/Support/raw_ostream.h"
using namespace llvm;

#include <iostream>

namespace llvm {

struct NewPassHelloWorld : public PassInfoMixin<NewPassHelloWorld> {

PreservedAnalyses run(Module &F, ModuleAnalysisManager &AM) {
std::cout << "NewPassHelloWorld Loaded" << std::endl;
errs() << "MyPass:";
errs() << F.getName() << "\n";
return PreservedAnalyses::all();
}
bool isRequird() { return true; }
};

} // namespace llvm
// This part is the new way of registering your pass
extern "C" ::llvm::PassPluginLibraryInfo LLVM_ATTRIBUTE_WEAK
llvmGetPassPluginInfo() {
return {LLVM_PLUGIN_API_VERSION, "NewPassHelloWorld", "v0.1",
[](PassBuilder &PB) {
PB.registerPipelineParsingCallback(
[](StringRef PassName, ModulePassManager &FPM, ...) {
if (PassName == "NewPassHelloWorld") {
FPM.addPass(NewPassHelloWorld());
return true;
}
return false;
});
}};
}

这里需要注意,我们需要导出 llvmGetPassPluginInfo 函数,但是不能使用关键字 __declspec(dllexport) ,否则编译不会成功,这里我选择在项目中新建一个export.def。

选择导出指定的函数,再在项目属性中指定该文件作为模块定义文件

这样我们编译出来的 LLVMHello.dll 便具有该导出函数了。

因此发现 pass 加载失败可以先看看 dll 有没有导出这个函数。

opt加载pass

首先先编译出一个 ll 文件。

clang -emit-llvm -S main.c -o main.ll

再使用 opt 加载 pass。

opt --load-pass-plugin=llvmhello.dll --passes=NewPassHelloWorld main.ll -o main.bc

输出信息了说明 pass 加载成功了。

clang加载pass

在LLVM新版本中允许 clang 加载 pass 了(据说以前是不行的),在编译的时候传参 -Xclang -fpass-plugin="/path/to/dll/pass.dll" 可以直接加载 pass。

但是需要在 dll 中调用 registerPipelineStartEPCallback 注册回调才能被 clang 加载,这里我们写一个回调并注册它,就可以被clang加载。

定义回调函数:

1
2
3
4
void myCallback(llvm::ModulePassManager &PM,
OptimizationLevel Level) {
PM.addPass(NewPassHelloWorld());
}

注册回调:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
extern "C" ::llvm::PassPluginLibraryInfo LLVM_ATTRIBUTE_WEAK
llvmGetPassPluginInfo() {
return {LLVM_PLUGIN_API_VERSION, "NewPassHelloWorld", LLVM_VERSION_STRING,
[](PassBuilder &PB) {
PB.registerPipelineStartEPCallback(myCallback);//这部分让clang得以加载pass
PB.registerPipelineParsingCallback(
[](StringRef PassName, ModulePassManager &FPM, ...) {
if (PassName == "NewPassHelloWorld") {
FPM.addPass(NewPassHelloWorld());
return true;
}
return false;
});

}};
}

编译命令:clang -Xclang -fpass-plugin=./LLVMHello.dll main.c -o test.exe

参考资料

  1. Windows下优雅地使用LLVMPass
  2. LLVM 入门引导