2 minutes
x86_64: レジスタについて
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が理解できるこのような機械語に変換され(これはアセンブリだけど)、レジスタにある値やアドレスを操作しながら演算していくのが基本的なコンピュータの動きとなる。