マシン語とアセンブラ、そしてPC

プログラムを書くとき、私たちは C や Python といった「人間が読みやすい言語」を使います。しかし CPU が実際に読むのは マシン語(機械語) と呼ばれる数値の羅列です。このページでは「マシン語とは何か」「アセンブラとどう対応するか」、そして「CPU がどの順番で命令を実行するか」を担う プログラムカウンタ(PC) について説明します。

目次

  1. マシン語とは
  2. アセンブラはマシン語の別名
  3. 命令エンコーディングを覗いてみる
  4. プログラムカウンタ(PC)の役割
  5. PCが進む仕組み
  6. 分岐命令とPCの書き換え

1. マシン語とは

CPU はメモリから「命令」を読み取って動作します。この命令は 整数値(バイト列) として表現されており、これを マシン語 と呼びます。

たとえば次の C コードをコンパイルすると:

int a = 3 + 5;

メモリ上には次のようなバイト列が書き込まれます(ARM Cortex-M の場合):

アドレスバイト列(16進数)意味
0x0800000003 20MOV r0, #3
0x0800000205 21MOV r1, #5
0x0800000408 18ADD r0, r1, r0

03 20 という2バイトは、「レジスタ r0 に値 3 を入れろ」という命令を CPU が解釈できる形式でエンコードしたものです。

マシン語 = CPU が唯一理解できる言語:
CPU は電気信号の ON/OFF(0と1)しか扱えません。マシン語はその 0 と 1 の列を16進数で表記したものです。 C言語のような高級言語は人間のために作られた表記であり、最終的には必ずこのバイト列に変換されて初めて CPU が実行できます。

2. アセンブラはマシン語の別名

マシン語のバイト列を人間が書いたり読んだりするのは困難です。そこで、各命令に対して ニーモニック(覚えやすい名前) を付けたのが アセンブリ言語(アセンブラ)です。

マシン語とアセンブラは 1対1 に対応します。

マシン語(16進数)アセンブラ命令意味
03 20MOV r0, #3r0 ← 3
05 21MOV r1, #5r1 ← 5
08 18ADD r0, r1, r0r0 ← r1 + r0
70 47BX LRPC ← LR(関数から戻る)

アセンブラソースファイル(.s)を アセンブル すると対応するバイト列が生成され、実行可能なバイナリになります。逆に実行ファイルをバイト列から読み直してニーモニックに変換することを 逆アセンブル(disassemble) と呼びます。

C → アセンブラ → マシン語 の流れ:
  1. C コードを コンパイル してアセンブラを生成(GCC の仕事)
  2. アセンブラを アセンブル してバイト列に変換(as の仕事)
  3. バイト列を含む 実行ファイル(ELF / EXE 等)が完成
AsmWalker では Godbolt API 経由でステップ1・2を自動的に行い、その結果のアセンブラをステップ実行できます。

3. 命令エンコーディングを覗いてみる

マシン語のバイト列は、命令の種類・対象レジスタ・即値などを決まったビットフォーマットに詰め込んだものです。

ARM Thumb 命令の例: MOV r0, #3

2バイト(16bit Thumb): 0x2003

ビット表現:  0 0 1 0 0  0 0 0  0 0 0 0 0 0 1 1
             ─────────  ─────  ───────────────
             オペコード  Rd=r0  即値 = 3 (0x03)
              MOV imm

CPU はこの16ビット列を受け取ると「r0 に値 3 を入れる」と解釈します。命令の種類(オペコード)、対象レジスタ(Rd)、定数値(即値)が一つのバイト列に収まっています。

x86-64 命令の例: MOV eax, 3

5バイト: B8 03 00 00 00

B8       = オペコード(MOV eax, imm32)
03 00 00 00 = 即値 3(リトルエンディアン 32bit)

x86 は命令によってバイト長が異なります(1〜15バイト)。ARM Thumb は主に2バイトまたは4バイトで固定長に近い設計です。

なぜこの話が重要か:
バイト列を見ると「なぜ PC が 2 ずつ・4 ずつ進むのか」が自然に理解できます。 1命令が2バイトであれば、次の命令のアドレスは「現在のアドレス + 2」です。 命令がメモリ上に隙間なく並んでいるからこそ、PCを命令長分だけ進めれば次の命令に届きます。

4. プログラムカウンタ(PC)の役割

CPU は 「今どの命令を実行するか」 を常に管理しなければなりません。その役割を担う専用レジスタが プログラムカウンタ(Program Counter / PC) です。

PC には「次に実行する命令のメモリアドレス」が格納されています。CPU はこの流れを繰り返すことでプログラムを実行します:

  1. PC が指すアドレスからマシン語命令をフェッチ(読み出し)
  2. 命令をデコード(解釈)
  3. 命令を実行(レジスタ・メモリの更新など)
  4. PC を次の命令のアドレスに更新
  5. 1 に戻る

この繰り返しを フェッチ・デコード・実行サイクル(Fetch-Decode-Execute cycle) と呼びます。

アドレスマシン語アセンブラ
0x0800000003 20MOV r0, #3← PC = 0x08000000(ここを実行)
0x0800000205 21MOV r1, #5
0x0800000408 18ADD r0, r1, r0
0x0800000670 47BX LR

PC が 0x08000000 を指しているとき、CPU は 03 20(= MOV r0, #3)を読み取って実行し、PC を 0x08000002 に進めます。

「PC が命令を指す」とはどういう意味か:
プログラムはメモリ上に「命令が並んだリスト」として存在しています。 PC は「今何行目を実行しているか」を示す行番号、あるいはエディタのカーソルのような存在です。 CPU は PC が指す位置から命令を読んで実行し、通常は自動的に次へ進めます。 しかし特定の命令は、PC を全く別の位置に強制的に書き換えます。それが分岐・関数呼び出しの正体です。

5. PCが進む仕組み

命令が実行されると、CPU は PC を「その命令のバイト数だけ」進めます。どちらのアーキテクチャも固定長ではなく、命令ごとに長さが異なります。

ARM Thumb-2(2バイトと4バイトが混在)

PC = 0x08000000
; PUSH {r7, lr} を実行(2バイト)
PC = 0x08000000 + 2 = 0x08000002
; MOV r0, #3 を実行(2バイト)
PC = 0x08000002 + 2 = 0x08000004
; BL add を実行(4バイト)
PC = 0x08000004 + 4 = 0x08000008

単純な命令は2バイト、BL などの長距離ジャンプ命令は4バイトになる。

x86-64(1〜15バイトの可変長)

PC = 0x401000
; push rbp を実行(1バイト)
PC = 0x401000 + 1 = 0x401001
; mov rbp, rsp を実行(3バイト)
PC = 0x401001 + 3 = 0x401004
; sub rsp, 16 を実行(4バイト)
PC = 0x401004 + 4 = 0x401008

命令ごとに長さが大きく異なる。CPU はデコード時に命令長を判定してから PC を進める。

ARM Thumb-2 は2バイトと4バイトの2種類のみなのに対し、x86 は1〜15バイトまで命令ごとに大きく異なります。どちらも先頭のビット列を読むことで CPU が命令長を自動的に識別します。

⚠ AsmWalker ではすべての命令を4バイト固定として扱います

上の例は実際の命令長に基づいて PC が進む様子を示しました。しかし AsmWalker では異なります。

本来の命令バイト数を取得するには各命令をエンコード・逆アセンブルする必要があります。 AsmWalker では 学習のしやすさを優先し、ARM Thumb の2バイト命令も x86 の可変長命令も、 すべて 4バイト固定 の仮想アドレスを割り当てています。

そのため ARM サンプルをコンパイルすると、アドレスは常に 0x08000000 → 0x08000004 → 0x08000008 …4ずつ 増えます。 実機の逆アセンブル結果(2ずつ・4ずつ混在)とは見た目が異なりますが、 「ステップごとに PC が次の命令を指す」という本質的な動きを学ぶためには支障ありません。

6. 分岐命令とPCの書き換え

通常の命令では PC が「命令長分だけ自動的に進む」だけです。しかし if 文や関数呼び出しでは、PC を特定のアドレスへ強制的に書き換える必要があります。これが 分岐命令(Branch instruction) です。

ARM の分岐命令

命令動作
B labelPC ← label のアドレス(無条件ジャンプ)
BEQ labelZ フラグが 1 なら PC ← label
BL funcLR ← PC+4, PC ← func(関数呼び出し)
BX LRPC ← LR(関数から戻る)

x86-64 の分岐命令

命令動作
JMP labelRIP ← label のアドレス(無条件ジャンプ)
JE labelZF=1 なら RIP ← label
CALL func[RSP] ← RIP+?, RSP-=8, RIP ← func
RETRIP ← [RSP], RSP+=8(関数から戻る)

分岐命令が実行されると PC が大きく別の場所に飛びます。条件分岐では「ある条件を満たしたときだけ PC を書き換える」ため、同じプログラムでも入力によって異なる経路を通ります。

具体例:if 文がアセンブラになると

int a = 5;
int b = 3;
if (a > b) {
    a = a - b;  // この行が実行される
}

ARM Cortex-M のアセンブラ出力(概略):

        mov  r0, #5         ; a = 5
        mov  r1, #3         ; b = 3
        cmp  r0, r1         ; a と b を比較(フラグを更新)
        ble  .end           ; a <= b なら .end へジャンプ(PC を書き換え)
        sub  r0, r0, r1     ; a = a - b(a > b のときだけ実行)
.end:

CMP 命令が内部フラグ(N/Z/C/V)を更新し、BLE 命令がそのフラグを見て「PC をジャンプ先に書き換えるかどうか」を決めます。条件が成立しなければ PC は普通に1命令分進み、次の SUB が実行されます。

まとめ:PCとアセンブラの関係
AsmWalker での確認:
AsmWalker の右下「特殊レジスタ」パネルに PC(ARM)または RIP(x86)の現在値がリアルタイムで表示されます。 ステップを1つ進めるたびにアドレスが変化する様子を確認してみましょう。 関数呼び出し(BL / CALL)のステップでは PC が大きく別のアドレスに飛ぶことが確認できます。