本文參考了官方教程Kaleidoscope語言的實現,本文只實現了JS的編譯器的demo,如果想要加深學習比如語言的JIT的實現和語言的程式碼優化,我將官方教程和程式碼集合打包在了 github.com/zy445566/ll… 中有興趣,可以更加深入的學習。
什麼是LLVM
像大家熟知的Swift就是依靠LLVM實現的一門語言,還有Rust也是將LLVM用於後端編譯。
一句話總結,它就是一種編譯器的基礎設施。可能有人說是gcc一類的東西麼?老實說最初它卻是用來取代gcc的,但它擁有的絕不是編譯而是擁有製造新語言能力的全部能力的一個工具。可以讓人更加無痛的實現一門語言。
本文編譯器流程大概是【編寫AST用於分析語言結構】->【將分析的語言繫結生成IR(中間語言)】-> 【生成二進位制或彙編程式碼】
如果使用LLVM製作語言的虛擬機器亦可以實現JIT,或者是編譯器和虛擬機器的結合體。
準備工作
安裝LLVM
# centOS,ubuntu應該也可以使用yum或apt-get進行安裝
# 有時間的話下載原始碼編譯當然更好
brew install llvm
複製程式碼
mac還需要安裝xcode命令列工具
# 兩臺電腦都裝了xcode,一臺編譯居然找不到標準庫
# 這個問題我找了好久
xcode-select --install
複製程式碼
編寫AST用於分析語言結構階段
先定義token型別,用於識別詞法結構,定義負數的原因是ascii碼的字元都是正數
enum Token{
tok_eof = -1,
// define
tok_var = -2,
tok_func = -3,
// code type
tok_id = -4,
tok_exp = -5,
tok_num = -6,
// choose
tok_if = -7,
tok_else = -8,
// interrupt
tok_return = -9,
// other
tok_unkown = -9999
};
複製程式碼
解析token的方法,也可以用於字元跳躍
static int gettoken()
{
LastChar = fgetc(fp);
// 排除不可見字元
while (isspace(LastChar))
{
LastChar = fgetc(fp);
}
// 排除註釋
if (LastChar=='/' && (LastChar = fgetc(fp))=='/'){
do{
LastChar = fgetc(fp);
}
while (!feof(fp) && LastChar != '\n' && LastChar != '\r' && LastChar != 10);
// 吃掉不可見字元
while (isspace(LastChar))
{
LastChar = fgetc(fp);
if (LastChar=='/') {fseek(fp,-1L,SEEK_CUR);}
}
}
// 解析[a-zA-Z][a-zA-Z0-9]*
if (isalpha(LastChar)) {
defineStr = LastChar;
int TmpChar;
while (isalnum((TmpChar = fgetc(fp))) && (LastChar = TmpChar))
{
defineStr += TmpChar;
}
fseek(fp,-1L,SEEK_CUR);
if (defineStr == "var")
{
return tok_var;
}
if (defineStr == "function")
{
return tok_func;
}
if (defineStr == "if")
{
return tok_if;
}
if (defineStr == "else")
{
return tok_else;
}
if (defineStr == "return")
{
return tok_return;
}
return tok_id;
}
// 解析[0-9.]+
if (isdigit(LastChar) || LastChar == '.') {
std::string NumStr;
do {
NumStr += LastChar;
LastChar = fgetc(fp);
} while (isdigit(LastChar) || LastChar == '.');
NumVal = strtod(NumStr.c_str(), nullptr);
return tok_num;
}
if(feof(fp)){
return tok_eof;
}
return LastChar;
}
複製程式碼
再次定義語法結構數的語法,這個可以根據自己的喜好定義
// AST基類
class ExprAST {
public:
virtual ~ExprAST() = default;
// 這是用於實現IR程式碼生成的東西
virtual llvm::Value *codegen() = 0;
};
// 定義解析的數字的語法樹
class NumberExprAST : public ExprAST {
double Val;
public:
NumberExprAST(double Val) : Val(Val) {}
llvm::Value *codegen() override;
};
// 定義解析的變數的語法樹
class VariableExprAST : public ExprAST {
std::string Name;
public:
VariableExprAST(const std::string &Name) : Name(Name) {}
llvm::Value *codegen() override;
};
// 還有很多語法型別,由於太多,暫時不寫
...
複製程式碼
迴圈獲取token並進入對應的方法
static void LoopParse() {
while (true) {
LastChar = gettoken();
switch (LastChar) {
case tok_eof:
return;
case ';':
gettoken();
break;
case tok_func:
HandleFunction();
break;
case tok_if:
HandleIf();
break;
default:
break;
}
}
}
複製程式碼
解析JS方法的功能
static std::unique_ptr<FunctionAST> HandleFunction() {
LastChar = gettoken();
// 解析方法的引數
auto Proto = ParsePrototype();
if (!Proto){return nullptr;}
// 吃掉方法的大括號
gettoken();
if (LastChar != '{'){return LogErrorF("Expected '{' in prototype");}
// 定義方法的內容,這是一個陣列,因為方法是多行的
std::vector<FnucBody> FnBody;
while(true){
// 這是這一行程式碼的型別,其中包含表示式和是否返回資料
FnucBody fnRow;
if (auto E = ParseExpression())
{
fnRow.expr_row = std::move(E);
fnRow.tok = RowToken;
RowToken = 0;
FnBody.push_back(std::move(fnRow));
} else {
// 如果這一行是分號,讓下一次gettoken去吃掉分號
if (LastChar == ';'){continue;}
// 如果方法結束判斷是否有大括號,沒有則報異常
if (LastChar != '}'){return LogErrorF("Expected '}' in prototype");}
// 生成方法的AST
auto FnAST = llvm::make_unique<FunctionAST>(std::move(Proto), std::move(FnBody));
// 生成方法的程式碼
if (auto *FnIR = FnAST->codegen()) {
//異常則輸出錯誤, 未出異常則輸出IR
// FnIR->print(llvm::errs());
}
return FnAST;
}
}
return nullptr;
}
複製程式碼
而裡面比較複雜應該是ParseExpression,用於解析表示式的方法,複雜點在於表示式中可能還有表示式,表示式裡面還有表示式,有的時候思考下來,腦子裡面基本是無限遞迴,能讓腦子瞬間短路
// 表示式解析
static std::unique_ptr<ExprAST> ParseExpression() {
// 解析表示式的左邊
auto LHS = ParsePrimary();
if (!LHS){
return nullptr;
}
// 解析表示式的操作符和表示式的右邊
return ParseBinOpRHS(0, std::move(LHS));
}
// 判斷表示式左邊是什麼型別
static int RowToken = 0;
static std::unique_ptr<ExprAST> ParsePrimary() {
int res = gettoken();
switch (res) {
default:
return LogError("unknown token when expecting an expression");
case tok_id:
// 如果是變數或執行的方法
return ParseIdentifierExpr();
case tok_if:
// 如果是if
return HandleIf();
case tok_num:
// 如果是數字
return ParseNumberExpr();
case tok_return:
// 如果是返回則標記,並繼續執行表示式左邊
RowToken = tok_return;
return ParsePrimary();
case '}':
// 符號跳過
return nullptr;
case ';':
// 符號跳過
return nullptr;
case '(':
// 作為父表示式執行
return ParseParenExpr();
}
}
// 解析表示式的操作符和表示式的右邊
static std::unique_ptr<ExprAST> ParseBinOpRHS(
int ExprPrec,
std::unique_ptr<ExprAST> LHS
) {
gettoken();
while (true) {
// 判斷操作符優先順序
int TokPrec = GetTokPrecedence();
// 如果操作符優先順序低,直接返回當前
if (TokPrec < ExprPrec){return LHS;}
// 如果操作符優先順序高,繼續運算
int BinOp = LastChar;
// 分析右表示式
auto RHS = ParsePrimary();
if (!RHS){return nullptr;}
// 繼續表表示式
int NextPrec = GetTokPrecedence();
// 繼續分析操作符優先順序
if (TokPrec < NextPrec) {
RHS = ParseBinOpRHS(TokPrec + 1, std::move(RHS));
if (!RHS){return nullptr;}
}
// 將左右表示式合併
LHS = llvm::make_unique<BinaryExprAST>(BinOp, std::move(LHS),
std::move(RHS));
}
}
複製程式碼
將分析的語言繫結生成IR(中間語言)
看完上面的是不是覺得有點慌,其實解析好了,生成IR很簡單。IR是一箇中間語言,簡單就是把一門語言轉換成另一門語言,而解析好了的話,其實就只剩下繫結了。
先看看方法的AST的定義
// 方法中的一行的型別定義
struct FnucBody{
// 是否有token
int tok;
// 這一行的表示式
std::unique_ptr<ExprAST> expr_row;
};
class FunctionAST {
// 引數列表定義
std::unique_ptr<PrototypeAST> Proto;
// 方法中全部表示式行
std::vector<FnucBody> FnBody;
public:
// 構造
FunctionAST(std::unique_ptr<PrototypeAST> Proto,
std::vector<FnucBody> FnBody)
: Proto(std::move(Proto)), FnBody(std::move(FnBody)) {}
// 定義IRcode的生成方法
llvm::Function *codegen();
};
複製程式碼
具體生成IR的方法
llvm::Function *FunctionAST::codegen() {
// 獲取函式名,並檢測是否是已存在的函式
llvm::Function *TheFunction = TheModule->getFunction(Proto->getName());
// 如果函式不存在,則生成行數及引數並將函式重新賦值
if (!TheFunction)
TheFunction = Proto->codegen();
// 如果沒生成成功,說明引數存在問題
if (!TheFunction)
return nullptr;
// 在上下文中將entry語法塊插入方法中
llvm::BasicBlock *BB = llvm::BasicBlock::Create(TheContext, "entry", TheFunction);
Builder.SetInsertPoint(BB);
// 將引數寫入map中
NamedValues.clear();
for (auto &Arg : TheFunction->args())
NamedValues[Arg.getName()] = &Arg;
// 遍歷每一行並生成程式碼,如果token是return,則設定返回資料
for (unsigned i = 0, e = FnBody.size(); i != e; ++i) {
llvm::Value *RetVal = FnBody[i].expr_row->codegen();
if (FnBody[i].tok==tok_return){
Builder.CreateRet(RetVal);
}
// 如果全部的行執行完成則校驗方法並返回方法
if(i+1==e){
verifyFunction(*TheFunction);
return TheFunction;
}
}
// 發生錯誤移除方法
TheFunction->eraseFromParent();
return nullptr;
}
複製程式碼
生成二進位制檔案
int destFile (std::string FileOrgin) {
// 初始化發出目的碼的所有目標
llvm::InitializeAllTargetInfos();
llvm::InitializeAllTargets();
llvm::InitializeAllTargetMCs();
llvm::InitializeAllAsmParsers();
llvm::InitializeAllAsmPrinters();
// 使用我們的目標三元組來獲得Target
auto TargetTriple = llvm::sys::getDefaultTargetTriple();
TheModule->setTargetTriple(TargetTriple);
std::string Error;
auto Target = llvm::TargetRegistry::lookupTarget(TargetTriple, Error);
if (!Target) {
llvm::errs() << Error;
return 1;
}
auto CPU = "generic";
auto Features = "";
llvm::TargetOptions opt;
auto RM = llvm::Optional<llvm::Reloc::Model>();
// 將編譯的機器資訊錄入
auto TheTargetMachine =
Target->createTargetMachine(TargetTriple, CPU, Features, opt, RM);
// 通過了解目標和資料佈局,優化程式碼
TheModule->setDataLayout(TheTargetMachine->createDataLayout());
// 定義檔案流
std::string Filename = FileOrgin+".o";
std::error_code EC;
llvm::raw_fd_ostream dest(Filename, EC, llvm::sys::fs::F_None);
if (EC) {
llvm::errs() << "Could not open file: " << EC.message();
return 1;
}
// 程式碼寫入流中
llvm::legacy::PassManager pass;
auto FileType = llvm::TargetMachine::CGFT_ObjectFile;
if (TheTargetMachine->addPassesToEmitFile(pass, dest, FileType)) {
llvm::errs() << "TheTargetMachine can't emit a file of this type";
return 1;
}
// 完成並清除流
pass.run(*TheModule);
dest.flush();
// 輸出完成提示
llvm::outs() << "Wrote " << Filename << "\n";
return 0;
}
複製程式碼
編譯編譯器
將我們做好的編譯器編譯出來,生成jsvm檔案
clang++ -g -O3 jsvm.cpp `llvm-config --cxxflags --ldflags --system-libs --libs all` -o jsvm
複製程式碼
使用我們寫好的編譯器編譯js檔案
編譯js
js檔案如下
// fibo.js 這是斐波納切數
function fibo(num) {
if (num<3) {
return 1;
} else {
return fibo(num-1)+fibo(num-2);
}
}
複製程式碼
開始編譯js檔案,將生成 fibo.js.o,如下
./jsvm fibo.js
複製程式碼
使用c引用js檔案,並編譯成二進位制檔案
c程式碼如下:
// main.cpp
#include <iostream>
extern "C" {
double fibo(double);
}
int main() {
std::cout << "fibo(9) is: " << fibo(9) << std::endl;
}
複製程式碼
編譯並執行,如下:
clang++ main.cpp fibo.js.o -o main && ./main
複製程式碼
總結
第一次寫編譯器感覺很凌亂,編譯器本身來說還算是一個相對複雜的工程,加上js語言的靈活多變性,實現起來可能更加困難,不過這作為一個學習的例子應該是不錯的,遂與大家分享。
相信llvm將來也是能為JS助力的,事實上已經有人有很大膽的想法去使用llvm編譯JS,前段時間facebook的prepack就有這樣一個PR【facebook/prepack/pull/2264】去實現用llvm將js編譯成二進位制而無需執行時。兄弟們!JS自舉的路或許不會太遠了。