CPU の仕組みの基本:レジスタとプログラムカウンタで処理が進む流れ

約 14 分で読めます

CPU は、メモリから命令を読み出し、レジスタを使って計算する装置です。順番に進む処理も、条件分岐・繰り返し・関数呼び出しも、最終的には「プログラムカウンタというレジスタの値をどう変えるか」で表されています。プログラマの目線から、CPU の構成、メモリ、アセンブリ言語、レジスタの役割、処理の流れの作られ方を整理しました。

この記事は、矢沢久雄さんの『プログラムはなぜ動くのか』を読んで CPU の仕組みに興味を持ち、学んだことを自分の言葉で整理した記録です。記事中の動作は、手元の環境で確かめています。

この記事でわかること

  • CPU を構成する要素 (レジスタ・制御装置・演算装置・クロック) の役割
  • メモリとアドレスの関係と、アセンブリ言語・マシン語の関係
  • マシン語の命令の大まかな分類
  • 代表的なレジスタの種類と、x86 系の CPU での実際の名前
  • 順次進行・条件分岐・繰り返し・関数呼び出し・配列の参照が、CPU の中でどう実現されているか

試した環境

記事中のアセンブリと実行結果は、次の環境で C のコードをコンパイル・実行して得たものです。

項目バージョン
CPUAMD Ryzen 9 9900X (x86-64)
OSArch Linux (WSL2)
コンパイラGCC 16.2.1
逆アセンブラ / デバッガGNU objdump 2.47 / GNU gdb 18.1

アセンブリは gcc -S -masm=intel で出力しました (-masm=intel は「命令 宛先, 元」の順で書く Intel 記法にするオプション)。最適化は、特に書いていない限り無効 (-O0) です。読みやすさのため、関数名の引用符や一部の行を省いています。CPU の種類やコンパイラの設定が違えば、出力も変わります。

学んだこと 1: CPU はレジスタ・制御装置・演算装置・クロックでできている

CPU (Central Processing Unit) はプロセッサとも呼ばれます。CPU の機能を 1 つの IC (集積回路) チップにまとめたものをマイクロプロセッサと呼び、今のパソコンの CPU はこれにあたります。CPU もメモリも、中身は膨大な数のトランジスタを組み合わせた IC です。

CPU がしていることは、マシン語 (機械語) で書かれたプログラムを読み取り、そのとおりに動くことです。中身は次の 4 つの要素に分けて考えられます。

要素役割
レジスタCPU の中にある小さな記憶場所。これから処理する命令やデータを置く
制御装置メモリから命令とデータを取り込み、命令の内容と結果に応じて各部に指示を出す
演算装置レジスタのデータを使って、足し算や比較などの計算をする
クロック各部が動くタイミングをそろえるための、一定間隔の信号 (クロック信号)

プログラムを書くときに直接扱うのは、このうちのレジスタです。制御装置と演算装置は、プログラムの指示どおりに動きます。そのため、CPU を理解するにはレジスタの仕組みから押さえるのがわかりやすいです。

クロック周波数は、1 秒あたりのクロック信号の数です。同じ設計の CPU なら、周波数が高いほど速く動きます。ただし、1 クロックでこなせる命令の数は CPU の設計によって違うので、周波数だけで異なる CPU の速さを比べることはできません。なお、今の CPU は、マザーボードから受け取った基準のクロックを内部で何倍かにして使っています。

学んだこと 2: メモリは 1 バイトごとにアドレスが付いている

パソコンで普通「メモリ」と呼ぶのは、メインメモリ (主記憶) のことです。メインメモリには、実行中のプログラムの命令とデータの両方が入っています。

メモリには 1 バイトごとにアドレス (番地) が付いています。CPU はアドレスを指定して、メインメモリから命令やデータを読み出したり、データを書き込んだりします。

メインメモリは揮発性で、電源を切ると内容が消えます。電源を切っても残しておきたいデータは、SSD などのストレージに保存します。

学んだこと 3: アセンブリ言語はマシン語に名前を付けたもの

CPU が実行できるのはマシン語だけです。マシン語の命令は数値の並びなので、人間には読みにくいです。そこで、命令ごとに動作を表す英語の略語 (ニーモニック) を割り当てたものがアセンブリ言語です。mov (move: 転送) や add (足し算) などがあります。

手元でコンパイルした足し算の一部を、objdump -d でマシン語 (左) とアセンブリ (右) に並べました。

8b 55 fc    mov    edx, DWORD PTR [rbp-0x4]   ; メモリの値 a を edx へ
8b 45 f8    mov    eax, DWORD PTR [rbp-0x8]   ; メモリの値 b を eax へ
01 d0       add    eax, edx                   ; eax = eax + edx

[rbp-0x4] は「rbp レジスタが指すアドレスから 4 バイト手前のメモリ」、DWORD PTR は「そこを 4 バイト分のデータとして扱う」という意味です。gcc -S の出力では、同じものを -4[rbp] と書きます。

アセンブリ言語の 1 行が、マシン語の 1 命令に対応しているのがわかります。この基本的に 1 対 1 の関係が、アセンブリ言語の特徴です。

  • アセンブリ言語をマシン語に変換するプログラムをアセンブラ、変換することをアセンブルと呼びます。
  • 逆に、マシン語をアセンブリ言語に戻すことを逆アセンブルと呼びます。上の出力は、objdump で逆アセンブルしたものです。

C などの高水準言語で書いたプログラムも、コンパイルされてマシン語になり、最後は CPU の中でレジスタを使って処理されます。

なお、x86 では add DWORD PTR [rbp-0x8], eax のように、メモリ上の値を直接計算の対象にする命令もあります。それでも計算の中心はレジスタで、多くの命令はレジスタを相手に書かれます。

学んだこと 4: マシン語の命令はおおむね 4 種類に分けられる

マシン語の命令はたくさんありますが、役割で分けると次の 4 つにおおむね収まります。

分類何をするかx86 の例
データ転送命令レジスタやメモリの間でデータを移すmov, push, pop
演算命令算術演算・論理演算・比較をするadd, sub, and, cmp
ジャンプ命令プログラムカウンタを書き換えて処理の流れを変えるjmp, jl, jle
コール / リターン命令関数を呼び出し、呼び出し元に戻るcall, ret

Intel のマニュアルでは、汎用の命令がもっと細かく、データ転送・算術・論理・シフト・制御転送・文字列操作などに分けられています。ジャンプ命令とコール / リターン命令は、どちらも「制御転送」にまとめられています。ほかに、OS を呼び出す syscall や CPU を止める hlt のように、上の 4 つにきれいには当てはまらない命令もあります。それでも、普段書くプログラムの処理は、ほぼこの 4 種類の組み合わせで表されます。

学んだこと 5: レジスタには役割ごとの種類がある

学んだこと 3 の例の eax や edx が、レジスタの名前です。概念上のレジスタの種類と、x86 で歴史的に対応するレジスタをまとめました。

種類役割x86 で対応するレジスタ
アキュムレータ計算に使う値や、計算の結果を置くeax / rax
フラグレジスタ直前の計算の結果がどうだったか (0 か、負か、など) を記録するeflags / rflags
プログラムカウンタ次に実行する命令が置かれたアドレスを持つeip / rip
ベースレジスタデータを置いたメモリ領域の起点となるアドレスを持つebx / rbx
インデックスレジスタ起点からどれだけ離れた位置かを持つesi, edi / rsi, rdi
汎用レジスタ用途を決めずに、自由にデータを置くecx, edx / rcx, rdx, r8〜r15
命令レジスタ実行中の命令そのものを置く(プログラムからは見えない)
スタックポインタスタック領域のいちばん上のデータの位置を持つesp / rsp

e で始まるのが 32 ビットの名前、r で始まるのが 64 ビットの名前です (r8〜r15 の 32 ビット版は r8d〜r15d)。

表を作りながら調べてわかったことが 4 つあります。

  • x86 の eax や ebx という名前は、昔の CPU で決まっていた役割 (accumulator、base など) の名残です。今の x86-64 では、rax から r15 までの 16 本がすべて汎用レジスタで、多くの命令でどれでも自由に使えます。ただし、一部の命令 (割り算など) や関数の呼び出し規約では、特定のレジスタに決まった役割があります。rsp はスタックポインタ専用に使います。
  • 命令レジスタは概念上のもので、今の CPU ではデコーダ (命令を解読する回路) などの内部構造に置き換わっています。プログラムから読み書きすることはできません。
  • スタックポインタが指すのは、スタック領域の端ではなく「今いちばん上にあるデータ」の位置です。x86 のスタックはアドレスの小さい方へ伸びるので、データを積むとスタックポインタの値は小さくなります。
  • フラグレジスタは 64 ビットモードでは rflags という名前ですが、gdb では eflags と表示されます。

CPU の種類が違えば、レジスタの数や種類、1 つのレジスタに入るデータのサイズも違います。たとえば x86-64 の汎用レジスタは 16 本 (各 64 ビット) ですが、ARM64 では 31 本あります。

学んだこと 6: 処理の流れはプログラムカウンタで決まる

CPU の制御装置は、プログラムカウンタが指すアドレスから命令を読み出して実行します。命令を 1 つ実行すると、プログラムカウンタは自動的に次の命令の位置へ進みます。

プログラムカウンタは「1 ずつ増える」と説明されることがあります。ただ、x86 では命令の長さが 1〜15 バイトと一定ではないので、実際には実行した命令の長さ分だけ進みます。学んだこと 3 の add eax, edx は 2 バイト (01 d0) です。gdb で実行してみると、rip は 0x...129 から 0x...12b へ 2 進みました。

また、今の CPU は内部で複数の命令を並行して処理しています。それでも結果は 1 命令ずつ順に実行したのと同じになるよう保証されているので、プログラムを読むときは順番に実行されると考えて問題ありません。

プログラムの流れは、次の 3 つの組み合わせで作られます。

流れCPU の中で起きていること
順次進行プログラムカウンタが、次の命令の位置へ順に進んでいく
条件分岐条件に応じて、ジャンプ命令がプログラムカウンタを別のアドレスに書き換える
繰り返しジャンプ命令が、プログラムカウンタを前の位置に戻す

条件分岐で使うジャンプ命令は、直前の計算の結果を見て、ジャンプするかどうかを決めます。その結果を覚えているのがフラグレジスタです。フラグレジスタには、直前の計算の結果が負だったか (符号フラグ)、ゼロだったか (ゼロフラグ) などが 1 ビットずつ記録されます。桁あふれ (キャリーやオーバーフロー) を記録するフラグもあります。

2 つの値の比較は、CPU の中では引き算として行われます。x86 の cmp 命令は、引き算をして結果を捨て、フラグだけを残します。そのあとの条件付きジャンプ命令がフラグを見て、分岐するかどうかを決めます。

C の for 文をコンパイルすると、繰り返しもこの仕組みで作られていました。

for (int i = 0; i < n; i++) {
    s += i;
}
    jmp   .L2                         ; まず条件判定へ
.L3:                                  ; ループの中身
    mov   eax, DWORD PTR -4[rbp]
    add   DWORD PTR -8[rbp], eax      ; s += i
    add   DWORD PTR -4[rbp], 1        ; i++
.L2:
    mov   eax, DWORD PTR -4[rbp]
    cmp   eax, DWORD PTR -20[rbp]     ; i と n を比べる (引き算してフラグを残す)
    jl    .L3                         ; i < n なら .L3 へ戻る

cmp で i - n を計算し、jl がフラグを見て i < n なら、プログラムカウンタを .L3 に戻します。jl は、符号フラグとオーバーフローフラグを組み合わせて、符号付きの数として「より小さい」かどうかを判定します。桁あふれがなければ、引き算の結果が負かどうかを見ているのと同じです。

学んだこと 7: 関数呼び出しはコール命令とリターン命令で行う

関数の呼び出しも、プログラムカウンタの値を関数の先頭アドレスに変えることで実現されます。ただし、関数の処理が終わったら呼び出し元に戻らなければなりません。そのため、ただのジャンプ命令ではなく、コール命令とリターン命令を使います。

  1. コール命令 (call) は、呼び出しの次に実行すべき命令のアドレス (戻り先) をメインメモリ上のスタック領域に保存してから、関数の先頭へジャンプします。
  2. 関数の出口でリターン命令 (ret) を実行すると、スタックに保存しておいたアドレスがプログラムカウンタに戻され、呼び出し元の続きから処理が再開します。

手元で、空の関数 f を呼ぶプログラムを gdb で確かめました。break *f で関数の 1 命令目に止めると、rsp が指すメモリには呼び出し元の次の命令のアドレスが入っていました。ret で戻ったあとは、rsp の値が 8 増えていました。戻り先のアドレス (8 バイト) がスタックから取り出されたためです。

学んだこと 8: ベースレジスタとインデックスレジスタで配列を扱う

ベースレジスタとインデックスレジスタを組み合わせると、メモリ上に並んだデータを配列として扱えます。CPU は 2 つのレジスタの値を足した結果を、実際に読み書きするアドレスとして使います。

  • ベースレジスタ: 配列の先頭アドレス
  • インデックスレジスタ: 先頭から何番目か (高水準言語でいう添え字)

C で配列の要素を返す関数を書き、最適化を有効にして (-O2) コンパイルすると、この形がそのまま 1 命令になりました。

int get(int *a, long i) {
    return a[i];
}
    mov   eax, DWORD PTR [rdi+rsi*4]   ; 先頭 rdi + 添え字 rsi × 4 バイト
    ret

rdi が配列の先頭 (ベース)、rsi が添え字 (インデックス) です。x86 では、インデックスに要素のサイズ (ここでは int の 4 バイト) を掛けてから足す書き方ができます。

ここで rdi と rsi が使われているのは、x86-64 の Linux では関数の 1 つ目と 2 つ目の引数をこのレジスタで渡す決まり (呼び出し規約) があるためです。今の x86-64 では、ベースやインデックスにはどの汎用レジスタでも使えます。

まとめ

  • CPU は、レジスタ・制御装置・演算装置・クロックで構成され、マシン語のプログラムを読み取って実行する
  • メインメモリには命令とデータが入っていて、CPU は 1 バイトごとのアドレスを指定して読み書きする
  • アセンブリ言語はマシン語に名前を付けたもので、基本的に 1 対 1 で対応する
  • マシン語の命令は、データ転送・演算・ジャンプ・コール / リターンの 4 種類におおむね分けられる
  • レジスタにはアキュムレータ・フラグレジスタ・プログラムカウンタ・スタックポインタなどの役割がある。今の x86-64 では、多くが汎用レジスタとして使える
  • 順次進行・条件分岐・繰り返しは、プログラムカウンタの値の変え方で実現されている
  • 関数呼び出しは、戻り先をスタックに保存するコール命令と、それを取り出すリターン命令で行う
  • 配列は、ベースレジスタ (先頭) とインデックスレジスタ (添え字) の組み合わせで扱える

高水準言語で書いていると意識しない部分ですが、if 文も for 文も関数呼び出しも、CPU から見ればプログラムカウンタをどう変えるかという同じ問題でした。この見方を持っておくと、デバッガでアセンブリを見るときにも読みやすくなりそうです。

参考文献