DIVE

Part 2: アセンブリ・実行モデル

第 4 章

人間の言葉が機械の言葉になるまで — 機械語とCの距離

前章で、CPUは「数字の列(機械語)を命令として実行するだけ」だと分かった。では、私たちが書く int x = 3 + 4; のような人間の言葉は、どうやってあの数字の列になるのか。

この章は、Part 2の入り口だ。高級言語と物理の間に架かる「抽象化のはしご」を、一段ずつ降りていく。


抽象化のはしご

プログラムは、いくつもの層を経てCPUに届く。

  高級言語 (C, Python, ...)     ← 人間が読み書きする
        │  コンパイル
        ▼
  アセンブリ言語               ← 機械語に1対1で対応する人間可読の形
        │  アセンブル
        ▼
  機械語 (0と1)                ← CPUが直接実行する数字の列
        │
        ▼
  CPU (フェッチ・デコード・実行) ← 第3章の世界

各層は「下の層の複雑さを隠す」ために存在する。抽象化とは、下の面倒を見なくて済むようにする仕組みだ。だが、隠されているだけで消えてはいない。「おまじない」を解くには、はしごを降りて下の層を覗く必要がある。


機械語 — CPUの母語

機械語は、命令を表す数字の列だ。前章の自作CPUで 0x01, 0, 3(LOAD r0, 3)と書いたが、あれがまさに機械語だった。

実物のx86-64 CPUでは、例えば「レジスタに5を入れる」命令はこうなる:

機械語(16進):  b8 05 00 00 00
意味:          mov eax, 5     (eaxレジスタに5を代入)

b8 が「eaxに即値を入れろ」というオペコード、続く 05 00 00 00 が値の5(リトルエンディアンの32ビット)。CPUはこの5バイトを読んで、レジスタに5を入れる。人間には解読困難だが、CPUにとっては明快な母語だ。


アセンブリ — 機械語に名前をつける

機械語の数字は人間に厳しすぎる。そこで、機械語と1対1で対応する、人間が読める記号を割り当てた。これがアセンブリ言語だ。

b8 05 00 00 00 → mov eax, 5

mov(move)、add、sub、jmp(jump)といったニーモニック(記憶しやすい記号)で命令を書く。アセンブリを機械語に変換するツールをアセンブラと呼ぶ。

「3 + 4」をアセンブリで書くとこうなる(x86-64・簡略化):

    mov eax, 3      ; eax に 3 を入れる
    add eax, 4      ; eax に 4 を足す → eax = 7
    ; 結果は eax レジスタに入っている

第3章の「フェッチ・デコード・実行」を思い出してほしい。この各行が、まさに1つの命令としてCPUに読まれ、実行される。アセンブリは機械語に名前を付けただけなので、CPUの動きがそのまま見える。


C — 「移植可能なアセンブリ」

アセンブリは強力だが、CPUの種類(x86, ARM…)ごとに命令が違う。同じプログラムを別のCPUで動かすには、全部書き直しになる。

この問題を解いたのがC言語だ。1972年、ベル研究所のデニス・リッチーが開発した。Cは「人間に読みやすく、かつ機械語に近い」絶妙な位置にいる。しばしば「移植可能なアセンブリ」と呼ばれる。

int main(void) {
    int x = 3 + 4;   // 人間に読みやすい
    return x;
}

このCコードは、コンパイラによって、各CPU向けの機械語に翻訳される。同じソースコードから、x86用にもARM用にも機械語を生成できる。これがCの革命だった。


コンパイルの4段階

gcc hello.c -o hello という1コマンドの裏で、実は4つの工程が走っている。

hello.c
   │  ① プリプロセス (cpp)   #include や #define を展開
   ▼
hello.i
   │  ② コンパイル (cc1)     C → アセンブリに翻訳
   ▼
hello.s   (アセンブリ)
   │  ③ アセンブル (as)      アセンブリ → 機械語(オブジェクトファイル)
   ▼
hello.o   (機械語)
   │  ④ リンク (ld)          ライブラリと結合し実行ファイルへ
   ▼
hello     (実行ファイル)

実際に、Cがどんなアセンブリになるか覗いてみよう。

# Cコードをアセンブリに変換して確認する
$ gcc -S -O0 add.c -o add.s
$ cat add.s

add.c が:

int add(int a, int b) {
    return a + b;
}

だとすると、生成されるアセンブリ(x86-64・抜粋)はおおむね:

add:
    mov     eax, edi     ; 第1引数 a を eax へ
    add     eax, esi     ; 第2引数 b を足す
    ret                  ; eax(結果)を返す

a + b という1行が、mov と add と ret の3命令になった。Cの1文が、複数の機械語命令に展開される。この対応関係が見えると、「なぜこの書き方は速い/遅い」が理解できるようになる。


なぜ抽象化を降りると強くなるのか

高級言語だけを使っていると、次のような「おまじない」に出会う。

Cの実行:ソース→事前コンパイル機械語→直接実行CPU\text{Cの実行:}\quad \text{ソース} \xrightarrow{\text{事前コンパイル}} \text{機械語} \xrightarrow{\text{直接実行}} \text{CPU} Pythonの実行:ソース→1行ずつ解釈仮想マシン→CPU\text{Pythonの実行:}\quad \text{ソース} \xrightarrow{\text{1行ずつ解釈}} \text{仮想マシン} \rightarrow \text{CPU}

抽象化のはしごを降りて「下で何が起きているか」を知ると、上の層の挙動が必然として理解できる。これがDIVEの一貫したテーマだ。


まとめ — 「おまじない」の消し方

  1. 抽象化のはしご:高級言語 → アセンブリ → 機械語 → CPU。各層は下の複雑さを隠すが、消してはいない
  2. 機械語はCPUの母語:命令を表す数字の列。アセンブリはそれに人間可読な名前を付けたもの
  3. Cは移植可能なアセンブリ:機械語に近く、かつCPUの違いをコンパイラが吸収する
  4. コンパイル=翻訳:Cの1文が複数の機械語命令に展開される。この対応が性能理解の鍵

「プログラムが実行される」とは、翻訳された機械語をCPUが読むこと。その距離感が掴めた。

次章では、この実行の最中に、関数呼び出しやローカル変数がメモリのどこに、どう置かれるのか——スタックとヒープの世界へ降りていく。


「コンパイラとは、人間の怠惰と機械の厳密さの間を取り持つ、最も偉大な翻訳者である。」