自作Cコンパイラを実装してみる – 四則演算のコンパイルまで

Cコンパイラを自作してみる回です。今更Cコンパイラと言われるかもしれませんが、直接機械語を触れながら、かつネット上に参考文献が多数落ちているという観点で、今回は実装してみることにしました。

コンパイラの仕組みを理解することは、プログラミング言語がどのように動作しているのかを深く理解する上で非常に有益です。本記事では、最も基本的な四則演算をコンパイルできるコンパイラを、Pythonを使ってステップバイステップで実装していきます。

本記事の内容

  • コンパイラの基本的な構成要素(字句解析・構文解析・コード生成)
  • 四則演算の式を解析する再帰下降構文解析
  • x86-64アセンブリへのコード生成とPythonでの実装

コンパイラの基本構成

コンパイラは大きく以下の3つのフェーズで動作します。

  1. 字句解析(Lexer / Tokenizer): ソースコード文字列をトークンの列に分割する
  2. 構文解析(Parser): トークン列を抽象構文木(AST)に変換する
  3. コード生成(Code Generator): ASTからターゲットのアセンブリコードを生成する

例えば、3 + 5 * 2 という式は以下のように処理されます。

ソースコード: "3 + 5 * 2"
    ↓ 字句解析
トークン列: [NUM(3), PLUS, NUM(5), STAR, NUM(2)]
    ↓ 構文解析
AST:     (+)
        /   \
      (3)   (*)
           /   \
         (5)   (2)
    ↓ コード生成
アセンブリ: x86-64命令列

字句解析の実装

字句解析器(Lexer)は、入力文字列をトークンの列に変換します。今回扱うトークンは、数値(NUM)、演算子(+, -, *, /)、括弧((, ))です。

from enum import Enum, auto
from dataclasses import dataclass
from typing import List, Union

class TokenKind(Enum):
    """トークンの種類"""
    NUM = auto()
    PLUS = auto()
    MINUS = auto()
    STAR = auto()
    SLASH = auto()
    LPAREN = auto()
    RPAREN = auto()
    EOF = auto()

@dataclass
class Token:
    """トークン"""
    kind: TokenKind
    value: Union[int, None] = None

def tokenize(source: str) -> List[Token]:
    """ソースコードをトークン列に変換する"""
    tokens = []
    i = 0
    while i < len(source):
        c = source[i]
        # 空白をスキップ
        if c.isspace():
            i += 1
            continue
        # 数値
        if c.isdigit():
            num_str = ""
            while i < len(source) and source[i].isdigit():
                num_str += source[i]
                i += 1
            tokens.append(Token(TokenKind.NUM, int(num_str)))
            continue
        # 演算子と括弧
        op_map = {
            '+': TokenKind.PLUS, '-': TokenKind.MINUS,
            '*': TokenKind.STAR, '/': TokenKind.SLASH,
            '(': TokenKind.LPAREN, ')': TokenKind.RPAREN,
        }
        if c in op_map:
            tokens.append(Token(op_map[c]))
            i += 1
            continue
        raise ValueError(f"不正な文字: {c}")
    tokens.append(Token(TokenKind.EOF))
    return tokens

tokenize 関数は入力文字列を1文字ずつ読み進め、対応するトークンを生成します。数値は複数桁にも対応しています。

構文解析の実装

構文解析では、トークン列を抽象構文木(AST)に変換します。四則演算の演算子優先順位を正しく扱うために、再帰下降構文解析(Recursive Descent Parsing)を用います。

扱う文法は以下のBNFで定義されます。

$$ \text{expr} \to \text{term} \; ((‘+’ \mid ‘-‘) \; \text{term})^* $$

$$ \text{term} \to \text{factor} \; ((‘*’ \mid ‘/’) \; \text{factor})^* $$

$$ \text{factor} \to \text{NUM} \mid ‘(‘ \; \text{expr} \; ‘)’ $$

この文法では、*/+- より優先順位が高くなります。これは termfactor を先にまとめ、exprterm をまとめるという階層構造で実現されます。

@dataclass
class NumNode:
    """数値ノード"""
    value: int

@dataclass
class BinOpNode:
    """二項演算ノード"""
    op: str
    left: Union['NumNode', 'BinOpNode']
    right: Union['NumNode', 'BinOpNode']

class Parser:
    """再帰下降構文解析器"""
    def __init__(self, tokens: List[Token]):
        self.tokens = tokens
        self.pos = 0

    def current(self) -> Token:
        return self.tokens[self.pos]

    def consume(self, kind: TokenKind) -> Token:
        """期待するトークンを消費する"""
        tok = self.current()
        if tok.kind != kind:
            raise ValueError(f"期待: {kind}, 実際: {tok.kind}")
        self.pos += 1
        return tok

    def parse_expr(self):
        """expr = term (('+' | '-') term)*"""
        node = self.parse_term()
        while self.current().kind in (TokenKind.PLUS, TokenKind.MINUS):
            op = '+' if self.current().kind == TokenKind.PLUS else '-'
            self.pos += 1
            right = self.parse_term()
            node = BinOpNode(op, node, right)
        return node

    def parse_term(self):
        """term = factor (('*' | '/') factor)*"""
        node = self.parse_factor()
        while self.current().kind in (TokenKind.STAR, TokenKind.SLASH):
            op = '*' if self.current().kind == TokenKind.STAR else '/'
            self.pos += 1
            right = self.parse_factor()
            node = BinOpNode(op, node, right)
        return node

    def parse_factor(self):
        """factor = NUM | '(' expr ')'"""
        tok = self.current()
        if tok.kind == TokenKind.NUM:
            self.pos += 1
            return NumNode(tok.value)
        if tok.kind == TokenKind.LPAREN:
            self.pos += 1
            node = self.parse_expr()
            self.consume(TokenKind.RPAREN)
            return node
        raise ValueError(f"予期しないトークン: {tok}")

コード生成の実装

ASTを走査して、x86-64アセンブリを生成します。スタックマシン方式を採用し、計算結果をスタックに積み上げていく方法を用います。

x86-64の rax レジスタを主な計算用レジスタとし、中間結果をスタックにpushする戦略です。

class CodeGenerator:
    """x86-64アセンブリコード生成器"""
    def __init__(self):
        self.output = []

    def emit(self, line: str):
        """アセンブリ命令を出力バッファに追加"""
        self.output.append(line)

    def generate(self, node) -> str:
        """ASTからアセンブリコードを生成"""
        # アセンブリのヘッダ
        self.emit(".intel_syntax noprefix")
        self.emit(".globl main")
        self.emit("main:")
        # AST を再帰的に評価
        self.gen_expr(node)
        # rax に結果が入っている状態で return
        self.emit("  ret")
        return "\n".join(self.output)

    def gen_expr(self, node):
        """ASTノードを再帰的にコード生成"""
        if isinstance(node, NumNode):
            self.emit(f"  mov rax, {node.value}")
            return
        # 二項演算
        # 左辺を評価してスタックに退避
        self.gen_expr(node.left)
        self.emit("  push rax")
        # 右辺を評価(結果は rax)
        self.gen_expr(node.right)
        # 左辺をスタックから復元
        self.emit("  pop rdi")
        # 演算を実行
        if node.op == '+':
            self.emit("  add rax, rdi")
        elif node.op == '-':
            # rdi - rax (左辺 - 右辺)
            self.emit("  sub rdi, rax")
            self.emit("  mov rax, rdi")
        elif node.op == '*':
            self.emit("  imul rax, rdi")
        elif node.op == '/':
            # rdi / rax (左辺 / 右辺)
            self.emit("  mov rcx, rax")
            self.emit("  mov rax, rdi")
            self.emit("  cqo")
            self.emit("  idiv rcx")

全体を結合して実行

これまでの3つのフェーズを結合し、入力式からアセンブリコードを生成するコンパイラを完成させましょう。

def compile_expr(source: str) -> str:
    """式をコンパイルしてx86-64アセンブリを返す"""
    tokens = tokenize(source)
    parser = Parser(tokens)
    ast = parser.parse_expr()
    codegen = CodeGenerator()
    return codegen.generate(ast)

# テスト
expressions = ["3 + 5 * 2", "(3 + 5) * 2", "100 / 4 - 10", "42"]

for expr in expressions:
    print(f"=== {expr} ===")
    asm = compile_expr(expr)
    print(asm)
    print()

実行すると、各式に対応するx86-64アセンブリが出力されます。例えば 3 + 5 * 2 の場合は以下のようになります。

=== 3 + 5 * 2 ===
.intel_syntax noprefix
.globl main
main:
  mov rax, 3
  push rax
  mov rax, 5
  push rax
  mov rax, 2
  pop rdi
  imul rax, rdi
  pop rdi
  add rax, rdi
  ret

5 * 2 が先に計算され(演算子の優先順位が正しく処理され)、その後に 3 と加算されていることがアセンブリからも確認できます。

ASTの可視化

構文解析の結果を可視化して、ASTの構造を確認してみましょう。

import matplotlib.pyplot as plt
import matplotlib.patches as patches

def ast_to_dict(node):
    """ASTを辞書形式に変換"""
    if isinstance(node, NumNode):
        return {"label": str(node.value), "children": []}
    return {
        "label": node.op,
        "children": [ast_to_dict(node.left), ast_to_dict(node.right)]
    }

def compute_positions(tree, x=0, y=0, dx=1.5, dy=1.2):
    """ノードの描画位置を計算"""
    positions = []
    positions.append((x, y, tree["label"]))
    edges = []
    children = tree["children"]
    if len(children) == 2:
        # 左の子
        lx, ly = x - dx, y - dy
        positions_l, edges_l = compute_positions(children[0], lx, ly, dx/2, dy)
        positions += positions_l
        edges += edges_l
        edges.append((x, y, lx, ly))
        # 右の子
        rx, ry = x + dx, y - dy
        positions_r, edges_r = compute_positions(children[1], rx, ry, dx/2, dy)
        positions += positions_r
        edges += edges_r
        edges.append((x, y, rx, ry))
    return positions, edges

# 式 "3 + 5 * 2" のASTを可視化
tokens = tokenize("3 + 5 * 2")
parser = Parser(tokens)
ast = parser.parse_expr()
tree = ast_to_dict(ast)

positions, edges = compute_positions(tree)

fig, ax = plt.subplots(figsize=(8, 5))
# エッジを描画
for x1, y1, x2, y2 in edges:
    ax.plot([x1, x2], [y1, y2], 'k-', linewidth=1.5)
# ノードを描画
for x, y, label in positions:
    circle = plt.Circle((x, y), 0.3, color='steelblue', ec='black', linewidth=1.5, zorder=5)
    ax.add_patch(circle)
    ax.text(x, y, label, ha='center', va='center', fontsize=14, color='white', fontweight='bold', zorder=6)

ax.set_xlim(-3, 3)
ax.set_ylim(-4, 1)
ax.set_aspect('equal')
ax.axis('off')
ax.set_title("AST: 3 + 5 * 2", fontsize=14)
plt.tight_layout()
plt.show()

この可視化により、*+ より深い階層に配置され、先に評価されることが視覚的に確認できます。

まとめ

本記事では、Cコンパイラ自作の第一歩として、四則演算をコンパイルする仕組みを解説しました。

  • 字句解析: ソースコード文字列をトークン列に分割する処理
  • 構文解析: 再帰下降構文解析で演算子の優先順位を考慮したASTを構築
  • コード生成: ASTを走査してスタックマシン方式のx86-64アセンブリを出力

コンパイラの基本的な3フェーズ(字句解析・構文解析・コード生成)の仕組みを理解することで、プログラミング言語がどのように機械語に変換されるかのイメージが掴めたかと思います。次のステップとしては、変数の導入やif文・ループ文のサポート、関数呼び出しなどを追加していくことで、より実用的なコンパイラへと発展させることができます。