プログラムを書くとき、私たちは C や Python といった「人間が読みやすい言語」を使います。しかし CPU が実際に読むのは マシン語(機械語) と呼ばれる数値の羅列です。このページでは「マシン語とは何か」「アセンブラとどう対応するか」、そして「CPU がどの順番で命令を実行するか」を担う プログラムカウンタ(PC) について説明します。
CPU はメモリから「命令」を読み取って動作します。この命令は 整数値(バイト列) として表現されており、これを マシン語 と呼びます。
たとえば次の C コードをコンパイルすると:
int a = 3 + 5;
メモリ上には次のようなバイト列が書き込まれます(ARM Cortex-M の場合):
| アドレス | バイト列(16進数) | 意味 |
|---|---|---|
| 0x08000000 | 03 20 | MOV r0, #3 |
| 0x08000002 | 05 21 | MOV r1, #5 |
| 0x08000004 | 08 18 | ADD r0, r1, r0 |
03 20 という2バイトは、「レジスタ r0 に値 3 を入れろ」という命令を CPU が解釈できる形式でエンコードしたものです。
マシン語のバイト列を人間が書いたり読んだりするのは困難です。そこで、各命令に対して ニーモニック(覚えやすい名前) を付けたのが アセンブリ言語(アセンブラ)です。
マシン語とアセンブラは 1対1 に対応します。
| マシン語(16進数) | アセンブラ命令 | 意味 |
|---|---|---|
03 20 | MOV r0, #3 | r0 ← 3 |
05 21 | MOV r1, #5 | r1 ← 5 |
08 18 | ADD r0, r1, r0 | r0 ← r1 + r0 |
70 47 | BX LR | PC ← LR(関数から戻る) |
アセンブラソースファイル(.s)を アセンブル すると対応するバイト列が生成され、実行可能なバイナリになります。逆に実行ファイルをバイト列から読み直してニーモニックに変換することを 逆アセンブル(disassemble) と呼びます。
マシン語のバイト列は、命令の種類・対象レジスタ・即値などを決まったビットフォーマットに詰め込んだものです。
MOV r0, #32バイト(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)、定数値(即値)が一つのバイト列に収まっています。
MOV eax, 35バイト: B8 03 00 00 00
B8 = オペコード(MOV eax, imm32)
03 00 00 00 = 即値 3(リトルエンディアン 32bit)
x86 は命令によってバイト長が異なります(1〜15バイト)。ARM Thumb は主に2バイトまたは4バイトで固定長に近い設計です。
CPU は 「今どの命令を実行するか」 を常に管理しなければなりません。その役割を担う専用レジスタが プログラムカウンタ(Program Counter / PC) です。
PC には「次に実行する命令のメモリアドレス」が格納されています。CPU はこの流れを繰り返すことでプログラムを実行します:
この繰り返しを フェッチ・デコード・実行サイクル(Fetch-Decode-Execute cycle) と呼びます。
| アドレス | マシン語 | アセンブラ | |
|---|---|---|---|
| 0x08000000 | 03 20 | MOV r0, #3 | ← PC = 0x08000000(ここを実行) |
| 0x08000002 | 05 21 | MOV r1, #5 | |
| 0x08000004 | 08 18 | ADD r0, r1, r0 | |
| 0x08000006 | 70 47 | BX LR |
PC が 0x08000000 を指しているとき、CPU は 03 20(= MOV r0, #3)を読み取って実行し、PC を 0x08000002 に進めます。
命令が実行されると、CPU は PC を「その命令のバイト数だけ」進めます。どちらのアーキテクチャも固定長ではなく、命令ごとに長さが異なります。
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バイトになる。
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 が命令長を自動的に識別します。
0x08000000 → 0x08000004 → 0x08000008 … と 4ずつ 増えます。
実機の逆アセンブル結果(2ずつ・4ずつ混在)とは見た目が異なりますが、
「ステップごとに PC が次の命令を指す」という本質的な動きを学ぶためには支障ありません。
通常の命令では PC が「命令長分だけ自動的に進む」だけです。しかし if 文や関数呼び出しでは、PC を特定のアドレスへ強制的に書き換える必要があります。これが 分岐命令(Branch instruction) です。
| 命令 | 動作 |
|---|---|
B label | PC ← label のアドレス(無条件ジャンプ) |
BEQ label | Z フラグが 1 なら PC ← label |
BL func | LR ← PC+4, PC ← func(関数呼び出し) |
BX LR | PC ← LR(関数から戻る) |
| 命令 | 動作 |
|---|---|
JMP label | RIP ← label のアドレス(無条件ジャンプ) |
JE label | ZF=1 なら RIP ← label |
CALL func | [RSP] ← RIP+?, RSP-=8, RIP ← func |
RET | RIP ← [RSP], RSP+=8(関数から戻る) |
分岐命令が実行されると PC が大きく別の場所に飛びます。条件分岐では「ある条件を満たしたときだけ PC を書き換える」ため、同じプログラムでも入力によって異なる経路を通ります。
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 が実行されます。
BL / CALL)のステップでは PC が大きく別のアドレスに飛ぶことが確認できます。