Upgrade to Pro — share decks privately, control downloads, hide ads and more …

コンパイラを作ろう

 コンパイラを作ろう

スタックマシン処理系でのVMで動く四則演算言語のコンパイラを作る。
(2025/4/16 社内勉強会用スライド)

Avatar for Hiroki Kamiyoshikawa

Hiroki Kamiyoshikawa

May 05, 2025
Tweet

More Decks by Hiroki Kamiyoshikawa

Other Decks in Programming

Transcript

  1. hardware arch hardware arch VM runtime program program native 環境

    VM 環境 コンパイラとは? 入力: ソースコード ( 文字列) 出力: プログラム ( バイナリ) ソースコードを解析し、マシンが実行できるバイナリ形式に変換するプログラム。 マシンとは? コンピューターのアーキテクチャ (x86-64, ARM64 など) 実際のアーキテクチャの上でエミュレートでき れば仮想のアーキテクチャ (VM) でもよい! 2
  2. 作成するコンパイラの概略 Compiler parse AST traversal assembly binarize mul 3.4 add

    3 sub 33.2 12 push 3.4 push 33.2 push 12 sub push 3 add mul ret source program 3.4*(33.2-12+3) 4
  3. BNF BNF (Backus–Naur Form; バッカス・ナウア記法) は文脈自由文法の生成規則を記述するた めの形式 符号なし小数 ( 例:

    3.95 , 2 ) <符号なし小数> ::= <整数部> | <整数部> "." <小数部> <整数部> ::= <一桁の自然数> | <一桁の自然数> <数の並び> <小数部> ::= <数の並び> <数の並び> ::= <一桁の数> | <一桁の数> <数の並び> <一桁の数> ::= "0" | <一桁の自然数> <一桁の自然数> ::= "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" BNF の欠点 繰り返しの表現を再帰でしか定義できない ( <数の並び> の部分など) 5
  4. EBNF EBNF (Extended BNF; 拡張 BNF) は BNF に繰り返しや省略を追加したもの 表現できる規則自体は

    BNF と変わらないがより簡潔な記述になる 符号なし小数 = 整数部 [ "." 小数部 ] ; 整数部 = 一桁の自然数 { 一桁の数 } ; 小数部 = 一桁の数 { 一桁の数 } ; 一桁の数 = "0" | 一桁の自然数 ; 一桁の自然数 = "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" ; { } は0 回以上の繰り返しを表す。 [ ] は省略可能を表す。 実際は…… BNF, EBNF とも ISO などで標準化されているが、独自の拡張や表記の乱れが多くある。 厳密な定義より人間が読むことを優先して、雰囲気で読むのがよい 6
  5. 四則演算の構文規則 今回作る四則演算言語の EBNF ソースコード例 : 3.4*(33.2-12+3) source = expression ;

    expression = term { ("+" | "-") term } ; term = factor { ("*" | "/") factor } ; factor = num | "(" expression ")" ; num = 今回は省略; 今回は簡単のため、数値や演算子の間のスペースも許可しない。 num は数値を表すがここでは省略。 今回は C# で作るので float.Parse メソッドなどを使えばパースできる。 7
  6. 字句解析・構文解析1 Compiler parse AST traversal assembly binarize mul 3.4 add

    3 sub 33.2 12 push 3.4 push 33.2 push 12 sub push 3 add mul ret source program 3.4*(33.2-12+3) 9
  7. 字句解析・構文解析2 UNIX におけるコンパイラ生成のためのツール (2025 年現在、もっと便利なものがいくらでもあるので 覚えなくてよい) lex ( レック、レックス) レキシカルアナライザー生成ツール。文字列をトークン列に変換する

    C のソースコードを 出力する。Linux では代わりに flex を使う。 yacc ( ヤック) パーサージェネレーター。トークン列の文法に応じて処理行う C のソースコードを出力す る。Linux では代わりに bison を使う。 共に1970 年代に開発された骨董品なので2025 年にあえて使う必要なし! 好きな言語で自分で字句解析・構文解析を作ろう 今回は C# で作成する 10
  8. 字句解析・構文解析3 ソースの文字列を入力して AST を出力する Parser を作っていく。 var parser = new

    Parser("3.4*(33.2-12+3)"); Ast ast = parser.Parse(); class Parser { private string _source; private int _pos; public Parser(string source) { _source = source; _pos = 0; } } 11
  9. 字句解析・構文解析4 ソース全体は単一の expression で構成されている。 source = expression ; expression =

    term { ("+" | "-") term } ; term = factor { ("*" | "/") factor } ; factor = num | "(" expression ")" ; num = 今回は省略; class Ast // 次のページ以降スペースの都合上 class ではなく record を使うが意味は同じ。 { public IToken Expression { get; } public Ast(IToken expression) { Expression = expression; } } class Parser { public Ast Parse() => new Ast(ParseExpression()); } 12
  10. 字句解析・構文解析5 expression = term { ("+" | "-") term }

    ; record Add(IToken Left, IToken Right) : IToken, IBinaryOp; // interface IBinaryOp record Sub(IToken Left, IToken Right) : IToken, IBinaryOp; // { // IToken Left { get; } class Parser // IToken Right { get; } { // } public IToken ParseExpression() // interface IToken { // { IToken left = ParseTerm(); // } while (PeekNextChar() is "+" or "-") { char op = ReadNextChar(); IToken right = ParseTerm(); left = (op == "+") ? new Add(left, right) : new Sub(left, right); } return left; } } 13
  11. 字句解析・構文解析6 term = factor { ("*" | "/") factor }

    ; record Mul(IToken Left, IToken Right) : IToken, IBinaryOp; record Div(IToken Left, IToken Right) : IToken, IBinaryOp; class Parser { public IToken ParseTerm() { IToken left = ParseFactor(); while (PeekNextChar() is "*" or "/") { char op = ReadNextChar(); IToken right = ParseFactor(); left = (op == "*") ? new Mul(left, right) : new Div(left, right); } return left; } } 14
  12. 字句解析・構文解析7 factor = num | "(" expression ")" ; record

    Num(float Value) : IToken; class Parser { public IToken ParseFactor() { if (PeekNextChar() is "(") { _ = ReadNextChar(); // "(" var expression = ParseExpression() _ = ReadNextChar(); // ")" return expression; } else { return new Num(ParseNum()); } } } 15
  13. アセンブリ1 Compiler parse AST traversal assembly binarize mul 3.4 add

    3 sub 33.2 12 push 3.4 push 33.2 push 12 sub push 3 add mul ret source program 3.4*(33.2-12+3) 16
  14. アセンブリ3 今回のコンパイラで扱う命令セット。 仮想のアーキテクチャなので自分で決めてよい。 instruction opcode operand 意味 push 0x00 num

    num をスタックにプッシュ add 0x01 - 2 つの数値 n0, n1 をポップ、n1 + n0 をプッシュ sub 0x02 - 2 つの数値 n0, n1 をポップ、n1 - n0 をプッシュ mul 0x03 - 2 つの数値 n0, n1 をポップ、n1 * n0 をプッシュ div 0x04 - 2 つの数値 n0, n1 をポップ、n1 / n0 をプッシュ ret 0x05 - 1 つの数値 n0 をポップ、n0 を計算結果とする 19
  15. hardware arch hardware arch VM runtime program program native 環境

    VM 環境 スタックマシン1 オペランドの置き場所としてスタックを使うアーキテクチャの方式。 x86-64, ARM64 など、現代のほとんどのハードウェアアーキテクチャはレジスタマシンと いう別の方式。 ではなぜスタックマシンを使用するのか? スタックマシン向けのアセンブリは作りやすい 必要ならレジスタマシンへの変換は可能 今回はスタックマシンの VM ランタイムを使用して実行す る。 20
  16. スタックマシン2 例: 1.3 + 3.8 push 1.3 push 3.8 add

    ret stack sp push 1.3 1.3 stack sp push 3.8 1.3 3.8 stack sp add stack[sp++] = 1.3; stack[sp++] = 3.8; float right = stack[--sp]; float left = stack[--sp]; stack[sp++] = left + right; 5.1 stack sp ret return stack[--sp]; 21
  17. mul 3.4 add 3 sub 33.2 12 0 1 2

    3 4 5 6 アセンブリ4 AST を DFS (Depth First Search; 深さ優先順) の post order ( 帰りがけ順) で巡回することで、ス タックマシン向けのアセンブリへの変換ができ る。 数値は push num にする。 演算子はそのまま演算命令にする。 巡回が終了したら最後に ret を追加。 ( ちなみに) 四則演算において、この順番で数値と演算子を 並べたものがいわゆる逆ポーランド記法。 3.4 33.2 12 - 3 + * 基本情報技術者試験に出るよ 22
  18. mul 3.4 add 3 sub 33.2 12 0 1 2

    3 4 5 6 アセンブリ5 3.4*(33.2-12+3) の AST をトラバースしてアセ ンブリを構築する (0) push 3.4 (1) push 33.2 (2) push 12 (3) sub (4) push 3 (5) add (6) mul (7) ret 23
  19. アセンブリ6 static void MakeAssembly(Ast ast, List<IInst> insts) { Traversal(ast.Expression, insts);

    insts.Add(new RetInst()); } static void Traversal(IToken token, List<IInst> insts) { if (token is Num num) { insts.Add(new PushInst(num.Value)); } else if (token is IBinaryOp binOp) { Traversal(binOp.Left, insts); Traversal(binOp.Right, insts); insts.Add(binOp switch { Add => new AddInst(), Sub => new SubInst(), Mul => new MulInst(), Div => new DivInst(), _ => throw new Exception(), }); } } 24
  20. アセンブリ7 opcode と照らし合わせて、バイト列 (Little Endian) に変換 ; asm ; bytecode

    ; ; opcode ; operand ;------------------------------------------------ push 3.4 ; 0x00, 0x9a, 0x99, 0x59, 0x40, push 33.2 ; 0x00, 0xcd, 0xcc, 0x04, 0x42, push 12 ; 0x00, 0x00, 0x00, 0x40, 0x41, sub ; 0x02, push 3 ; 0x00, 0x00, 0x00, 0x40, 0x40, add ; 0x01, mul ; 0x03, ret ; 0x05, 最終的に得られたバイト列が、コンパイルされたプログラムとなる。 25
  21. 処理系の実装2 static float Exec(byte[] program) { float[] stack = float[MAX_STACK_SIZE];

    int sp = 0; // stack pointer int pc = 0; // program counter while (true) { switch (program[pc++]) { case 0x00: // push stack[sp++] = BitConverter.ToSingle(program.AsSpan(pc, 4)); pc += 4; break; case 0x01: // add float right = stack[--sp]; float left = stack[--sp]; stack[sp++] = left + right; break; // ... 他の命令は省略 ... case 0x05: // ret return stack[--sp]; } } } 27
  22. スタックマシンの実例 .NET C# をコンパイルして得られる EXE や DLL は、.NET 向けの仮想のアーキテクチャ。 JVM

    (Java Virtual Machine) Java ( および Scala などの JVM 言語) をコンパイルして得られるバイナリは、JVM 向けの仮 想のアーキテクチャ。 Wasm (Web Assembly) C, C++, Rust, Go などからコンパイルできる Wasm もスタックマシンベースの仮想のアーキ テクチャ。 いずれも、実際は JIT (Just in Time compile) という仕組みを使うため、実際の処理系はスタ ックマシンではないが、命令セットはスタックマシンベース。 28