every Tech Blog

株式会社エブリーのTech Blogです。

プログラムがCPUに理解されるまでのプロセスをまとめてみた

プログラムがCPUに理解されるまでのプロセスをまとめてみた

目次

はじめに

こんにちは。 トモニテ開発部ソフトウェアエンジニア兼、CTO室Dev Enableグループの庄司(ktanonymous)です。
every Tech Blog Advent Calendar 2024(夏) の2日目の記事執筆担当者として参加させていただいております!

tech.every.tv

今回の記事では、普段書いているプログラムがCPUによってどのように理解されているのかについて、気になって勉強したのでまとめてみたいと思います。
(厳密には異なる表現があるかもしれませんが、概念的な理解を目指すものなので、ご容赦ください。)

CPUが理解できる言葉

CPUが理解できる言葉は、機械語と呼ばれるものです。 我々エンジニアが普段から書いているプログラミング言語は、CPUから見れば 「意味のわからない、ただの文字列でしかない」と言えるでしょう。
機械語とは、例えば、以下のように表現することができます。(CPUの種類によって表現が異なっていたり、そもそもの解読が辛かったりするので正確な表現・値ではありません)

01 00 10

各数値は16進数で表されており、これで「アドレス00番地に値10を書き込む」というように、処理(01)と必要な対象を組み合わせて1つの命令を表現します。 では、プログラミング言語はどのように機械語としてCPUに解釈されるのでしょうか。

プログラミング言語が機械語として理解されるまで

アセンブリ言語

現在一般的に使われているプログラミング言語の話をする前に、アセンブリ言語について触れたいと思います。
アセンブリ言語とは、人間が理解しやすいように機械語と1対1で対応させた言語です。 アセンブリ言語がアセンブラと呼ばれるプログラムによって機械語に変換されることで、CPUが言語を理解できるようになります。 アセンブリ言語に関しては、実際にシェル上でobjdumpコマンド1を利用することで確認することができます(これはオブジェクトファイルの情報を表示するコマンドですが、-dフラグをつけることで機械語を逆アセンブルすることができます)。 例として、筆者のマシン上でecho命令を逆アセンブルしてみます。

$ objdump -d /bin/echo

すると、以下のような出力が得られます。 (なお、出力結果の全体は非常に長いので、先頭の数行を抜粋しています)

/bin/echo (architecture x86_64):
(__TEXT,__text) section
100000bbc:      55      pushq   %rbp
100000bbd:      48 89 e5        movq    %rsp, %rbp
100000bc0:      41 57   pushq   %r15
100000bc2:      41 56   pushq   %r14
100000bc4:      41 55   pushq   %r13
100000bc6:      41 54   pushq   %r12
100000bc8:      53      pushq   %rbx
100000bc9:      48 83 ec 28     subq    $40, %rsp
...

左から、ファイル(今回は /bin/echo )上でのオフセット、実際の機械語(16進数の値2~4つの組)、機械語に対応する命令を表すニーモニック(mnemonic)の順に並んでいます。

プログラミング言語の解釈

一般的に、我々が日々書いているソースコードは、アセンブリ言語への変換を目指して解釈が進められ、最終的にCPUが実行可能な機械語へと変換されます。
この解釈の過程を担っているのが、コンパイラやインタプリタと呼ばれるものになります。 コンパイル型言語やインタプリタ型言語というのは、この解釈の過程がどのように行われるかによって分類されます。

コンパイラ

コンパイラ  C は以下のように定義できます2 (説明のため一部表現を変えています)。

言語  L_2 のプログラムを言語  L_1 のプログラムに変換するプログラム  C

一般的なコンパイル型言語では、ソースコードをアセンブリ言語まで変換する役割をコンパイラが担っていることが多いでしょう(アセンブラはコンパイラに含まれている場合もあります)。 この定義から考えると、アセンブラもコンパイラの一種と言えると思いますが、 アセンブリ言語から機械語への変換は、変換元がアセンブリ言語であることを強調するためにアセンブラと呼ばれることがあります。
コンパイル型言語の1つとして、弊社でも利用されているGo言語が挙げられます。 Go言語では以下のステップを経てソースコードがコンパイルされます3

  1. Parsing
    1. Lexical analysis (tokenize)
    2. Syntax analysis (parse)
    3. AST construction
  2. Type checking
  3. IR construction
  4. Middle end (最適化)
  5. Walk (順序評価、構文の低級化)
  6. Generic SSA(Static Single Assignment4)
  7. Generating machine code

Goのコンパイラにはアセンブラも含まれているため、最終的には機械語に変換されていることがわかります。

Goの標準のコンパイラである gc はGoで実装されています。 これは、セルフホスティングと呼ばれる手法で、自身の言語で自身のコンパイラを書くというものです。 gcは元々C言語で書かれていましたが、この手法を用いることでGoで書かれたコンパイラが実現されています5。 自身の言語で実装されたコンパイラを実行するために、コンパイラのコード自身がコンパイルされている必要があります。 そのため、異なる言語で実装されたコンパイラ  C_1 を用いて自身の言語で実装されたコンパイラ  C_2 をコンパイルし、 最後に  C_2  C_2 自身をコンパイルすることで、自身の言語で書かれたコンパイラが完成します(イメージ)。 また、コンパイラの実現方法の他に、コンパイルの手法にもJIT(Just-In-Time)コンパイラ6やAOT(Ahead-Of-Time)コンパイラ7などの種類があります。 TypeScriptからJavaScriptへの変換(トランスパイル)もコンパイルの一種です。

リンカ

一般的に、プログラムは複数のソースコードから構成されます。そのため、 コンパイラによって生成されたそれぞれのオブジェクトファイルは、そのままでは実行可能な1つのプログラムとはなりません。 これらのオブジェクトファイルをリンカと呼ばれるプログラムによって結合することで、実行可能な1つのプログラムが生成されます。
リンカは、関数のエントリーポイント情報を補完するなどして、複数のオブジェクトファイルを結合した1つの実行可能ファイルなどを生成します。 通常、ソースコードを機械語にコンパイルしてリンクするまでの過程を指して「ビルド」と呼びます。 なお、生成された実行可能ファイルは、ローダーによってストレージ(外部記憶装置)からメインメモリ(RAM)などに読み込まれます。
また、リンクには静的リンクと動的リンクの2種類があります。 静的リンクは、プログラムの実行に必要なライブラリなどを単一の実行ファイル内部にリンクする方法、 動的リンクは、呼び出される側のライブラリが実行時にリンクされる方法です。 リンク方法が静的か動的かどうかで実行ファイルのサイズや開発サイクルのスピードなどに違いが出てきます。

インタープリタ

インタープリタ  I は以下のように定義できます8 (説明のため一部表現を変えています)。

言語  L_1 を用いて実現した、言語  L_2 のプログラムが動作するプログラム  I

インタープリタは、実行時にソースコードを逐次解釈して実行するものです。 インタープリタの解釈手法には、ソースコードをそのまま逐次解釈するものもあれば、 一度バイトコードなどに変換(コンパイル)してから逐次解釈するものもあります。
例えば、Pythonの標準的なインタープリタであるCPython9は、 ソースコードをバイトコード(中間表現)に変換してから逐次解釈され、 PVM(Python Virtual Machine)によって処理されます10。 ちなみに、Pythonのバイトコードはdisモジュール11を利用することで確認することもできます。 例えば、以下のようなコードを実行する場合を考えます(Google Colaboratoryでの実行を前提にしています)。

import dis

def print_hello():
  print("Hello!")

dis.dis(print_hello)

このコードを実行すると、以下のような出力が得られます。

  4           0 LOAD_GLOBAL              0 (print)
              2 LOAD_CONST               1 ('Hello!')
              4 CALL_FUNCTION            1
              6 POP_TOP
              8 LOAD_CONST               0 (None)
             10 RETURN_VALUE

左から、ソースファイル内での行番号、命令のバイトコード(とその該当バイトインデックス)、 命令が取得する引数の参照インデックスと引数の順に並んでいます。

まとめ

今回の記事では、普段意識することのなかったプログラムの処理系について勉強したことをアウトプットしてみました。
これを知ったからといって普段のコーディングが劇的に変わるということはないと思いますが、 こういった基礎的な知識がシビアなシーンでは役に立つことも多いと思います。 この記事が、「なんかそれっぽいこと書いてるだけで勝手にPCが結果を出してくれる」を脱却したい人の一助になれば幸いです。
最後まで読んでいただき、ありがとうございました。

参考