10
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

CrystalAdvent Calendar 2022

Day 25

Crystal言語がどのようにして動作するのか調べてみた(前編)

Last updated at Posted at 2023-01-12

はじめに

Crystal言語というものが一体どのように動いているのかを調べてみようと思いました。しかし情報科学のバックグラウンドのない人間が少し調べたぐらいではCrystalがなぜ動いているのかはわかりませんでした。理解できた部分について書いていきます。

元々はCrystalのアドベントカレンダーのために準備した記事でした。後半は勉強不足により未完になっているのですが、近日中に完成する見込みが立たないのでこのまま公開します。(前編と書いてありますが、後編は出ない可能性が高いです)

Crystal言語とは?

Crystalは、Ruby言語に非常によく似た文法を持つ静的型付け言語です。文法はRubyによく似ており、実行速度は非常に高速です。メタプログラミングはマクロによって実現しています。型推論のおかげで型を宣言しなくても、かなりの程度までRubyと似たコードを書くことができます。

いきなり元資料

Crystalの動作する仕組みについては、上記のスライドが公開されています。
この記事はこのスライドの前半について、情報を要約、整理、追加したものです。

Crystalのコードが実行されるまでの仕組み

コンパイラはテキストファイルから、実行可能なファイルを作成する

Crystalのコードはどのようにして実行されるのでしょうか。
テキスト形式でコードを書き、crystal コマンドを利用してビルドすると、実行可能ファイルが生成されます。

最も簡単なCrystalのプログラム、x=0 をコンパイルします。

最も簡単なCrystalのプログラム : test.cr
x = 0

ビルドします。

crystal build test.cr

実行ファイルを GHex で見てみましょう。こんな感じです。

image.png

これは人間が見てもよくわかりません。

かわりにアセンブリ言語のコードを出力してみましょう。アセンブリ言語は、機械語を人間がわかるように文字列で表現するものです。--emit asm オプションを指定することで、実行ファイルの変わりに、アセンブリ言語のコードを出力することができます。

crystal build --emit asm --prelude empty test.cr

prelude は、事前に読み込むファイルの指定です。emptyを指定すると、何も読み込まれないために小さなアセンブリ言語のコードが生成されます。こんな感じです。

image.png

CPUに対する命令が並んでいます。コンパイラはテキストファイルから、CPUに対する命令のリストを作成しています。Crystalは最終的にはアセンブリ言語のコードを生成すればいいわけですね。

コンパイラはCPUの命令セットによって異なる実行ファイルを生成する

生成される実行可能ファイルは、プラットフォームによって異なります。たとえば、Ubuntu(x86-64)で作成された実行ファイルと、Raspberry Pi(AArch64)上で生成される実行は異なります。Ubuntuの実行ファイルをRaspberry Piにコピーアンドペーストしても動きません。これは、CPUの命令セットアーキテクチャに互換性がないからです。

だから、コンパイラはCPU環境に合わせた実行ファイルを出力する必要があります。

CrystalのコンパイラはLLVMに実行可能ファイルの作成を委託している

しかし、全てのCPUの命令セットに対応するバイナリを出力するコンパイラを作成するのは大変です。プログラミング言語の開発者にとって大きな負担になります。開発者の利用しているCPUに対して最適化されていても、別のCPUに対しては効率の悪い実行ファイルが吐き出される歪な実装ができてしまうかもしれません。

そのため、Crystalでは、LLVMという「コンパイラ基盤」と呼ばれるプログラムに、下流の部分を丸投げしているそうです。LLVMがそれぞれのプラットフォームに向けて最適化された実行ファイルを出力します。

それではCrsytalはLLVMにどうやってLLVMに実行可能ファイルの作成を依頼しているのでしょうか。

Crystalは中間表現 LLVM-IR を作成する

LLVMでは LLVM IRと呼ばれる中間表現を作成しているそうです。このLLVM IRとは、一種のプログラミング言語のようなもので、理想化されたアセンブリ言語のようなものだそうです。

LLVM IR は↓のようなファイルです。(x=0 を LLVM IR に変換したもの)

image.png

アセンブリ言語のコードを準備するためにはLLVM_IRを準備すればいいわけですね。

LLVM IR(LLVM中間表現)のコードを出力してみよう

crystal build コマンドの --emit オプションに llvm-ir を指定すると、LLVM中間表現が出力されます。先ほどのCrystal言語で書かれたスクリプトを用意します。

test.cr
x = 0
crystal build --emit llvm-ir --prelude empty test.cr

--prelude オプションは、Crystalの実行時に最初に読み込むファイルを指定しています。デフォルトではこちらのファイルが読み込まれます。これを empty に切り替えることで出力されるファイルがずっと小さくなります。(これによって puts などよくあるメソッドが使えなくなりますのでご注意ください。)

test.ll というファイルが生成されていると思います。これが LLVM IR (LLVM中間表現)です。

LLVM IR(LLVM中間表現)からアセンブリ言語のコードを出力する

つぎに、このLLVM IRからアセンブリ言語のコードを作成します。これは、LLVM の LLC というコマンドを用います。

llc --relocation-model=pic test.ll

私の環境では --relocation-model=pic オプションをつけないと次のアセンブラから実行ファイルを作る工程が動きませんでした。これはメインメモリのどこにプログラムが配置されても実行できるようになるものだそうです。

アセンブリ言語をコンパイルする

clang test.s
./test

これで、LLVM IR 中間表現から実行ファイルが作成される様子を再現ことができました。
ちなみに --prelude=empty を指定しない場合、リンクするライブラリを指定する必要があります。たとえば、

clang test.s -levent -lgc -lm

といった感じです。ここでは、libevent, libgc, libmath にリンクします。エラーメッセージを参考にして適宜必要なライブラリを指定してみてください。このように、Crystal言語は、いくつかのC言語のライブラリに依存しています。したがって、マルチプラットフォーム対応もLLVMに頼るだけでは難しい場合があります。たとえば、wasm を出力する際にはwasm用のライブラリを用意する必要があります

今回はCrystalのコンパイラにが出力するLVM IRを観察しましたが、Crystal言語を使ってLLVM中間表現を自分で出力する時は標準添付のLLVMバインディングを利用します。

上流を構成する、Lexer、Parser、Semantic

ここまで、CrystalはコードからLLVM中間表現を作成することがわかりました。ではその工程はどうなっているのでしょうか。正直にいうと、今回はよくわかりませんでした。しかし、他の多くの言語と同じく、まずは、Lexerと、Parserを利用しています。

字句解析器(Lexer)を使ってみよう

字句解析器(Lexer)は、コードの文字列を解析して、トークン(token 単語や記号などの小さな意味を持つ単位)に分解します。下記のようなコードで、CrystalのLexerを使うことができます。

require "./src/compiler/crystal/syntax"

str = ARGF.gets_to_end
a = Crystal::Lexer.new(str)
loop do
  t = a.next_token
  p t
  break if t.type == Crystal::Token::Kind::EOF
end

こんな感じで、タブ区切りテキストを表示できるようにしておくと、

require "./src/compiler/crystal/syntax"

str = ARGF.gets_to_end
a = Crystal::Lexer.new(str)
loop do
  t = a.next_token
  puts [t.type,
        t.value,
        t.number_kind,
        t.line_number,
        t.column_number,
        t.filename,
        t.delimiter_state.kind,
        t.delimiter_state.nest,
        t.delimiter_state.end,
        t.delimiter_state.open_count,
        t.delimiter_state.heredoc_indent,
        t.delimiter_state.allow_escapes,
        t.macro_state.whitespace,
        t.macro_state.nest,
        t.macro_state.control_nest,
        t.macro_state.delimiter_state,
        t.macro_state.beginning_of_line,
        t.macro_state.yields,
        t.macro_state.comment,
        t.macro_state.heredocs,
        t.passed_backslash_newline,
        t.doc_buffer,
        t.raw,
        t.start,
        t.invalid_escape,
        t.location].map { |i| i.to_s }.join("\t")
  break if t.type == Crystal::Token::Kind::EOF
end

test.crを解析させると

test.cr
x = 0

こんな結果が得られます。一行目を見ると、INDENT, SPACE, =, NUMBER, NEWLINE, EOF などが非常に細かい単位で分類されていることがわかると思います。

IDENT	x	i32	1	1		STRING			0	0	true	true	0	0		true	false	false		false			0	false	:1:1
SPACE		i32	1	2		STRING			0	0	true	true	0	0		true	false	false		false			0	false	:1:2
=		i32	1	3		STRING			0	0	true	true	0	0		true	false	false		false			0	false	:1:3
SPACE		i32	1	4		STRING			0	0	true	true	0	0		true	false	false		false			0	false	:1:4
NUMBER	0	i32	1	5		STRING			0	0	true	true	0	0		true	false	false		false			0	false	:1:5
NEWLINE		i32	1	6		STRING			0	0	true	true	0	0		true	false	false		false			0	false	:1:6
EOF		i32	3	1		STRING			0	0	true	true	0	0		true	false	false		false			0	false	:3:1

構文解析器(Parser)を使ってみよう

ParserはLexerを継承しています。かなり不格好ですが次のようなコードを作成しました。

require "./src/compiler/crystal/syntax"

str = ARGF.gets_to_end
a = Crystal::Parser.parse(str)

def puts_ast(ast)
  puts "\e[32m# #{ast.class}\e[0m" # green
  puts ast
  puts
end

if a.is_a?(Crystal::Expressions)
  a.expressions.each do |ast|
    puts_ast(ast)
  end
else
  puts_ast(a)
end

これを自分自身に対して実行すると次のような出力が得られます。コードがいくつかの単位に分割されていることがわかります。

# Crystal::Require
require "./src/compiler/crystal/syntax"

# Crystal::Assign
str = ARGF.gets_to_end

# Crystal::Assign
a = Crystal::Parser.parse(str)

# Crystal::Def
def puts_ast(ast)
  puts("\e[32m# #{ast.class}\e[0m")
  puts(ast)
  puts
end

# Crystal::If
if a.is_a?(Crystal::Expressions)
  a.expressions.each do |ast|
    puts_ast(ast)
  end
else
  puts_ast(a)
end

Parserが作成したASTノードを渡り歩くにはビジターモデルというものが使われているようです。が、すみません。今回はそこまで理解できていません。

コンパイラ

    def compile(source : Source | Array(Source), output_filename : String) : Result
      source = [source] unless source.is_a?(Array)
      program = new_program(source)
      node = parse program, source
      node = program.semantic node, cleanup: !no_cleanup?
      result = codegen program, node, source, output_filename unless @no_codegen

      @progress_tracker.clear
      print_macro_run_stats(program)
      print_codegen_stats(result)

      Result.new program, node
    end

Semantic

Semanticはとても難しいらしく、ここまでくると解説資料も少なくなります。ここではVisitorモデルというのが使われているらしいです。

Aryさんの資料のPart2があります(ひょっとするとこれも未完成かもしれません)
私は諦めてしまいましたが、興味がある人はご覧ください。

LLVM IRはこの先で出力されているのだと思いますが、去年はそこまで追いかけることはできませんでした。

まとめ

Crystal --> assembly

crystal build --emit asm --prelude empty test.cr

Crystal --> LLVM IR

crystal build --emit llvm-ir --prelude empty test.cr

LLVM IR --> assembly

llc --relocation-model=pic test.ll

assembly --> executable

clang test.s

必要に応じて、リンクするライブラリを指定

clang test.s -levent -lgc -lm

llvm .ll -> .bc

llvm-as test.ll
lli test.bc

今回よくわからなかったこと

今回はCrstalのコードがどのように動いているのか簡単に観察しました。

この記事を書いたモチベーションの一つは、Crystalが分割コンパイルができない問題の理由を理解したいという気持ちです。Crystalで作成した関数を共有ライブラリに出力して他のライブラリから呼び出すことは推奨されていないとされます。その理由が知りたいと思っていました。

伝聞によるとCrystalは型を数値として管理しているらしく、最初にすべてのコードを読み込んで、型を数値にするらしく、分割コンパイルすると型の整合性が取れないようです。

分割コンパイルするときに、Aのコンパイルで作成した数値と型との対応は、Bの分割コンパイルで作成した数値と型の対応と異なり、整合性が取れなくなると理解しています。しかし、これはAryさんがわかりやすく説明するために単純化したもので、実際にはもっと難しい問題なのだろうと思います。

これを理解する前にSemanticsの中身を理解する必要があるようです。Aryさんのスライドによると、Aryさんの実装も特定の論文や既存のベストプラクティスに依拠しているわけではなく、試行錯誤しながら自分で組み立てたというふうに書いてある気がします。

これについては数日調べた程度で理解するのはおこがましいというものでしょう…。今回の探索はここまでとします。

最後に

この記事を書くまで、私はアセンブリ言語や、LLVM中間表現などに一回も触れたことがありませんでした。Rubyでスクリプトを書いているとなかなかこういった低レベルに触れる機会は少ないので、こういった新しい世界を学習する機会を与えてくれたCrystal言語とその作者に感謝しています。Crystal言語の開発の中心はアルゼンチンにありますが、オープンソースのプログラミング言語であり、誰でもその開発に参加することは可能です。この記事を見てCrystal言語に興味を持った方は、ぜひCrystal言語そのものの開発に挑戦してみてはいかがでしょうか。

この記事は以上です。

参考文献

10
5
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?