카테고리 없음

[LLVM tutorial] 01 ~ 02

snejs 2025. 7. 8. 13:44

LLVM tutorial

이 글은 LLVM tutorial의 아래 두 챕터를 바탕으로 정리한 글이다.

 

https://llvm.org/docs/tutorial/MyFirstLanguageFrontend/LangImpl01.html

 

1. Kaleidoscope: Kaleidoscope Introduction and the Lexer — LLVM 21.0.0git documentation

This tutorial is illustrated with a toy language called “Kaleidoscope” (derived from “meaning beautiful, form, and view”). Kaleidoscope is a procedural language that allows you to define functions, use conditionals, math, etc. Over the course of th

llvm.org

 

https://llvm.org/docs/tutorial/MyFirstLanguageFrontend/LangImpl02.html

 

2. Kaleidoscope: Implementing a Parser and AST — LLVM 21.0.0git documentation

With just under 400 lines of commented code (240 lines of non-comment, non-blank code), we fully defined our minimal language, including a lexer, parser, and AST builder. With this done, the executable will validate Kaleidoscope code and tell us if it is g

llvm.org

 

1.1. The Kaleidoscope Language

Kaleidoscope 언어는 LLVM tutorial을 설명하기 위한 toy language로, 조건문, 반복문, 사용자 정의 연산자 및 간단한 명령줄 인터페이스를 통한 JIT 컴파일, 디버그 정보 등까지 확장한다.

 

Kaleidoscope는 매우 단순한 체계의 언어이다.

 

- 64bit 부동 소수점(double)의 유일한 data type을 사용하며, 단일 타입을 가지는만큼 타입 선언도 없다.

# Compute the x'th fibonacci number.
def fib(x)
  if x < 3 then
    1
  else
    fib(x-1)+fib(x-2)

# This expression will compute the 40th number.
fib(40)

 

 

- 표준 라이브러리 함수를 호출할 수 있으며, 'extern' 키워드를 사용하여 함수를 사용하기 전에 정의할 수 있다.

- LLVM JIT이 외부 함수들을 연결, 호출하는 작업을 쉽게해주어 외부 표준 라이브러리 함수들을 불러오는게 간편해졌다.

extern sin(arg);
extern cos(arg);
extern atan2(arg1 arg2);

atan2(sin(.4), cos(42))

 

 

1.2. Lexer

Lexer는 텍스트 파일의 입력을 받아서, 그것을 해석할 수 있도록 토큰 단위로 잘라주는 역할을 한다.

구현은 C 언어를 기반으로 한다.

 

- input : text file

- output : tokens (Token enum values OR unknown character)

- token : token code + metadata

- token code : token의 종류 (ex) tok_number)

- metadata : token의 실제 값 (ex) 42.0)

 

// The lexer returns tokens [0-255] if it is an unknown character, otherwise one
// of these for known things.
enum Token {
  tok_eof = -1,

  // commands
  tok_def = -2,
  tok_extern = -3,

  // primary
  tok_identifier = -4,
  tok_number = -5,
};

static std::string IdentifierStr; // Filled in if tok_identifier
static double NumVal;             // Filled in if tok_number

 

Lexer로부터 반환되는 Token enum 값들에 포함되지 않은 unknown character들은 (ex) '+') ASCII value로 반환된다.

 

 

Lexer의 실제구현은 다음 gettok() 한 개의 함수에 구현되어 있다.

- gettok() 함수는 입력을 읽고 다음 토큰을 만들어서 반환하는 역할을 하며, static 변수로 선언되는 LastChar 변수는 문자 및 EOF 값을 저장하며, 읽고 처리하지 않은 문자가 저장되어 있다.

 

gettok()의 절차

1. token 사이의 whitespace는 무시 

/// gettok - Return the next token from standard input.
static int gettok() {
  static int LastChar = ' ';

  // Skip any whitespace.
  while (isspace(LastChar))
    LastChar = getchar();

 

2. identifier의 처리 : 문자로 시작하는 모든 token이 여기에 해당하며, def와 extern의 키워드들을 개별적인 토큰으로 반환

if (isalpha(LastChar)) { // identifier: [a-zA-Z][a-zA-Z0-9]*
  IdentifierStr = LastChar;
  while (isalnum((LastChar = getchar())))
    IdentifierStr += LastChar;

  if (IdentifierStr == "def")
    return tok_def;
  if (IdentifierStr == "extern")
    return tok_extern;
  return tok_identifier;
}

 

3. numeric value의 처리 : 숫자로 시작하는 double 값을 저장한 뒤 tok_number 반환

    (strtod에 의해 유효하지 않은 값을 제거됨, 1.23.45.67 -> 1.23)

if (isdigit(LastChar) || LastChar == '.') {   // Number: [0-9.]+
  std::string NumStr;
  do {
    NumStr += LastChar;
    LastChar = getchar();
  } while (isdigit(LastChar) || LastChar == '.');

  NumVal = strtod(NumStr.c_str(), 0);
  return tok_number;
}

 

4. comment는 문장의 끝을 찾아 EOF 토큰 혹은 다음 문장의 첫 토큰을 반환

if (LastChar == '#') {
  // Comment until end of line.
  do
    LastChar = getchar();
  while (LastChar != EOF && LastChar != '\n' && LastChar != '\r');

  if (LastChar != EOF)
    return gettok();
}

 

5. EOF이면 tok_eof를 반환하고 어디에도 해당되지 않은 문자열은 ASCII 값 그대로 반환

  // Check for end of file.  Don't eat the EOF.
  if (LastChar == EOF)
    return tok_eof;

  // Otherwise, just return the character as its ascii value.
  int ThisChar = LastChar;
  LastChar = getchar();
  return ThisChar;
}

 

 


2.1. Chapter 2 Introduction

Chapter 2 에서 다루는 것 : AST의 정의, Kaleidoscope language의 paser의 build

여기에서의 parser는 Recursive Descent Parsing(이항 표현식 외 전체 구현)과 Operater-Precedence Parsing(이 표현식을 구현)으로 구현된다.

 

 

2.2. The Abstract Syntax Tree (AST)

Kaleidoscope의 문법은 표현식(expression), 프로토타입(prototype), 함수 정의(function)로 크게 세 가지로 나뉘며, 이에 대응하는 AST 노드를 각각 구현한다.

 

Expression

- ExprAST : 모든 expression node 들에 대한 기본이 되는 interface 역할을 하는 class

- NumberExprAST : 숫자 리터럴을 표현하기 위한 class, 해당 리터럴의 실제 값을 저장

- VariableExprAST : 변수 참조를 표현하는 class, 변수의 이름을 저장하여 참조할 수 있게 함
- BinaryExprAST : binary operator을 표현하는 class, 연산자와 좌우 피연산자의 expression을 저장

- CallExprAST : function call을 표현하는 class, 호출할 함수의 이름과 전달할 인자 목록을 저장

 

/// ExprAST - Base class for all expression nodes.
class ExprAST {
public:
  virtual ~ExprAST() = default;
};

/// NumberExprAST - Expression class for numeric literals like "1.0".
class NumberExprAST : public ExprAST {
  double Val;

public:
  NumberExprAST(double Val) : Val(Val) {}
};
/// VariableExprAST - Expression class for referencing a variable, like "a".
class VariableExprAST : public ExprAST {
  std::string Name;

public:
  VariableExprAST(const std::string &Name) : Name(Name) {}
};

/// BinaryExprAST - Expression class for a binary operator.
class BinaryExprAST : public ExprAST {
  char Op;
  std::unique_ptr<ExprAST> LHS, RHS;

public:
  BinaryExprAST(char Op, std::unique_ptr<ExprAST> LHS,
                std::unique_ptr<ExprAST> RHS)
    : Op(Op), LHS(std::move(LHS)), RHS(std::move(RHS)) {}
};

/// CallExprAST - Expression class for function calls.
class CallExprAST : public ExprAST {
  std::string Callee;
  std::vector<std::unique_ptr<ExprAST>> Args;

public:
  CallExprAST(const std::string &Callee,
              std::vector<std::unique_ptr<ExprAST>> Args)
    : Callee(Callee), Args(std::move(Args)) {}
};

 

 

Conditional control flow 구현되지 않음

 

 

Prototype & Function

- PrototypeAST : 함수의 시그니처 부분 (ex) def func1 (arg1, arg2))
- FunctionAST : 함수의 시그니처 + body

 

Kaleidoscope에서는 데이터 타입이 한 개 이므로 함수의 타입은 파라미터 개수만으로 결정된다. 

/// PrototypeAST - This class represents the "prototype" for a function,
/// which captures its name, and its argument names (thus implicitly the number
/// of arguments the function takes).
class PrototypeAST {
  std::string Name;
  std::vector<std::string> Args;

public:
  PrototypeAST(const std::string &Name, std::vector<std::string> Args)
    : Name(Name), Args(std::move(Args)) {}

  const std::string &getName() const { return Name; }
};

/// FunctionAST - This class represents a function definition itself.
class FunctionAST {
  std::unique_ptr<PrototypeAST> Proto;
  std::unique_ptr<ExprAST> Body;

public:
  FunctionAST(std::unique_ptr<PrototypeAST> Proto,
              std::unique_ptr<ExprAST> Body)
    : Proto(std::move(Proto)), Body(std::move(Body)) {}
};

 

 

2.3. Parser Basics

Parser 파트에서는 lexer 파트에서 구현한 gettok()을 사용하여 토큰을 바탕으로 AST를 생성하는 코드를 설명한다.

 

Parser 구현을 위한 basic helper routines

- CurTok : 현재 파싱해야하는 토큰

- getNextToken() : gettok() 함수를 호출하여 CurTok에 다음으로 파싱할 토큰을 저장

- LogError() : 파싱 과정 중 오류 발생 시에 호출되는 에러 처리 함수. 다양한 반환 타입을 갖는 파싱 함수 내에서 일관된 방식으로 오류를 처리할 수 있도록 도와줌

 

/// CurTok/getNextToken - Provide a simple token buffer.  CurTok is the current
/// token the parser is looking at.  getNextToken reads another token from the
/// lexer and updates CurTok with its results.
static int CurTok;
static int getNextToken() {
  return CurTok = gettok();
}

/// LogError* - These are little helper functions for error handling.
std::unique_ptr<ExprAST> LogError(const char *Str) {
  fprintf(stderr, "Error: %s\n", Str);
  return nullptr;
}
std::unique_ptr<PrototypeAST> LogErrorP(const char *Str) {
  LogError(Str);
  return nullptr;
}

 

 

2.4. Basic Expression Parsing

이 파트에서는 가장 간단하게 구현되는 리터럴 수를 파싱하는 것을 구현하는 것부터 파싱 과정에서 사용되는 방법들을 소개한다.

 

아래 ParseNumberExpr에서는 tok_number 토큰에서 NumVal 값을 가져와 NumberExprAST 노드를 생성하고 현 토큰은 소비한 후 노드를 반환한다.

여기서 parser는 node 생성에 필요한 모든 토큰을 처리한 후 다음 토큰을 lexer buffer에 반환(getNextToken())하고 있으며, 이는 Recursive Descent Parser의 표준 방식이다.

/// numberexpr ::= number
static std::unique_ptr<ExprAST> ParseNumberExpr() {
  auto Result = std::make_unique<NumberExprAST>(NumVal);
  getNextToken(); // consume the number
  return std::move(Result);
}

 

 

ParseParenExpr은 '(' expression ')' 의 괄호를 처리하는 함수로, expression과 ')'이 오류가 났을 시 각각에 대한 대응을 nullptr 반환과 LogError를 반환하는 것으로 보여주고 있다.

모든 토큰이 오류 없이 parsing되면 각 토큰을 소모하고 노드를 반환한다. 이 때 AST에서는 괄호를 제거하고 expression만 노드에 저장하게 되는데 이는 괄호가 표현식의 우선순위와 그룹핑을 위한 도구로만 사용될 뿐 어떠한 의미를 담고 있지 않기 때문에 제거되어도 영향을 끼치지 않기 때문이다.

/// parenexpr ::= '(' expression ')'
static std::unique_ptr<ExprAST> ParseParenExpr() {
  getNextToken(); // eat (.
  auto V = ParseExpression();
  if (!V)
    return nullptr;

  if (CurTok != ')')
    return LogError("expected ')'");
  getNextToken(); // eat ).
  return V;
}

 

또한 위의 코드에서 ParseExpression을 호출하여 expression에 대한 처리를 다른 함수에 맡기는 것을 볼 수 있다. 이는 호출을 통해 재귀를 사용하는 Recursive Decent Parser의 특징을 보여주며, 이러한 특징을 바탕으로 괄호가 중첩되는 구조도 쉽게 처리할 수 있다.

 

 

다음은 Identifier에 대한 처리를 담당하는 함수이다. identifier에는 변수 참조와 함수 호출의 두가지 패턴이 있으며 이를 한 함수에서 조건문 분기를 통해 각각 적절한 노드를 반환하는 것을 확인할 수 있다.

조건문 분기 과정에서 look-ahead를 사용하며 다음 토큰을 미리 보고 '('와의 일치 여부에 따라 생성되는 노드가 갈린다.

/// identifierexpr
///   ::= identifier
///   ::= identifier '(' expression* ')'
static std::unique_ptr<ExprAST> ParseIdentifierExpr() {
  std::string IdName = IdentifierStr;

  getNextToken();  // eat identifier.

  if (CurTok != '(') // Simple variable ref.
    return std::make_unique<VariableExprAST>(IdName);

  // Call.
  getNextToken();  // eat (
  std::vector<std::unique_ptr<ExprAST>> Args;
  if (CurTok != ')') {
    while (true) {
      if (auto Arg = ParseExpression())
        Args.push_back(std::move(Arg));
      else
        return nullptr;

      if (CurTok == ')')
        break;

      if (CurTok != ',')
        return LogError("Expected ')' or ',' in argument list");
      getNextToken();
    }
  }

  // Eat the ')'.
  getNextToken();

  return std::make_unique<CallExprAST>(IdName, std::move(Args));
}

 

 

ParsePrimary는 expression-parsing에서 가장 상위의 primary를 파싱하는 함수이다.

현재 토큰을 확인하여 token 종류에 따라 함수를 호출한다. 여기서 CurTok은 처리되지 않은 토큰을 가리키고 있기 때문에 다음 토큰을 가져올 필요 없이 바로 확인 후 분기할 수 있다.

/// identifierexpr
///   ::= identifier
///   ::= identifier '(' expression* ')'
static std::unique_ptr<ExprAST> ParseIdentifierExpr() {
  std::string IdName = IdentifierStr;

  getNextToken();  // eat identifier.

  if (CurTok != '(') // Simple variable ref.
    return std::make_unique<VariableExprAST>(IdName);

  // Call.
  getNextToken();  // eat (
  std::vector<std::unique_ptr<ExprAST>> Args;
  if (CurTok != ')') {
    while (true) {
      if (auto Arg = ParseExpression())
        Args.push_back(std::move(Arg));
      else
        return nullptr;

      if (CurTok == ')')
        break;

      if (CurTok != ',')
        return LogError("Expected ')' or ',' in argument list");
      getNextToken();
    }
  }

  // Eat the ')'.
  getNextToken();

  return std::make_unique<CallExprAST>(IdName, std::move(Args));
}

 

 

2.5. Binary Expression Parsing

이 파트에서는 이항 연산자가 포함된 표현식에 대해 parsing하는 과정을 다루며 Operator-Precedence Parsing 방법을 사용한다.

Kaleidoscope language의 기본 형태에서는 4개의 이 연산자 (<, +, -, *)만을 지원하며, 각 연산자에 맞는 우선순위를 반환하는 함수인 GetTokPrecedence를 사용할 수 있다. 4개의 연산자 외에는 -1이 반환되도록 설계되어있다.

/// BinopPrecedence - This holds the precedence for each binary operator that is
/// defined.
static std::map<char, int> BinopPrecedence;

/// GetTokPrecedence - Get the precedence of the pending binary operator token.
static int GetTokPrecedence() {
  if (!isascii(CurTok))
    return -1;

  // Make sure it's a declared binop.
  int TokPrec = BinopPrecedence[CurTok];
  if (TokPrec <= 0) return -1;
  return TokPrec;
}

int main() {
  // Install standard binary operators.
  // 1 is lowest precedence.
  BinopPrecedence['<'] = 10;
  BinopPrecedence['+'] = 20;
  BinopPrecedence['-'] = 20;
  BinopPrecedence['*'] = 40;  // highest.
  ...
}

 

 

이항 연산자가 포함된 표현식에 대한 처리는 그 표현식을 [binop, primaryexpr] 쌍의 여러 조각으로 나눠 시퀀스를 만들어 낸다.

 

예시 : “a+b+(c+d)*e*f+g”

[binop, primaryexpr] sequence :  [+, b] [+, (c+d)] [*, e] [*, f], [+, g]

 

위의 예에서 'a'는 먼저 구문 분석된 상태이며, 시퀀스에서 각 연산자의 우선순위를 비교하며 primaryexpr 간의 node를 구성하고 연결한다.

'(c+d)'의 경우 expression으로서 내부적으로 처리되기 때문에 해당 단계에서 고려할 필요가 없다.

 

아래 코드는 a + b ... 에서 a의 구문 분석을 마친 후 우선순위 0을 준 뒤 오른쪽에 따라오는 연산들을 처리한다.

/// expression
///   ::= primary binoprhs
///
static std::unique_ptr<ExprAST> ParseExpression() {
  auto LHS = ParsePrimary();
  if (!LHS)
    return nullptr;

  return ParseBinOpRHS(0, std::move(LHS));
}

 

나머지 '+b+(c+d)*e*f+g'를 [0, a] 인자를 갖고 처리하는 과정이다.

/// binoprhs
///   ::= ('+' primary)*
static std::unique_ptr<ExprAST> ParseBinOpRHS(int ExprPrec,
                                              std::unique_ptr<ExprAST> LHS) {
  // If this is a binop, find its precedence.
  while (true) {
    int TokPrec = GetTokPrecedence(); // 현재 토큰이 연산자일 시 우선순위를 가져옴

    // If this is a binop that binds at least as tightly as the current binop,
    // consume it, otherwise we are done.
    // 현재 연산자가 ExprPrec보다 낮으면 반환
    // ex) a * b + c
    // * 보다 +의 우선 순위가 낮음 > * 까지만 계산하고 반환 
    if (TokPrec < ExprPrec) 
      return LHS;

    // Okay, we know this is a binop.
    int BinOp = CurTok; // 현재 이항 연산자 저장
    getNextToken(); // eat binop

    // Parse the primary expression after the binary operator.
    auto RHS = ParsePrimary(); // 피연산자 파싱
    if (!RHS)
      return nullptr;

    // If BinOp binds less tightly with RHS than the operator after RHS, let
    // the pending operator take RHS as its LHS.
    // a + b * c
    int NextPrec = GetTokPrecedence(); 
    if (TokPrec < NextPrec) {	
      // 다음 연산자의 우선순위가 더 높으면 b * c 를 먼저 완성 해야하므로 함수 호출
      // 피연산자에 있는 연산자의 우선순위가 더 높은 상태 a + b * c * d
      // (TokPrec + 1, => 함수를 재귀적으로 호출하여 우선순위를 높임으로써 
      // b * c * d 가 한 묶음으로 묶이도록 함
      RHS = ParseBinOpRHS(TokPrec + 1, std::move(RHS));
      if (!RHS)
        return nullptr;
    }

    // Merge LHS/RHS.
    LHS =
        std::make_unique<BinaryExprAST>(BinOp, std::move(LHS), std::move(RHS));
  }
}

 

 

2.6. Parsing the Rest

마지막으로 함수 prototype 처리 부분으로 이는 함수의 시그니처를 표현하는 부분이다. 

prototype은 함수 본문 정의에도 사용되지만 'extern'을 사용한 함수 선언에도 사용된다.

 

prototype의 정의는 다음과 같다.

/// prototype
///   ::= id '(' id* ')'
static std::unique_ptr<PrototypeAST> ParsePrototype() {
  if (CurTok != tok_identifier)
    return LogErrorP("Expected function name in prototype");

  std::string FnName = IdentifierStr;
  getNextToken();

  if (CurTok != '(')
    return LogErrorP("Expected '(' in prototype");

  // Read the list of argument names.
  std::vector<std::string> ArgNames;
  while (getNextToken() == tok_identifier)
    ArgNames.push_back(IdentifierStr);
  if (CurTok != ')')
    return LogErrorP("Expected ')' in prototype");

  // success.
  getNextToken();  // eat ')'.

  return std::make_unique<PrototypeAST>(FnName, std::move(ArgNames));
}

 

위에 구현한 prototype에 더불어 함수의 본문을 정의하는 경우를 대비해 함수 본문을 구현하는 것을 덧붙이는 것으로 완성한다.

각 'def'와 'extern' 키워드에 대한 처리이다.

/// definition ::= 'def' prototype expression
static std::unique_ptr<FunctionAST> ParseDefinition() {
  getNextToken();  // eat def.
  auto Proto = ParsePrototype();
  if (!Proto) return nullptr;

  if (auto E = ParseExpression())
    return std::make_unique<FunctionAST>(std::move(Proto), std::move(E));
  return nullptr;
}
/// external ::= 'extern' prototype
static std::unique_ptr<PrototypeAST> ParseExtern() {
  getNextToken();  // eat extern.
  return ParsePrototype();
}

 

마지막으로 익명 함수 트릭을 써서 호출 없이 결과를 내도록 하는 방법을 사용한 parsing 이다.

사용자가 1 + 2  * 3 을 입력했을 때 함수 호출 없이 바로 실행하여 출력하게 하고자 할 때 사용할 수 있다.

static std::unique_ptr<FunctionAST> ParseTopLevelExpr() {
  if (auto E = ParseExpression()) {
    // Make an anonymous proto.
    auto Proto = std::make_unique<PrototypeAST>("", std::vector<std::string>());
    return std::make_unique<FunctionAST>(std::move(Proto), std::move(E));
  }
  return nullptr;
}

 

 

2.7. The Driver

아래는 모든 파싱을 호출할 수 있는 최상위 루프 함수이다.

주목할만한 점은 최상위 세미콜론을 무시하는것으로, 이는 파서에게 입력이 끝났음을 알려주는 역할을 한다. 

/// top ::= definition | external | expression | ';'
static void MainLoop() {
  while (true) {
    fprintf(stderr, "ready> ");
    switch (CurTok) {
    case tok_eof:
      return;
    case ';': // ignore top-level semicolons.
      getNextToken();
      break;
    case tok_def:
      HandleDefinition();
      break;
    case tok_extern:
      HandleExtern();
      break;
    default:
      HandleTopLevelExpression();
      break;
    }
  }
}

 

전체 코드 

LLVM tutorial 2.9. 참고