CPUにはデータや実行している命令のアドレスを保存するためのレジスタという少量のメモリ領域がある。コンピュータで演算を行う際にはレジスタ上に保存されているデータやアドレスを元にして実行を行うようになっており、コンピュータ上ではもっともアクセス速度が速いデータ保存領域と言える。ここではx86_64に対してgdbを動かしながらレジスタについてまとめていこうと思う。

レジスタの種類

一言でレジスタと言ってもいろいろな用途があり、大きく下記のように一般的なCPUでは分類することができる。

  • 汎用レジスタ: データの保存用。x86_64では64bit値を保持できる。
  • プログラムカウンタ: 現在実行している命令のアドレスを保持する。この値を操作するのに特殊な命令が必要になる。
  • フラグレジスタ: 演算結果のうち、データではない結果を保持する。例えば、足し算の結果が64bit値に収まらない場合にそれを示す値が入れられる。
  • 浮動小数レジスタ: 浮動小数演算のデータを保持する。

x86_64でgdbのinfo_registersを入力すると多くのレジスタが表示される。

レジスタ名 内容
rax 汎用レジスタ
rbx 汎用レジスタ
rcx 汎用レジスタ
rdx 汎用レジスタ
rsi 汎用レジスタ
rdi 汎用レジスタ
rbp 汎用レジスタ
rsp 汎用レジスタ兼スタックポインタ
r8 汎用レジスタ
r9 汎用レジスタ
r10 汎用レジスタ
r11 汎用レジスタ
r12 汎用レジスタ
r13 汎用レジスタ
r14 汎用レジスタ
r15 汎用レジスタ
rip プログラムカウンタ
eflags フラグレジスタ
cs セグメントレジスタ
ss セグメントレジスタ
ds セグメントレジスタ
es セグメントレジスタ
fs セグメントレジスタ
gs セグメントレジスタ

このようにレジスタにも沢山あって、自分たちが気軽に書いているプログラムは目に見えないところでそれぞれのレジスタを利用してデータ操作をしながら命令を実行している。一つ一つのレジスタの容量は小さく、汎用レジスタは16個ほどあるが、それぞれ8byteの容量となるため全部で128byteという小さいサイズのデータしか保存することができない。当然、現代で使われるデータ(例えば画像とか)が128byteに収まるなんてあり得ないため、大きなデータを保存するためにコンピュータにはメモリという主記憶装置が存在し、また、SSDやHDDといった大容量データ保存用の場所が別で用意されている。

レジスタは容量がこのように小さい分、CPU内部に存在するためにコンピュータの中でも最も高速にアクセスできる場所にある。次にキャッシュメモリやメインメモリがあるが、レジスタと比べるとアクセス速度は遅くなる。ので、レジスタはコンピュータにとって命令実行の際に最も高速に動かせる場所であり、メモリへのアドレスを保存していたりとプログラム実行のための中核となる機能と言える。

アセンブリ言語

簡単なプログラムを作成し、x86_64でどのようにプログラムが動くのかをデバッグして挙動を確認する。

	.globl	main
main:
	mov $1, %rax
	mov $2, %r8
	add %r8, %rax
	ret

このプログラムはとても単純だ。命令の1行目だと1という即値をraxレジスタに保存しているだけだ(即値を表現する時は$を数値の前に付ける)。これはアセンブリ言語で、このプログラムをgccでコンパイルしてgdbでデバッグしながらどのようにプログラムが実行していくのかを見ていくのだが、まずはこのプログラムの記法について触れる。

アセンブリ言語ではCPUによって記法が変わる。基本的にはAT&T記法とIntel記法で分かれるのだが、一般的なCPUで採用されているIntel記法とは違って、x86_64のLinuxではAT&T記法を採用している。

# Intel記法
instruction operand0, operand1

# AT&T記法
instruction operand1, operand0

これは結構混乱してしまうところなのだが、二つの記法でoperandの順序が逆転している。instructionはmovやaddといった命令を指すもので、これはニーモニックと一般的に呼ばれている。その次に続くのはオペランドで、たとえばAレジスタの値をBレジスタに書き換える、といった命令があった場合のAレジスタやBレジスタがオペランドとして表現される。

さて、上記のプログラムに立ち戻ると、このプログラムは「即値1を保存したraxレジスタに対して、即値2をr8レジスタに保存したものを加算する」という命令になるので、1+2=3という単純な命令を表現している。mainというのはラベルと呼ばれ、main関数内の実行内容という捉え方で問題ない。retはreturnで、スタックで呼び出されていく命令に対して呼び出し元のアドレスに戻ることを意味する。

gdbでデバッグしてみる

プログラムの動きを見ていくためにgdbでデバッグしていこう。上に書いたプログラムをgccでコンパイルしたあとでgdbを実行してプログラムの動きを見ていく。

$ gcc sample.s
$ gdb a.out

アセンブリをコンパイルするとa.outのファイルが生成されるので、gdbでa.outファイルを指定して実行する。すると対話的にgdbが起動してコマンドを一つ一つ打っていくことでプログラムの動きを一つ一つ追っていくことができる。いわゆるデバッグするということだ。

gdbの基本コマンドをここにまとめる。

  • start: プログラムを開始してmain関数の先頭で処理を止める
  • info registers: 各レジスタの値を表示
  • disassemble: 現在実行中の関数に含まれる命令を表示
  • stepi: 一命令実行する
  • print: コマンドに与えた式の値を表示する
  • quit: gdb を終了

gdbは対話的なデバッガーなのでコマンドを打ちながら命令を実行していき、情報を見ていく。info registersを叩くと各レジスタに保存されているデータやアドレスが表示されるため、命令を実行していく中でこの値が変わることを確認する。まずはstartでa.outのデバッグを開始する。

(gdb) start
Temporary breakpoint 1 at 0x5fa
Starting program: /home/a.out

Temporary breakpoint 1, 0x00005555555545fa in main ()
(gdb) info registers
rax            0x5555555545fa   93824992232954
rbx            0x0      0
rcx            0x555555554610   93824992232976
rdx            0x7fffffffe798   140737488349080
rsi            0x7fffffffe788   140737488349064
rdi            0x1      1
rbp            0x555555554610   0x555555554610 <__libc_csu_init>
rsp            0x7fffffffe6a8   0x7fffffffe6a8
r8             0x7ffff7dd0d80   140737351847296
r9             0x7ffff7dd0d80   140737351847296
r10            0x0      0
r11            0x0      0
r12            0x5555555544f0   93824992232688
r13            0x7fffffffe780   140737488349056
r14            0x0      0
r15            0x0      0
rip            0x5555555545fa   0x5555555545fa <main>
eflags         0x246    [ PF ZF IF ]
cs             0x33     51
ss             0x2b     43
ds             0x0      0
es             0x0      0
fs             0x0      0
gs             0x0      0

このようにinfo registersで各レジスタの値を確認することができる。次に読み込んだプログラムをstepiで一つ一つ命令を進めていく。

(gdb) stepi
0x0000555555554601 in main ()
(gdb) stepi
0x0000555555554608 in main ()
(gdb) print $rax
$1 = 1
(gdb) print $r8
$2 = 2

printでレジスタの値を確認すると、raxには即値1が、r8には即値2が保存されており、mov命令が実行されたことがわかる。ここでdisassembleコマンドを打ってみる。

(gdb) disassemble
Dump of assembler code for function main:
   0x00005555555545fa <+0>:     mov    $0x1,%rax
   0x0000555555554601 <+7>:     mov    $0x2,%r8
=> 0x0000555555554608 <+14>:    add    %r8,%rax
   0x000055555555460b <+17>:    retq
   0x000055555555460c <+18>:    nopl   0x0(%rax)
End of assembler dump.

disassembleコマンドは現在デバッガによって停止されている命令の位置を矢印で示している。つまり、二つのmovが実行されたところで止まっている。ここで命令を進めてadd命令を行う。

(gdb) stepi
0x000055555555460b in main ()
(gdb) print $rax
$3 = 3
(gdb) print $r8
$4 = 2

printで確認してみると、raxの値が1+2=3となり、正しく即値が加算されていることがわかる。

このようにニーモニックの命令を一つ一つ実行していき、レジスタの値を操作することで機械語というものは構成される。世の中には多くのプログラミング言語が存在するが、コンパイラによってコンパイルされるとCPUが理解できるこのような機械語に変換され(これはアセンブリだけど)、レジスタにある値やアドレスを操作しながら演算していくのが基本的なコンピュータの動きとなる。