学校で習わないが(習う学校もある)、現実に必要になるプログラミング技術に、低レベルプログラミングなどと呼ばれるものがある
厳密な定義は聞いたことがないし、おそらく存在しないとは思うが、大体のみんなの共通認識として、 「高級プログラミング言語を使わないプログラムを書き、OSで抽象化されないデバイスの機能を使う」といったような認識があると思う。
筆者の経験から言わせてもらうならば、低レベルプログラミングに関する知識は、プログラミングにおいてあらゆる場面で、常に、少しずつ役立てられる知識だと言えると思う。
普段はRubyやPHPなどを書いてる人であったとしても、メモリが足りなくなった場合や、デバッガを使っている場合、性能が足りなくなった場合など、 厳しい環境におかれた時に低レベルプログラミングに関する知識が必ず役に立つ場面が来ると信じている。
また、役に立つかどうかは置いておいても、「プログラムはどのように動いているのか」を知ることは、知的好奇心を満たし、 その知識を駆使すれば、コンピュータの挙動を手中に収めた全能感を楽しむことができることだろう。
多くのプログラム初心者が、 「#include <stdio.h> ってなになのか?」「何故 print "Hello World" というプログラムを実行すると、画面に"Hello World"と出るのか?」 と、いった点について、多かれ少なかれ疑問を持ちながら、モヤモヤとした気持ちでプログラムを書いたことがあるはずだ。
しかし、残念ながら、これらに関する知識は体系立てられているとは言えず、 また、低レベルプログラミングがターゲットとする領域では、 アドホックな方法で、場当たり的に実装されたものがそのままデファクトスタンダードになってしまう例が数多くあり、 なんらかの一般的な理論を勉強するよりは、実装ごとの事情を勉強する必要があるのが現実である。
それでも、現実にある多くの事情、デバイス、実装などを眺めていれば、そこはかとなく存在する一般的な概念を読みとることができるはずで、 それらの多くを実際に経験した筆者は、おそらく多くの人より、なんらかの普遍的な知識を獲得しているだろうという自信はある。
この文章は、筆者が書ける限りの、色々なデバイスの色々な事情について、書けるかぎり書いていこうというものである。 普遍的な知識を表現することは難しいが、色々なレイヤの事情を通して、なんらかの共通する概念を習得する人がひとりでも増えれば幸いである。
2018/10 現在、書きかけの状態です。とりあえず今は読みやすさよりも情報量増やすほうに注力しているので、読みやすさはあとまわしになっています。 ひとくぎり付いたら、推敲したり、体裁整えたり、図を入れたりしようと思います。
更新状況は https://github.com/tanakamura/pllp/tree/gh-pages から確認ください。 また @tanakmura で何か追加で言い訳などを書いている場合もあります。
気が向いたらリアルタイム更新を 配信 しています。
なんらかの方法で Linux、GCC、binutils の環境を用意することを強くお勧めする。実機でもVMでもWSLでも構わない。(もしかすると実機でしかできないこともやるかもしれないが)
Windowsにも、優れた開発環境はあるが、少し道を踏みはずした低レベルプログラミングをする場合、ぱぱっと色々なコマンドを組みあわせて変なことができる Linux環境のほうがかなり使いやすい。道をはずしたプログラミングをするなら、Linux環境に慣れておくにこしたことはないと思う。
また、場合によってはARMなどの、PC以外の環境にも触れたいと思っているが、Linuxならば、その場合にもインターフェースが共通して使えるので、心強い。
巨大なライブラリなどは必要ない。C言語で書いた Hello World がコンパイルできる程度の環境があれば十分である。ついでにgdbも使えるようになっているとよい。
PC以外に、Raspberry Pi、Zybo などを使っていくかもしれない。手元にある人は使ってみてほしい。
アセンブリ言語はご存知だろうか?ご存知のかたは、どの程度ご存知であろうか。 もし、あなたが、共有ライブラリロード時のリロケーションの処理が何なのか説明できる程度に、色々なことを知っているならば、もう、この章は飛ばしてもらってかまわない。(というかそもそもこの文章読む意味あるか?)
低レベルプログラミングにおいて、機械語は第一級言語だと言っていい。機械語に関する理解なく、低レベルプログラミングにチャレンジするのは、筆や鉛筆等を持たないで絵を描くのと似たようなものだ。
この章の目的は
の二点だ。
できる限り、わかりやすい説明を試みるが、かなり重いテーマなので、筆者の説明不足などにより、全てを正しく理解するのは難しいかもしれない。 その場合でも、次からの章を断片的に理解することはできるので、よくわからなければ、次の章へ進んでもらってもかまわない。 この文章全体を読み終えるくらいに、第一章が理解できるぐらいのペースでもよいと思う。
また、C言語への理解がまだ浅い人は、別のC言語の書籍、解説ページを用意して、そちらと交互に読んでいくものよいかもしれない。 ポインタなどがわからないという人も、その背後にある機械語を理解すれば、いくらか理解しやすくなることもある。C言語の文法などは基本的に説明しないので、 よくわからなければ、C言語の本に戻っていただいてもかまわない。多分、両方をちょっとずつ理解していくのがいいと思う。
好きなエディタを開いて、add.s というテキストファイルを作り、中に以下のように書く
.globl main main: add $1, %rax ret
続いて、gcc を使って、これを実行ファイルに変換する。
$ gcc add.s $ ls add.s a.out
間違いがなければ、同じディレクトリに、a.out という実行ファイルができているはずだ。
次に、gdb に a.out を指定して起動する。(デバッガは、CPUやメモリの状態を調べるのに、非常に有用なツールである。必要な使いかたは都度説明するが、可能ならば色々な使いかたを知っておくことをおすすめする)
$ gdb a.out GNU gdb (Ubuntu 7.11.1-0ubuntu1~16.5) 7.11.1 Copyright (C) 2016 Free Software Foundation, Inc. License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html> This is free software: you are free to change and redistribute it. There is NO WARRANTY, to the extent permitted by law. Type "show copying" and "show warranty" for details. This GDB was configured as "x86_64-linux-gnu". Type "show configuration" for configuration details. For bug reporting instructions, please see: <http://www.gnu.org/software/gdb/bugs/>. Find the GDB manual and other documentation resources online at: <http://www.gnu.org/software/gdb/documentation/>. For help, type "help". Type "apropos word" to search for commands related to "word"... Reading symbols from ./a.out...done. (gdb)
gdb のプロンプトが出るはずだ。このプロンプトに対して、
と、打ちこんでみよう。
(gdb) start Temporary breakpoint 1 at 0x4004d6 Starting program: /mnt/d/wsl/src/pllp/docs/1/a.out Temporary breakpoint 1, 0x00000000004004d6 in main () (gdb) info registers rax 0x4004d6 4195542 rbx 0x0 0 rcx 0x0 0 rdx 0x7ffffffee518 140737488282904 rsi 0x7ffffffee508 140737488282888 rdi 0x1 1 rbp 0x4004e0 0x4004e0 <__libc_csu_init> rsp 0x7ffffffee428 0x7ffffffee428 r8 0x400550 4195664 r9 0x7fffff410ab0 140737475840688 r10 0x846 2118 r11 0x7fffff050740 140737471907648 r12 0x4003e0 4195296 r13 0x7ffffffee500 140737488282880 r14 0x0 0 r15 0x0 0 rip 0x4004d6 0x4004d6 <main> eflags 0x246 [ PF ZF IF ] cs 0x33 51 ss 0x2b 43 ds 0x0 0 es 0x0 0 fs 0x0 0 gs 0x0 0
このような出力がされると思う。(実際の値は、OSやライブラリによって異なるため、同じ値ではなくてもよいです)
現代の多くのCPUは、レジスタ と呼ばれる、 ごく少量のメモリ ———メモリってなんだ?という疑問を忘れないで!それについてはそのうち説明しよう!とりあえずここはデータを保存する領域と思ってもらいたい——— を、搭載している。
gdb の info registers というコマンドは、そのレジスタを表示するコマンドである。
info registers の出力を見て、以下のような情報を読み取ってほしい。
値の10進表現 rax 0x4004d6 4195542 レジスタ名 値の16進表現
レジスタには、いくつか種類があり、レジスタによって使える場面に制限がある。 現代の一般的なCPUのレジスタは、以下のように分類される
gdb の info registers 表示されているレジスタの分類は、以下のとおりである。
と、なっている。え…スタックポインタとかセグメントレジスタって何…?
x86_64 は、PCが誕生する(より以前?)から存在したCPUの仕様をいくらかひきずっており、現代のCPUでは見られない *生きた化石* のようなレジスタが見られるのが特徴である。このへんの話は、話がずれるので、そのうち書くことにする。とりあえずセグメントレジスタは忘れてもらって構わない。一時期は消滅したスタックポインタが、aarch64 で復活したのは興味深い点ではある。
まず最初は、汎用レジスタだ。汎用レジスタは、多くの命令の入力、出力用の領域として使うことができ、プログラマから見たとき、一番目にすることが多いはずだ。
最初に書いたプログラムをもう一度見てもらいたい
.globl main main: add $1, %rax ret
まず、.globl 、 次に main: と、あるが、これはあとのリンカのところで説明しよう。
次に来るのが、 add $1, %rax という行だ。
これは、「アセンブリ言語でのadd命令」を書いた行で、意味は、
add $1, %rax rax レジスタに対して、 1を 足す
と、なる。
一般的なCPUでは、多くの命令は、以下のような形式をしている
instruction operand0, operand1 - instruction : 命令の名前、何をするかを指示する - operand0 : 0番目のオペランド、どのレジスタに対して命令を実行するかを指示する - operand1 : 1番目のオペランド、命令によっては、複数のレジスタを入出力にとることがあり、その場合は、コンマで区切って 1番目、2番目のオペランド、というように指示していく
だがしかし!悲しいかな、x86_64 の Linux では、アセンブリの表記方法が一般的ではなく、
instruction operand1, operand0
というように、operand1 と operand0 の順序が入れかわってしまう(AT&T記法)。これは、Intel のマニュアルの表記(Intel記法)とは異なっており、完全な初心者殺しである。
標準にあわせるオプションもあるが、gdbなど各種ツールの出力がAT&T記法なので慣れるしかない。ここ以降、出現する命令の表記では、operand1, operand0 の順番になっており、Intel のマニュアルとは順序が異なっているという点を頭に入れて読んでほしい。
さて、それでは、add 命令の挙動を見てみよう。さきほどの gdb のコンソールに戻ろう(gdbのコンソールをなくしてしまった人は、もう一度gdbを起動して、start を実行しよう)
まず、gdbのコンソール に disassemble と入力する
(gdb) disassemble Dump of assembler code for function main: => 0x00000000004004d6 <+0>: add $0x1,%rax 0x00000000004004da <+4>: retq 0x00000000004004db <+5>: nopl 0x0(%rax,%rax,1) End of assembler dump.
このような文字が表示されるはずだ。"disassemble" コマンドは、現在停止中の関数に含まれる命令を表示するコマンドで、今は、プログラムの開始地点、main関数を実行しようというところで停止しているので、main 関数に含まれる命令列を表示している。
いや、"main関数"について説明が足りてない、これは、詳しくはあとでリンクのところで説明するが、一応簡単に説明しておこう。
最初に書いたプログラムを見てほしい。
main: add $1, %rax ret
"main:" という行を最初に書いたはずだ。C言語を書いたことがある人なら、main関数は見たことあるだろう。 この、"main:" という行は、ラベル と呼ばれる、C言語の関数にかなり近い物体を作るように指示する行で、 このようにラベルを書くことで、 デバッガから見たときに、ここに、C言語のmain関数のようなものが存在するように見えるのである。 (実際に、gdbは、 "Dump of assembler code for function main:"、"main関数のアセンブラコードのダンプ" と言っている点に注目しよう)
さて、最初にgdbを起動したときに、"start" コマンドを実行したことを思い出してほしい。
"start" コマンドは、「プログラムを起動し、main関数の先頭でプログラムを一旦停止する」というコマンドである。 そのため、"start"コマンドを実行した直後に、"disassemble" コマンドを実行すると、main関数に含まれる命令列がダンプされるのだ。
disassemble の出力を見てみよう。
(gdb) disassemble Dump of assembler code for function main: => 0x00000000004004d6 <+0>: add $0x1,%rax 0x00000000004004da <+4>: retq 0x00000000004004db <+5>: nopl 0x0(%rax,%rax,1) End of assembler dump.
=> 0x00000000004004d6 <+0>: add $0x1,%rax
の、ように、=> で行が矢印で指されているのがわかると思う。これが、現在プログラムが停止している位置を示している。
add.s では、main 関数の先頭に、add 命令を書いたので、これを実行しようという直前で停止しているわけだ。 では、ここで指されている命令を実行してみよう。
gdb には、一命令だけ、命令を実行する、stepi (step instruction)というコマンドがあるこれを実行しよう。そして、一命令実行したら、もう一度 info register として、レジスタを表示しよう。
次のようになるはずだ。
(gdb) info register rax 0x4004d6 4195542 rbx 0x0 0 rcx 0x0 0 rdx 0x7ffffffee518 140737488282904 rsi 0x7ffffffee508 140737488282888 rdi 0x1 1 rbp 0x4004e0 0x4004e0 <__libc_csu_init> rsp 0x7ffffffee428 0x7ffffffee428 r8 0x400550 4195664 r9 0x7fffff410ab0 140737475840688 r10 0x846 2118 r11 0x7fffff050740 140737471907648 r12 0x4003e0 4195296 r13 0x7ffffffee500 140737488282880 r14 0x0 0 r15 0x0 0 rip 0x4004d6 0x4004d6 <main> eflags 0x246 [ PF ZF IF ] cs 0x33 51 ss 0x2b 43 ds 0x0 0 es 0x0 0 fs 0x0 0 gs 0x0 0 (gdb) stepi 0x00000000004004da in main () (gdb) info register rax 0x4004d7 4195543 rbx 0x0 0 rcx 0x0 0 rdx 0x7ffffffee518 140737488282904 rsi 0x7ffffffee508 140737488282888 rdi 0x1 1 rbp 0x4004e0 0x4004e0 <__libc_csu_init> rsp 0x7ffffffee428 0x7ffffffee428 r8 0x400550 4195664 r9 0x7fffff410ab0 140737475840688 r10 0x846 2118 r11 0x7fffff050740 140737471907648 r12 0x4003e0 4195296 r13 0x7ffffffee500 140737488282880 r14 0x0 0 r15 0x0 0 rip 0x4004da 0x4004da <main+4> eflags 0x206 [ PF IF ] cs 0x33 51 ss 0x2b 43 ds 0x0 0 es 0x0 0 fs 0x0 0 gs 0x0 0 (gdb)
rax レジスタの値に注目してほしい
(gdb) info register rax 0x4004d6 4195542 ...(略)... (gdb) stepi 0x00000000004004da in main () (gdb) info register rax 0x4004d7 4195543 ...(略)...
stepi を実行したあとに、rax の値が1増えているのが確認できる。これが、add 命令の効果である。add $1, %rax をという命令を実行すると、 rax レジスタに入っている命令が、1増えるのだ。
ここまで確認したら、一旦gdbを終了しよう。gdb を終了するのは、quit コマンドだ。
(gdb) quit A debugging session is active. Inferior 1 [process 1665] will be killed. Quit anyway? (y or n) y $
「プログラムが実行中だが終了してよいか?」と聞かれるが、今は重要なプログラムを実行しているわけではないので、y でいい。 ここで add 命令の次に書いた ret は何?と、疑問に思っている方もいるかもしれない。それについては、あとのOSインターフェースのところで解説する。
いくつか解説をはさんでしまったので、ここまでの流れをざっともう一度流しておこう
$ gdb a.out GNU gdb (Ubuntu 7.11.1-0ubuntu1~16.5) 7.11.1 Copyright (C) 2016 Free Software Foundation, Inc. License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html> This is free software: you are free to change and redistribute it. There is NO WARRANTY, to the extent permitted by law. Type "show copying" and "show warranty" for details. This GDB was configured as "x86_64-linux-gnu". Type "show configuration" for configuration details. For bug reporting instructions, please see: <http://www.gnu.org/software/gdb/bugs/>. Find the GDB manual and other documentation resources online at: <http://www.gnu.org/software/gdb/documentation/>. For help, type "help". Type "apropos word" to search for commands related to "word"... Reading symbols from a.out...(no debugging symbols found)...done. (gdb) start Temporary breakpoint 1 at 0x4004d6 Starting program: /mnt/d/wsl/src/pllp/docs/1/a.out Temporary breakpoint 1, 0x00000000004004d6 in main () (gdb) disassemble Dump of assembler code for function main: => 0x00000000004004d6 <+0>: add $0x1,%rax 0x00000000004004da <+4>: retq 0x00000000004004db <+5>: nopl 0x0(%rax,%rax,1) End of assembler dump. (gdb) info registers rax 0x4004d6 4195542 rbx 0x0 0 rcx 0x0 0 rdx 0x7ffffffee518 140737488282904 rsi 0x7ffffffee508 140737488282888 rdi 0x1 1 rbp 0x4004e0 0x4004e0 <__libc_csu_init> rsp 0x7ffffffee428 0x7ffffffee428 r8 0x400550 4195664 r9 0x7fffff410ab0 140737475840688 r10 0x846 2118 r11 0x7fffff050740 140737471907648 r12 0x4003e0 4195296 r13 0x7ffffffee500 140737488282880 r14 0x0 0 r15 0x0 0 rip 0x4004d6 0x4004d6 <main> eflags 0x246 [ PF ZF IF ] cs 0x33 51 ss 0x2b 43 ds 0x0 0 es 0x0 0 fs 0x0 0 gs 0x0 0 (gdb) stepi 0x00000000004004da in main () (gdb) info registers rax 0x4004d7 4195543 rbx 0x0 0 rcx 0x0 0 rdx 0x7ffffffee518 140737488282904 rsi 0x7ffffffee508 140737488282888 rdi 0x1 1 rbp 0x4004e0 0x4004e0 <__libc_csu_init> rsp 0x7ffffffee428 0x7ffffffee428 r8 0x400550 4195664 r9 0x7fffff410ab0 140737475840688 r10 0x846 2118 r11 0x7fffff050740 140737471907648 r12 0x4003e0 4195296 r13 0x7ffffffee500 140737488282880 r14 0x0 0 r15 0x0 0 rip 0x4004da 0x4004da <main+4> eflags 0x206 [ PF IF ] cs 0x33 51 ss 0x2b 43 ds 0x0 0 es 0x0 0 fs 0x0 0 gs 0x0 0 (gdb) quit A debugging session is active. Inferior 1 [process 1770] will be killed. Quit anyway? (y or n) y
もうひとつ、よく使う gdb のコマンドを紹介しておこう。print コマンドだ。
(gdb) start Temporary breakpoint 1 at 0x4004d6 Starting program: /mnt/d/wsl/src/pllp/docs/1/a.out Temporary breakpoint 1, 0x00000000004004d6 in main () (gdb) print $rax $1 = 4195542 (gdb) print /x $rax $2 = 0x4004d6 (gdb) print 45 * 4 $3 = 180 (gdb) print /x 255 $5 = 0xff (gdb) print $r8 $7 = 4195664 (gdb) print $r9 $8 = 140737475840688 (gdb) print $ds $9 = 0 (gdb) print $cs $10 = 51 (gdb) print $rax + 512 $11 = 4196054
print コマンドは、コマンドに与えた式の値を表示してくれる。"/x" を付けると、値を16進で表示だ。 print $rax + 512 のように、頭に'$' を付けて、レジスタ名を書くと、式の中にレジスタの値を入れることができる。
さて、それでは gdb で命令を実行して、結果を表示する方法は解説したので、いくつか基本的な命令を説明しておこう。
レジスタに値を設定する、mov 命令。
.globl main main: mov $1, %rax ret
(gdb) start Temporary breakpoint 1 at 0x4004d6 Starting program: /mnt/d/wsl/src/pllp/docs/1/a.out Temporary breakpoint 1, 0x00000000004004d6 in main () (gdb) disassemble Dump of assembler code for function main: => 0x00000000004004d6 <+0>: mov $0x1,%rax 0x00000000004004dd <+7>: retq 0x00000000004004de <+8>: xchg %ax,%ax End of assembler dump. (gdb) p $rax $1 = 4195542 (gdb) stepi 0x00000000004004dd in main () (gdb) disassemble Dump of assembler code for function main: 0x00000000004004d6 <+0>: mov $0x1,%rax => 0x00000000004004dd <+7>: retq 0x00000000004004de <+8>: xchg %ax,%ax End of assembler dump. (gdb) p $rax $2 = 1
rax の値が 1 になる。
p コマンドは、print の略で、gdb では、よく使うコマンドは、省略できるという機能がある。 printはよく使うので、p 一文字で実行できる。 同じように、info registers も、"i r" で実行可能だ。慣れてくると使ってみるのもよいかもしれない。
多くの演算は、レジスタ間で演算することが可能だ。
.globl main main: mov $1, %rax mov $2, %r8 add %r8, %rax ret
とすると、r8 レジスタの値を、raxレジスタに加算できる。
(gdb) start Temporary breakpoint 1 at 0x4004d6 Starting program: /mnt/d/wsl/src/pllp/docs/1/a.out Temporary breakpoint 1, 0x00000000004004d6 in main () (gdb) stepi 0x00000000004004dd in main () (gdb) stepi 0x00000000004004e4 in main () (gdb) p $rax $1 = 1 (gdb) p $r8 $2 = 2 (gdb) disassemble Dump of assembler code for function main: 0x00000000004004d6 <+0>: mov $0x1,%rax 0x00000000004004dd <+7>: mov $0x2,%r8 => 0x00000000004004e4 <+14>: add %r8,%rax 0x00000000004004e7 <+17>: retq 0x00000000004004e8 <+18>: nopl 0x0(%rax,%rax,1) End of assembler dump. (gdb) stepi 0x00000000004004e7 in main () (gdb) p $rax $3 = 3 (gdb) p $r8 $4 = 2 (gdb)
rax レジスタの値が、1 + 2 = 3 になっていることを確認しよう。
subで減算、imul で乗算、and,or,xor で bitwise and, or, xor だ。
.globl main main: mov $1, %rax mov $2, %r8 sub %r8, %rax mov $3, %rax mov $4, %r8 imul %r8, %rax mov $0xaa, %rax mov $0x0f, %r8 and %r8, %rax mov $0xaa, %rax mov $0x0f, %r8 or %r8, %rax mov $0xaa, %rax mov $0x0f, %r8 xor %r8, %rax ret
Temporary breakpoint 2, 0x00000000004004d6 in main () (gdb) stepi 3 0x00000000004004e7 in main () (gdb) p $rax $5 = -1 (gdb) stepi 3 0x00000000004004f9 in main () (gdb) p $rax $6 = 12 (gdb) stepi 3 0x000000000040050a in main () (gdb) p /x $rax $7 = 0xa (gdb) stepi 3 0x000000000040051b in main () (gdb) p /x $rax $8 = 0xaf (gdb) stepi 3 0x000000000040052c in main () (gdb) p /x $rax $11 = 0xa5 (gdb)
gdb の stepi コマンドは、引数に数字を渡すと、その回数だけ繰り返してくれる。stepi 3 の意味は、命令を3個実行だ。
x86_64 では、さきほど見たように、汎用レジスタが16個あった。汎用レジスタは一個8byteあるので、16個あれば、128byteのデータを保持できることになる。 128byte というのは、本当に小さなサイズだ。画像で言うと、8x8ピクセルの画像も保持できないし、今書いてるこの文章も扱えないほど、ほんとうに小さなサイズだ。 たった128byteのデータでできることなんてほとんど何もない。月へ行くことだって難しいだろう。
今のコンピュータのように、大きな写真や、動画を見たり編集したりするには、128byteよりも、もっともっと大きなデータを扱える必要がある。 現代の一般的なコンピュータは、この大きなデータを保持するために、「メインメモリ」、日本の情報の教科書的に言うと、「主記憶装置」と呼ばれるものが付いている。
メインメモリは、データを保存するためのデバイスで、保存したデータをアドレス と呼ばれる整数値で指定し、次のふたつの操作をすることができる。
(TODO:図を入れたい)
例えば、次のような操作が可能だ
各アドレスによって識別できるデータは、完全に独立していて、
のように、アドレス毎に、最後にストアしたデータが読めるようになっている。
また、ロードする操作では、データは変わらず、何度ロードしても最後にストアしたデータが読める
"メイン"メモリ ("主"記憶装置) は、"メイン" ("主")と呼ばれるだけあって、コンピュータを使うときに、レジスタの次によく使われる記憶用デバイスだ。 メインメモリはCPUの外側にあるのだが、よく使うデバイスなので、メインメモリを操作するための専用の命令が、CPUの中に用意されている。
その専用命令が、ロード命令とストア命令 だ。どのぐらいよく使うかというと、add 命令と同じか、それ以上の頻度で使われる命令だ。
(実際には、この説明は正確ではない。正しい解説はもう少しあとで書く)
ロード命令 は、メインメモリにアドレスで識別されるデータを要求し、得られたデータをレジスタに格納する命令で、 ストア命令 は、メインメモリにレジスタに含まれるデータを、指定したアドレスに保存するように、メインメモリに要求する命令だ。
x86_64 の AT&T 記法では、それぞれ次のように書く
見てわかるかもしれないが、x86_64 では、ロード命令も、ストア命令も両方とも、"mov命令" だ。ニーモニックとしては区別されず、オペランドの順序が変わっているだけである
これはややこしいかもしれないが、そうなってしまっているので仕方ない。 筆者はロード命令とストア命令は別物だと理解したほうが理解しやすいと思っているので、mov 命令を見たときは、
の、どれになるかをきちんと区別するのをお勧めしたい。区別の仕方を覚えよう
(実際は、もう少し話がややこしい。これはx86_64機械語のところで説明する)
ともかく、言いたかったこととしては、mov命令を使うと、メインメモリの中のアドレスで識別できる領域に対して、データを保存したり、保存したデータを取り出したりできるということだ。
それでは、早速、このmov命令の挙動を見てみよう。
.globl main main: mov $99, %rax mov %rax,0 # アドレス0で識別される領域に、raxの値(99)を保存する mov 0, %r8 # アドレス0で識別される領域から、値を取り出し、その値をr8に格納する ret
これをさきほどと同じように、gcc で a.out に変換し、それを gdb で見てみよう
$ gcc load.s $ gdb a.out GNU gdb (GDB) 8.2 Copyright (C) 2018 Free Software Foundation, Inc. License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html> This is free software: you are free to change and redistribute it. There is NO WARRANTY, to the extent permitted by law. Type "show copying" and "show warranty" for details. This GDB was configured as "x86_64-pc-linux-gnu". Type "show configuration" for configuration details. For bug reporting instructions, please see: <http://www.gnu.org/software/gdb/bugs/>. Find the GDB manual and other documentation resources online at: <http://www.gnu.org/software/gdb/documentation/>. For help, type "help". Type "apropos word" to search for commands related to "word"... Reading symbols from a.out...(no debugging symbols found)...done. (gdb) start Temporary breakpoint 1 at 0x401106 Starting program: /home/w0/src/pllp/docs/1/a.out Temporary breakpoint 1, 0x0000000000401106 in main () (gdb) stepi 0x000000000040110d in main () (gdb) stepi Program received signal SIGSEGV, Segmentation fault. 0x000000000040110d in main () (gdb) stepi Program terminated with signal SIGSEGV, Segmentation fault. The program no longer exists. (gdb)
おや…何かがおかしい…プログラムが終了してしまった。
これは、OSのメモリ保護機能が働いた結果だ。
メインメモリは、各コンピュータに一個か、数個ぐらいしかない。 その一個のメインメモリの上で、たくさんのプログラムが動いている。 (例えば、あなたは、この文章をブラウザで見ながら、エディタでプログラムを編集し、gccやgdbを起動している) 一個のメインメモリを、複数のプログラムで共有しているわけだ。
何の仕組みもなく、一個のメモリを複数のプログラムで共有すると、他のプログラムのデータが読み書きできるので、あまり良くない。 他人が動かしたプログラムのデータが読めるのはセキュリティ的によくないし、 一個のプログラムの小さなミスがシステム全体を止めてしまう可能性があるのは、色々問題があるし使いづらいだろう。
これを防ぐために、一般的なOSには、メモリを保護する機能が付いている。 このメモリ保護の仕組みは、低レベルプログラミングをする上で避けて通れない道なので、あとで詳しく書こう。 とりあえず、今はOSにはメモリを保護する仕組みがあって、メインメモリはいつでも自由に使えるわけではないという点だけ覚えておいてほしい。
では、どうするか。 メモリ保護機能のあるOSには、メモリ割り当て(memory allocation)のインターフェースが用意されており、 そのインターフェースを使うことで、メインメモリの一部を、自由に使えるメモリとしてOSから割り当ててもらうことができる。
メモリを割り当てる方法は、いくつかあるが、一番簡単な方法は、単にプログラムを起動するだけだ。
OSはプログラムを起動するように指示されると、起動するプログラムに必要なメモリを割り当ててから、プログラムの起動を行う。 いわゆる実行ファイル(Windowsのexe等や、さっきあなたが作ったa.out)には、この、起動時に必要なメモリサイズを示す情報が含まれており、OSはその情報を見ることで、 プログラムが使うメモリサイズを判断している。
これはリンカのところでもう少し詳しく説明するが、簡単に見ておこう。Linux では、 readelf というコマンドを、-l を付けて実行することで、 実行ファイルが使うメモリサイズを知ることができる。
$ gcc add.s $ readelf -l a.out Elf ファイルタイプは DYN (共有オブジェクトファイル) です Entry point 0x1020 There are 11 program headers, starting at offset 64 プログラムヘッダ: タイプ オフセット 仮想Addr 物理Addr ファイルサイズ メモリサイズ フラグ 整列 PHDR 0x0000000000000040 0x0000000000000040 0x0000000000000040 0x0000000000000268 0x0000000000000268 R 0x8 INTERP 0x00000000000002a8 0x00000000000002a8 0x00000000000002a8 0x000000000000001c 0x000000000000001c R 0x1 [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2] LOAD 0x0000000000000000 0x0000000000000000 0x0000000000000000 0x0000000000000528 0x0000000000000528 R 0x1000 LOAD 0x0000000000001000 0x0000000000001000 0x0000000000001000 0x00000000000001a5 0x00000000000001a5 R E 0x1000 LOAD 0x0000000000002000 0x0000000000002000 0x0000000000002000 0x00000000000000b8 0x00000000000000b8 R 0x1000 LOAD 0x0000000000002e28 0x0000000000003e28 0x0000000000003e28 0x0000000000000200 0x0000000000000208 RW 0x1000 DYNAMIC 0x0000000000002e38 0x0000000000003e38 0x0000000000003e38 0x00000000000001a0 0x00000000000001a0 RW 0x8 NOTE 0x00000000000002c4 0x00000000000002c4 0x00000000000002c4 0x0000000000000044 0x0000000000000044 R 0x4 GNU_EH_FRAME 0x0000000000002004 0x0000000000002004 0x0000000000002004 0x0000000000000024 0x0000000000000024 R 0x4 GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000 0x0000000000000000 0x0000000000000000 RWE 0x10 GNU_RELRO 0x0000000000002e28 0x0000000000003e28 0x0000000000003e28 0x00000000000001d8 0x00000000000001d8 R 0x1 セグメントマッピングへのセクション: セグメントセクション... 00 01 .interp 02 .interp .note.ABI-tag .note.gnu.build-id .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn 03 .init .text .fini 04 .rodata .eh_frame_hdr .eh_frame 05 .init_array .fini_array .dynamic .got .got.plt .data .bss 06 .dynamic 07 .note.ABI-tag .note.gnu.build-id 08 .eh_frame_hdr 09 10 .init_array .fini_array .dynamic .got
以下の部分に注目しよう
タイプ オフセット 仮想Addr 物理Addr ファイルサイズ メモリサイズ フラグ 整列 PHDR 0x0000000000000040 0x0000000000000040 0x0000000000000040 0x0000000000000268 0x0000000000000268 R 0x8 INTERP 0x00000000000002a8 0x00000000000002a8 0x00000000000002a8 0x000000000000001c 0x000000000000001c R 0x1 [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2] LOAD 0x0000000000000000 0x0000000000000000 0x0000000000000000 0x0000000000000528 0x0000000000000528 R 0x1000 LOAD 0x0000000000001000 0x0000000000001000 0x0000000000001000 0x00000000000001a5 0x00000000000001a5 R E 0x1000 LOAD 0x0000000000002000 0x0000000000002000 0x0000000000002000 0x00000000000000b8 0x00000000000000b8 R 0x1000 LOAD 0x0000000000002e28 0x0000000000003e28 0x0000000000003e28 0x0000000000000200 0x0000000000000208 RW 0x1000
PHDR, INTERP は、また説明が難しいので飛ばすとして、重要なのは、 "LOAD" と書かれている箇所だ。
"LOAD" は、プログラムを実行するときに使うメモリについての情報を格納した領域で、
LOAD 0x0000000000002e28 0x0000000000003e28 0x0000000000003e28 0x0000000000000200 0x0000000000000208 RW 0x1000
これは簡単にいうと「実行ファイルの 0x2e28 の位置にあるデータを、メモリ0x3e28 に 0x200 byte 分コピー、領域として、0x208 byte分確保しておく」という意味だ。
OSは、プログラム実行時に、この情報を見て、必要なメモリの確保を行ってからプログラムを起動してくれる。
ただ、これは、まだ少し説明が正しくなくて…これも今は説明するのが難しいので説明はあとにするが… gcc を使うときに、以下のように、"-static -no-pie" を付けてコンパイルすると この説明と正しく一致するようになる。
$ gcc -static -no-pie add.s ~/src/pllp/docs/1 $ readelf -l a.out Elf ファイルタイプは EXEC (実行可能ファイル) です Entry point 0x401a30 There are 8 program headers, starting at offset 64 プログラムヘッダ: タイプ オフセット 仮想Addr 物理Addr ファイルサイズ メモリサイズ フラグ 整列 LOAD 0x0000000000000000 0x0000000000400000 0x0000000000400000 0x0000000000000470 0x0000000000000470 R 0x1000 LOAD 0x0000000000001000 0x0000000000401000 0x0000000000401000 0x000000000007c681 0x000000000007c681 R E 0x1000 LOAD 0x000000000007e000 0x000000000047e000 0x000000000047e000 0x00000000000235f0 0x00000000000235f0 R 0x1000 LOAD 0x00000000000a20a0 0x00000000004a30a0 0x00000000004a30a0 0x00000000000051f0 0x0000000000006940 RW 0x1000 NOTE 0x0000000000000200 0x0000000000400200 0x0000000000400200 0x0000000000000044 0x0000000000000044 R 0x4 TLS 0x00000000000a20a0 0x00000000004a30a0 0x00000000004a30a0 0x0000000000000020 0x0000000000000060 R 0x8 GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000 0x0000000000000000 0x0000000000000000 RWE 0x10 GNU_RELRO 0x00000000000a20a0 0x00000000004a30a0 0x00000000004a30a0 0x0000000000002f60 0x0000000000002f60 R 0x1 セグメントマッピングへのセクション: セグメントセクション... 00 .note.ABI-tag .note.gnu.build-id .rela.plt 01 .init .plt .text __libc_freeres_fn .fini 02 .rodata .eh_frame .gcc_except_table 03 .tdata .init_array .fini_array .data.rel.ro .got .got.plt .data __libc_subfreeres __libc_IO_vtables __libc_atexit .bss __libc_freeres_ptrs 04 .note.ABI-tag .note.gnu.build-id 05 .tdata .tbss 06 07 .tdata .init_array .fini_array .data.rel.ro .got
説明をもう少し単純にするために、これに加えて、gcc のオプションに "-Tbss=0x800000" を付けよう。 こうすることで、OSに対して "自由に読み書きできるメインメモリをアドレス0x800000に割り当ててからプログラムを起動してください" と指示できる実行ファイルができあがる。
$ gcc -static -no-pie -Tbss=0x800000 add.s $ ./a.out $ readelf -l a.out Elf ファイルタイプは EXEC (実行可能ファイル) です Entry point 0x401a30 There are 10 program headers, starting at offset 64 プログラムヘッダ: タイプ オフセット 仮想Addr 物理Addr ファイルサイズ メモリサイズ フラグ 整列 LOAD 0x0000000000000000 0x0000000000400000 0x0000000000400000 0x00000000000004e0 0x00000000000004e0 R 0x1000 LOAD 0x0000000000001000 0x0000000000401000 0x0000000000401000 0x000000000007c681 0x000000000007c681 R E 0x1000 LOAD 0x000000000007e000 0x000000000047e000 0x000000000047e000 0x00000000000235f0 0x00000000000235f0 R 0x1000 LOAD 0x00000000000a20a0 0x00000000004a30a0 0x00000000004a30a0 0x00000000000051f0 0x00000000000051f0 RW 0x1000 LOAD 0x00000000000a8000 0x0000000000800000 0x0000000000800000 0x0000000000000000 0x0000000000001740 RW 0x1000 NOTE 0x0000000000000270 0x0000000000400270 0x0000000000400270 0x0000000000000020 0x0000000000000020 R 0x4 NOTE 0x0000000000000290 0x0000000000400290 0x0000000000400290 0x0000000000000024 0x0000000000000024 R 0x4 TLS 0x00000000000a20a0 0x00000000004a30a0 0x00000000004a30a0 0x0000000000000020 0x0000000000000060 R 0x8 GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000 0x0000000000000000 0x0000000000000000 RWE 0x10 GNU_RELRO 0x00000000000a20a0 0x00000000004a30a0 0x00000000004a30a0 0x0000000000002f60 0x0000000000002f60 R 0x1 セグメントマッピングへのセクション: セグメントセクション... 00 .note.ABI-tag .note.gnu.build-id .rela.plt 01 .init .plt .text __libc_freeres_fn .fini 02 .rodata .eh_frame .gcc_except_table 03 .tdata .init_array .fini_array .data.rel.ro .got .got.plt .data __libc_subfreeres __libc_IO_vtables __libc_atexit 04 .bss __libc_freeres_ptrs 05 .note.ABI-tag 06 .note.gnu.build-id 07 .tdata .tbss 08 09 .tdata .init_array .fini_array .data.rel.ro .got
以下の部分を見てみよう
LOAD 0x00000000000a8000 0x0000000000800000 0x0000000000800000 0x0000000000000000 0x0000000000001740 RW 0x1000
アドレス 0x800000 に 0x1740 byte 分のメモリを割り当てるように指示する情報が作られているのが確認できるはずだ。(0x1740byte という値はどこから来たんだ?これもあとで説明しよう!あとで説明することが多い!)
# gcc -static -no-pie -Tbss=0x800000 add.s のようにビルドすれば、0x800000 のアドレスに # メインメモリがOSから割り当てられた状態でプログラムが起動する実行ファイルを作ることができる .globl main main: mov $99, %rax mov %rax,0x800000 # アドレス0x800000で識別される領域に、raxの値(99)を保存する mov 0x800000, %r8 # アドレス0x800000で識別される領域から、値を取り出し、その値をr8に格納する ret
このプログラムをgdbで起動して見てみよう
(gdb) start The program being debugged has been started already. Start it from the beginning? (y or n) y Temporary breakpoint 2 at 0x401b55 Starting program: /home/w0/src/pllp/docs/1/a.out Temporary breakpoint 2, 0x0000000000401b55 in main () (gdb) stepi 0x0000000000401b5c in main () (gdb) x/g 0x800000 0x800000 <completed.6735>: 0 (gdb) p /x $rax $2 = 0x99 (gdb) disassemble Dump of assembler code for function main: 0x0000000000401b55 <+0>: mov $0x99,%rax => 0x0000000000401b5c <+7>: mov %rax,0x800000 0x0000000000401b64 <+15>: mov 0x800000,%r8 0x0000000000401b6c <+23>: retq 0x0000000000401b6d <+24>: nopl (%rax) End of assembler dump. (gdb) stepi 0x0000000000401b64 in main () (gdb) x/g 0x800000 0x800000 <completed.6735>: 0x0000000000000099 (gdb) disassemble Dump of assembler code for function main: 0x0000000000401b55 <+0>: mov $0x99,%rax 0x0000000000401b5c <+7>: mov %rax,0x800000 => 0x0000000000401b64 <+15>: mov 0x800000,%r8 0x0000000000401b6c <+23>: retq 0x0000000000401b6d <+24>: nopl (%rax) End of assembler dump. (gdb) stepi 0x0000000000401b6c in main () (gdb) p /x $r8 $3 = 0x99
また新しいgdbコマンドを使ってしまった。ここで使っているのは、"x" コマンドだ。
"x" コマンドは、メインメモリに格納されているデータを表示するコマンドで、"x アドレス" というようにして実行する。 "x" のあとに"/" と文字を書くと、表示方法を指定できる。/g だと、メモリに格納されたデータを、64bit 値として16進数で表示する。 上の例では、"x"コマンドを使って、0x800000 のアドレスに格納されているデータを表示している。(completed.6735 ってなんだ!?これはデバッガかリンカの説明ところで掘り下げよう。今は無視してもらって構わない)
%rax に、0x99 を格納した状態で、%rax の値を 0x800000にストアしたあとに、"x /g 0x800000" コマンドを実行すると、メモリに0x99 が格納されていること、 その次にアドレス 0x800000 から %r8 に0x800000からデータをロードすると、%r8 の値が0x99 になっていることを確認してほしい。
あ、アクセスサイズの説明をしていなかった。(TODO:あとで構成を考える)
上の例では、メモリアクセス命令のオペランドとして、rax と r8 を使っていたので、 メモリアクセスのサイズは64bit(8byteだった)
メインメモリのアクセス単位は1byteだが、このメインメモリに対して、 8byteのロードをすると、指定したアドレスから、連続する8個分のデータをロードしてくる。 同様に、8byteのストアをすると、8byteの連続した領域に、8個分のデータをストアする。
この、2byte以上のデータをロード、ストアする方式には、流派がふたつある。リトルエンディアンと、ビッグエンディアンだ。
x86_64 は、リトルエンディアンを採用している。 リトルエンディアン環境でメモリアクセスを行う環境では、8byte のストアを行うと、順に
と、なる。ロードは逆に、アドレス+0番の位置にあるデータを、レジスタの0bit目から7bit目まで…というようになる。
これは、値を表示したとき、人間が見る表示と、メモリに格納されているデータが逆になることを覚えておこう。
例えば、64bitの 0x0000000011223344 という値をメモリに格納すると、
0byte目 | 1byte目 | 2byte目 | 3byte目 | 4byte目 | 5byte目 | 6byte目 | 7byte目 |
0x44 | 0x33 | 0x22 | 0x11 | 0x00 | 0x00 | 0x00 | 0x00 |
こうなる
さきほどの例でも、"x" コマンドで 64bit 値 0x99 を格納したあとに、格納されたデータをバイト単位で8byte表示すると(フォーマット指定は8bxだ)、64bit 値が逆順に表示される。
(gdb) x/8bx 0x800000 0x800000 <completed.6735>: 0x99 0x00 0x00 0x00 0x00 0x00 0x00 0x00
一般的なCPUは、レジスタ幅でロードストアする以外に、8bit、16bit、32bit 単位でのロードストアができるようになっている。 もちろん、x86_64 もそれが可能だ。それぞれ、命令は以下のようになる。
bit幅 | 符号拡張ロード | ゼロ拡張ロード | ストア |
32bit | movsl アドレス,64bit_register | mov アドレス,32bit_register | mov 32bit_register, アドレス |
16bit | movsw アドレス,64bit register | movzw アドレス,64bit register | mov 16bit_register, アドレス |
8bit | movsb アドレス,64bit register | movzb アドレス,64bit register | mov 8bit_register, アドレス |
この表を読むには 2点追加で説明が必要だ。符号拡張と、レジスタ幅だ。
まず符号拡張。コンピュータで扱う整数には、符号付き整数と、符号無し整数がある。C 言語でいうと、signed と unsigned の違いだ。
符号付き整数の場合、ある整数ビット列をより大きなビットを持つ整数ビット列に変換するとき、 値の整合性を維持する場合、大きくした部分に、変換前の符号ビットをコピーして入れなければならない。
例えば、符号付き整数-128 は、
となる。また、符号付き整数 +127 は、
となる。これを見れば拡張されたビット部分に、拡張前の値の符号が入っていることが確認できるはずだ。
ロード命令では、このビット幅の拡張が発生するので、符号拡張ロード命令(signed用)と、ゼロ拡張ロード命令(unsigned用)が用意されている。
ストア命令は、ビット幅が小さくなるほうに変換するので、この問題は発生しない。そのため、符号拡張ストアは存在しない。
この符号拡張ロードの挙動はどのPUでも大体同じである。次に、レジスタ幅だ。
x86系列のCPUでは、レジスタの下位ビットに名前が付いており、 このレジスタの下位ビットを示す名前を使うことで、64bitレジスタから、32bit、16bit、8bit値を取り出すことができるようになっている。
x86_64 では、次のようになる
64bit レジスタ | 32bit レジスタ | 16bit レジスタ | 8bit レジスタ |
rax | eax | ax | al |
rbx | ebx | bx | bl |
rcx | ecx | cx | cl |
rdx | edx | dx | dl |
rsi | esi | si | sil |
rdi | edi | di | dil |
rbp | ebp | bp | bpl |
rsp | esp | sp | spl |
r8 | r8d | r8w | r8b |
r9 | r9d | r9w | r9b |
rX | rXd(r8-r15はdが付く) | rXw(r8-r15はwが付く) | rXb(r8-r15はbが付く) |
r15 | r15d | r15w | r15b |
命名規則がグダグダなのは、x86_64 が 16bit だった時代の名残を背負ってしまっているからだ。このへんの名残は本当にひどくて、さきほどの表
bit幅 | 符号拡張ロード | ゼロ拡張ロード | ストア |
32bit | movsx アドレス,64bit_register | mov アドレス,32bit_register | mov 32bit_register, アドレス |
16bit | movsx アドレス,64bit register | movzx アドレス,64bit register | mov 16bit_register, アドレス |
8bit | movsx アドレス,64bit register | movzx アドレス,64bit register | mov 8bit_register, アドレス |
を見ても、何かルールがありそうな、なさそうな形をしているし、あと、add などの算術演算は、レジスタの下位16bit や 下位32bit だけで演算できて、add のオペランドに、alレジスタなどを入れると8bit加算ができたりするのだけど、8bit、16bit算術演算は性能上の理由で使用が推奨されていない、が、64bit環境でも、32bit 算術演算が使える場合は使ったほうが性能上わずかに有利とか、そういう初見殺しルールがあったりする。(この話は nopの話 とあわせて結構好きなネタなので、気が向いたら書く)
その話はともかく、現代のx86_64では、名前のルールにひどい仕様が残っているものの、正しい命令を使えば実行時の悪影響はゼロにすることができて、コンパイラは効率良いコードを出すので安心してほしい。 基本的には、上の表に書いた命令を使っていればよい。
符号拡張の挙動を見ておこう
# gcc -static -no-pie -Tbss=0x800000 small-load-store.s でビルドすること .globl main main: mov $0xff, %rax mov %al, 0x800000 # 1byte の 0xff を 0x800000 にストア movsxb 0x800000, %r8 ret
(gdb) display /4i $pc 1: x/4i $pc <error: No registers.> (gdb) start Temporary breakpoint 1 at 0x401106 Starting program: /home/w0/src/pllp/docs/1/a.out Temporary breakpoint 1, 0x0000000000401106 in main () 1: x/4i $pc => 0x401106 <main>: mov $0xff,%rax 0x40110d <main+7>: mov %al,0x800000 0x401114 <main+14>: movsbq 0x800000,%r8 0x40111d <main+23>: retq (gdb) stepi 0x000000000040110d in main () 1: x/4i $pc => 0x40110d <main+7>: mov %al,0x800000 0x401114 <main+14>: movsbq 0x800000,%r8 0x40111d <main+23>: retq 0x40111e <main+24>: xchg %ax,%ax (gdb) stepi 0x0000000000401114 in main () 1: x/4i $pc => 0x401114 <main+14>: movsbq 0x800000,%r8 0x40111d <main+23>: retq 0x40111e <main+24>: xchg %ax,%ax 0x401120 <__libc_csu_init>: endbr64 (gdb) x/8bx 0x800000 0x800000 <completed.7286>: 0xff 0x00 0x00 0x00 0x00 0x00 0x00 0x00 (gdb) stepi 0x000000000040111d in main () 1: x/4i $pc => 0x40111d <main+23>: retq 0x40111e <main+24>: xchg %ax,%ax 0x401120 <__libc_csu_init>: endbr64 0x401124 <__libc_csu_init+4>: push %r15 (gdb) p $r8 $1 = -1 (gdb) p /x $r8 $2 = 0xffffffffffffffff
一個便利な gdb コマンドを説明しておこう。"display" だ。 "display" は、"x"や"print" とほぼ同じコマンドだが、 一度 "display" コマンドを実行すると、以降は、gdbのコマンドを実行するごとに、毎回式やメモリのダンプ結果を表示してくれる。 "display /4i $pc" は、"プログラムカウンタが指す位置にあるメモリの中身を命令として4個分表示しろ"と指示するコマンドだ。 一旦これを書いておけば、以降はgdbのコマンドを実行するごとにプログラムカウンタ周辺の命令をダンプしてくれるようになる。
rax レジスタに、1byteの-1 (0xff) を格納後、その下位8bit をストア、それを符号拡張してロードし、r8レジスタに入れると、r8 レジスタの値が64bit値の-1 (0xffff_ffff_ffff_ffff) になっていることを確認しよう。
そしてまたもう一個必要な説明が抜けていた。メモリオペランドのサイズだ。
movsx は、単なる mov と違い、ニーモニックとオペランドのペアから実行する機械語が一意に定まらない。
movsx $0x800000, %rax # 8bit 符号拡張ロード (?) movsx $0x800000, %rax # 16bit 符号拡張ロード (?) movsx $0x800000, %rax # 32bit 符号拡張ロード (?)
何かしらの方法で、ロードするデータのサイズを明示的に指示する必要がある。 これもまた初心者殺しで、AT&T記法と、Intel記法で指示する方法が違う。
AT&T 記法では、命令のニーモニックに、ロードするサイズを指示する接尾辞を付ける。 8bit なら "b"、16bit なら "w"、32bit なら "l" だ。
movsxb $0x800000, %rax # 8bit 符号拡張ロード movsxw $0x800000, %rax # 16bit 符号拡張ロード movsxl $0x800000, %rax # 32bit 符号拡張ロード
Intel 記法では、メモリオペランドのほうに、ロードするデータのサイズを書く。 (Intel記法にもさらに流派があって、nasm では PTR を書かないようだ。 筆者はもうよくわからないので詳細は近くの詳しい人に聞いてほしい)
movsx rax, BYTE PTR [0x800000] ; 8bit 符号拡張ロード movsx rax, WORD PTR [0x800000] ; 8bit 符号拡張ロード movsx rax, DWORD PTR [0x800000] ; 8bit 符号拡張ロード
さて、これで必要なことはひととおり説明したはずなので、ロードストア命令について掘り下げていこう。
メインメモリのメモリアドレスは、整数値だと書いた。この整数値は、レジスタに入っていても構わない。
レジスタに含まれる整数値を使って、メインメモリを参照することを、レジスタ間接参照 (register indirect addresssing) と呼ぶ。
x86_64 の AT&T 記法では、レジスタ名を丸括弧'()'で囲むと、そのレジスタを使ったレジスタ間接参照になる。
# gcc -static -no-pie -Tbss=0x800000 load-v2.s のようにビルドすれば、0x800000 のアドレスに # メインメモリがOSから割り当てられた状態でプログラムが起動する実行ファイルを作ることができる .globl main main: mov $0x99, %rax mov %rax, 0x800000 # 0x800000 に 0x99 をストア mov $0x800000, %rdx # RDX に整数値0x800000 を入れる mov (%rdx), %r8 # RDXの値(0x800000)をアドレスとして、メインメモリからロード ret
これを実行してみよう
(gdb) display /4i $pc 1: x/4i $pc <error: No registers.> (gdb) start Temporary breakpoint 1 at 0x401106 Starting program: /home/w0/src/pllp/docs/1/a.out Temporary breakpoint 1, 0x0000000000401106 in main () 1: x/4i $pc => 0x401106 <main>: mov $0x99,%rax 0x40110d <main+7>: mov %rax,0x800000 0x401115 <main+15>: mov $0x800000,%rdx 0x40111c <main+22>: mov (%rdx),%r8 (gdb) stepi 0x000000000040110d in main () 1: x/4i $pc => 0x40110d <main+7>: mov %rax,0x800000 0x401115 <main+15>: mov $0x800000,%rdx 0x40111c <main+22>: mov (%rdx),%r8 0x40111f <main+25>: retq (gdb) 0x0000000000401115 in main () 1: x/4i $pc => 0x401115 <main+15>: mov $0x800000,%rdx 0x40111c <main+22>: mov (%rdx),%r8 0x40111f <main+25>: retq 0x401120 <__libc_csu_init>: endbr64 (gdb) 0x000000000040111c in main () 1: x/4i $pc => 0x40111c <main+22>: mov (%rdx),%r8 0x40111f <main+25>: retq 0x401120 <__libc_csu_init>: endbr64 0x401124 <__libc_csu_init+4>: push %r15 (gdb) 0x000000000040111f in main () 1: x/4i $pc => 0x40111f <main+25>: retq 0x401120 <__libc_csu_init>: endbr64 0x401124 <__libc_csu_init+4>: push %r15 0x401126 <__libc_csu_init+6>: mov %rdx,%r15 (gdb) p /x $rdx $1 = 0x800000 (gdb) p /x $r8 $2 = 0x99
ここで重要なことは、アドレスは整数値で、その整数値がレジスタに入っているということだ。 レジスタに入っている整数値は、add 命令などを使って算術演算を行うことができた。 つまり、アドレスは算術演算することが可能だ。
(TODO:"算術演算"の説明を書いてない)
# gcc -static -no-pie -Tbss=0x800000 address-arith.s のようにビルドする .globl main main: movb $0x0, 0x800000 # 0x800000 から順番に 8個データを保存 movb $0x1, 0x800001 movb $0x2, 0x800002 movb $0x3, 0x800003 mov $0x800000, %rax # %rax に整数値0x800000を格納 mov $0, %r8 # %r8 ゼロ初期化 movsxb (%rax), %r9 add %r9, %r8 add $1, %rax movsxb (%rax), %r9 add %r9, %r8 add $1, %rax movsxb (%rax), %r9 add %r9, %r8 add $1, %rax ret
(gdb) start Temporary breakpoint 1 at 0x401106 Starting program: /home/w0/src/pllp/docs/1/a.out Temporary breakpoint 1, 0x0000000000401106 in main () (gdb) display /4xb 0x800000 1: x/4xb 0x800000 0x800000 <completed.7286>: 0x00 0x00 0x00 0x00 (gdb) display /x $rax 2: /x $rax = 0x401106 (gdb) display $r8 3: $r8 = 140737353694176 (gdb) display $r9 4: $r9 = 140737353694176 (gdb) display /4i $pc 5: x/4i $pc => 0x401106 <main>: mov $0x0,%r8 0x40110d <main+7>: mov $0x0,%r9 0x401114 <main+14>: movb $0x0,0x800000 0x40111c <main+22>: movb $0x1,0x800001 (gdb) stepi 0x000000000040110d in main () 1: x/4xb 0x800000 0x800000 <completed.7286>: 0x00 0x00 0x00 0x00 2: /x $rax = 0x401106 3: $r8 = 0 4: $r9 = 140737353694176 5: x/4i $pc => 0x40110d <main+7>: mov $0x0,%r9 0x401114 <main+14>: movb $0x0,0x800000 0x40111c <main+22>: movb $0x1,0x800001 0x401124 <main+30>: movb $0x2,0x800002 (gdb) stepi 0x0000000000401114 in main () 1: x/4xb 0x800000 0x800000 <completed.7286>: 0x00 0x00 0x00 0x00 2: /x $rax = 0x401106 3: $r8 = 0 4: $r9 = 0 5: x/4i $pc => 0x401114 <main+14>: movb $0x0,0x800000 0x40111c <main+22>: movb $0x1,0x800001 0x401124 <main+30>: movb $0x2,0x800002 0x40112c <main+38>: movb $0x3,0x800003 (gdb) 0x000000000040111c in main () 1: x/4xb 0x800000 0x800000 <completed.7286>: 0x00 0x00 0x00 0x00 2: /x $rax = 0x401106 3: $r8 = 0 4: $r9 = 0 5: x/4i $pc => 0x40111c <main+22>: movb $0x1,0x800001 0x401124 <main+30>: movb $0x2,0x800002 0x40112c <main+38>: movb $0x3,0x800003 0x401134 <main+46>: mov $0x800000,%rax (gdb) 0x0000000000401124 in main () 1: x/4xb 0x800000 0x800000 <completed.7286>: 0x00 0x01 0x00 0x00 2: /x $rax = 0x401106 3: $r8 = 0 4: $r9 = 0 5: x/4i $pc => 0x401124 <main+30>: movb $0x2,0x800002 0x40112c <main+38>: movb $0x3,0x800003 0x401134 <main+46>: mov $0x800000,%rax 0x40113b <main+53>: movsbq (%rax),%r9 (gdb) 0x000000000040112c in main () 1: x/4xb 0x800000 0x800000 <completed.7286>: 0x00 0x01 0x02 0x00 2: /x $rax = 0x401106 3: $r8 = 0 4: $r9 = 0 5: x/4i $pc => 0x40112c <main+38>: movb $0x3,0x800003 0x401134 <main+46>: mov $0x800000,%rax 0x40113b <main+53>: movsbq (%rax),%r9 0x40113f <main+57>: add %r9,%r8 (gdb) 0x0000000000401134 in main () 1: x/4xb 0x800000 0x800000 <completed.7286>: 0x00 0x01 0x02 0x03 2: /x $rax = 0x401106 3: $r8 = 0 4: $r9 = 0 5: x/4i $pc => 0x401134 <main+46>: mov $0x800000,%rax 0x40113b <main+53>: movsbq (%rax),%r9 0x40113f <main+57>: add %r9,%r8 0x401142 <main+60>: add $0x1,%rax (gdb) 0x000000000040113b in main () 1: x/4xb 0x800000 0x800000 <completed.7286>: 0x00 0x01 0x02 0x03 2: /x $rax = 0x800000 3: $r8 = 0 4: $r9 = 0 5: x/4i $pc => 0x40113b <main+53>: movsbq (%rax),%r9 0x40113f <main+57>: add %r9,%r8 0x401142 <main+60>: add $0x1,%rax 0x401146 <main+64>: movsbq (%rax),%r9 (gdb) 0x000000000040113f in main () 1: x/4xb 0x800000 0x800000 <completed.7286>: 0x00 0x01 0x02 0x03 2: /x $rax = 0x800000 3: $r8 = 0 4: $r9 = 0 5: x/4i $pc => 0x40113f <main+57>: add %r9,%r8 0x401142 <main+60>: add $0x1,%rax 0x401146 <main+64>: movsbq (%rax),%r9 0x40114a <main+68>: add %r9,%r8 (gdb) 0x0000000000401142 in main () 1: x/4xb 0x800000 0x800000 <completed.7286>: 0x00 0x01 0x02 0x03 2: /x $rax = 0x800000 3: $r8 = 0 4: $r9 = 0 5: x/4i $pc => 0x401142 <main+60>: add $0x1,%rax 0x401146 <main+64>: movsbq (%rax),%r9 0x40114a <main+68>: add %r9,%r8 0x40114d <main+71>: add $0x1,%rax (gdb) 0x0000000000401146 in main () 1: x/4xb 0x800000 0x800000 <completed.7286>: 0x00 0x01 0x02 0x03 2: /x $rax = 0x800001 3: $r8 = 0 4: $r9 = 0 5: x/4i $pc => 0x401146 <main+64>: movsbq (%rax),%r9 0x40114a <main+68>: add %r9,%r8 0x40114d <main+71>: add $0x1,%rax 0x401151 <main+75>: movsbq (%rax),%r9 (gdb) 0x000000000040114a in main () 1: x/4xb 0x800000 0x800000 <completed.7286>: 0x00 0x01 0x02 0x03 2: /x $rax = 0x800001 3: $r8 = 0 4: $r9 = 1 5: x/4i $pc => 0x40114a <main+68>: add %r9,%r8 0x40114d <main+71>: add $0x1,%rax 0x401151 <main+75>: movsbq (%rax),%r9 0x401155 <main+79>: add %r9,%r8 (gdb) 0x000000000040114d in main () 1: x/4xb 0x800000 0x800000 <completed.7286>: 0x00 0x01 0x02 0x03 2: /x $rax = 0x800001 3: $r8 = 1 4: $r9 = 1 5: x/4i $pc => 0x40114d <main+71>: add $0x1,%rax 0x401151 <main+75>: movsbq (%rax),%r9 0x401155 <main+79>: add %r9,%r8 0x401158 <main+82>: add $0x1,%rax (gdb) 0x0000000000401151 in main () 1: x/4xb 0x800000 0x800000 <completed.7286>: 0x00 0x01 0x02 0x03 2: /x $rax = 0x800002 3: $r8 = 1 4: $r9 = 1 5: x/4i $pc => 0x401151 <main+75>: movsbq (%rax),%r9 0x401155 <main+79>: add %r9,%r8 0x401158 <main+82>: add $0x1,%rax 0x40115c <main+86>: movsbq (%rax),%r9 (gdb) 0x0000000000401155 in main () 1: x/4xb 0x800000 0x800000 <completed.7286>: 0x00 0x01 0x02 0x03 2: /x $rax = 0x800002 3: $r8 = 1 4: $r9 = 2 5: x/4i $pc => 0x401155 <main+79>: add %r9,%r8 0x401158 <main+82>: add $0x1,%rax 0x40115c <main+86>: movsbq (%rax),%r9 0x401160 <main+90>: add %r9,%r8 (gdb) 0x0000000000401158 in main () 1: x/4xb 0x800000 0x800000 <completed.7286>: 0x00 0x01 0x02 0x03 2: /x $rax = 0x800002 3: $r8 = 3 4: $r9 = 2 5: x/4i $pc => 0x401158 <main+82>: add $0x1,%rax 0x40115c <main+86>: movsbq (%rax),%r9 0x401160 <main+90>: add %r9,%r8 0x401163 <main+93>: retq (gdb) 0x000000000040115c in main () 1: x/4xb 0x800000 0x800000 <completed.7286>: 0x00 0x01 0x02 0x03 2: /x $rax = 0x800003 3: $r8 = 3 4: $r9 = 2 5: x/4i $pc => 0x40115c <main+86>: movsbq (%rax),%r9 0x401160 <main+90>: add %r9,%r8 0x401163 <main+93>: retq 0x401164 <main+94>: nopw %cs:0x0(%rax,%rax,1) (gdb) 0x0000000000401160 in main () 1: x/4xb 0x800000 0x800000 <completed.7286>: 0x00 0x01 0x02 0x03 2: /x $rax = 0x800003 3: $r8 = 3 4: $r9 = 3 5: x/4i $pc => 0x401160 <main+90>: add %r9,%r8 0x401163 <main+93>: retq 0x401164 <main+94>: nopw %cs:0x0(%rax,%rax,1) 0x40116e <main+104>: xchg %ax,%ax (gdb) 0x0000000000401163 in main () 1: x/4xb 0x800000 0x800000 <completed.7286>: 0x00 0x01 0x02 0x03 2: /x $rax = 0x800003 3: $r8 = 6 4: $r9 = 3 5: x/4i $pc => 0x401163 <main+93>: retq 0x401164 <main+94>: nopw %cs:0x0(%rax,%rax,1) 0x40116e <main+104>: xchg %ax,%ax 0x401170 <__libc_csu_init>: endbr64 (gdb)
rax レジスタに入れた値を +1 していくと、メインメモリの連続した領域に格納した値を順番に読んでいけることを確認してほしい。 (重要な部分なので、できればちゃんと理解できるまで手元で実行して確認することをおすすめする。また、余裕があれば、プログラムを改変して、動きがどう変わるかなども確認してほしい)
またいくつか説明していないことが出てきたので説明しておこう。
movb $0x0, 0x800000 # 0x800000 から順番に 8個データを保存 movb $0x1, 0x800001 movb $0x2, 0x800002 movb $0x3, 0x800003
x86_64 では、即値を直接メインメモリにストアすることができる。 (これができるCPUは現代ではx86系のCPUぐらいで、ARMやPowerPC、MIPSはできない) operand0 にメモリアドレス、operand1 に即値を書く。 詳しくは、またあとのx86_64機械語の説明のところで説明しよう。
この場合は、mov ではなく、mov"b" 命令を使っている点に注意してほしい。 この "b" は、movsx のところで書いたサイズを指定する接尾辞と同じもので、 この mov 命令が1byteの値をmovするように指定している。
ここで、単に "mov" 命令を使ってしまうと、
mov $0x0, 0x800000 # 0x800000 から順番に 8個データを保存 mov $0x1, 0x800001 mov $0x2, 0x800002 mov $0x3, 0x800003
ストアするバイト数が一意に定まらない。このような場合には、明示的に "b" を付ける。
この接尾辞は、命令がオペランドサイズから一意に定まる場合にも付けてよくて、 例えばレジスタ間転送する場合は、レジスタ名から転送サイズが一意に定まるが、その場合にも接尾辞を付けてよい
mov %rax, %rcx # 8 byte 転送 movq %rax, %rcx # 8 byte 転送 (q は 8byte の意味)
次に、gdb のコマンドを見てほしいが、
(gdb) stepi 0x0000000000401114 in main () 1: x/4xb 0x800000 0x800000 <completed.7286>: 0x00 0x00 0x00 0x00 2: /x $rax = 0x401106 3: $r8 = 0 4: $r9 = 0 5: x/4i $pc => 0x401114 <main+14>: movb $0x0,0x800000 0x40111c <main+22>: movb $0x1,0x800001 0x401124 <main+30>: movb $0x2,0x800002 0x40112c <main+38>: movb $0x3,0x800003 (gdb) 0x000000000040111c in main () 1: x/4xb 0x800000 0x800000 <completed.7286>: 0x00 0x00 0x00 0x00 2: /x $rax = 0x401106 3: $r8 = 0 4: $r9 = 0 5: x/4i $pc => 0x40111c <main+22>: movb $0x1,0x800001 0x401124 <main+30>: movb $0x2,0x800002 0x40112c <main+38>: movb $0x3,0x800003 0x401134 <main+46>: mov $0x800000,%rax
ここで、二回目は、"stepi" コマンドを入力していない点が気になる人もいるかもしれない。
gdb のプロンプトでは、単にエンターキーを押すと、最後に入力したコマンドがもう一度実行される。 ここでは、直前のコマンドは"stepi"なので、この何もコマンドを入れていない行は、 "stepi"をコマンドを入力したことになっている。
ロードストアの説明はこのあたりにしておこう。
低レベルプログラミングをする場合、ロードストア命令についての正しい理解が求められる場面がかなり多い。 きちんと正しい理解が得られるまで、ゆっくり色々試行錯誤してみるのがよいと思う。
レジスタについて解説したところで、
と、いうのを書いていた。
現代の一般的なCPUでは、プログラムは、データと本質的な違いはなく、 メモリ上に配置されたデータと同じように、メインメモリ上の"どこか"に配置されたバイト列である。 CPU は命令を実行するとき、このメインメモリ上に配置された命令バイト列を、メインメモリからロードしてくる(このロードはフェッチ(fetch)と呼ばれる)。
ロードストア命令のところで、
mov (%rax), %rdx
のように書くと、レジスタに含まれる整数値をアドレスとして使ってメインメモリを参照する、「レジスタ間接参照」ができると説明した。
フェッチは、プログラムカウンタを使ってレジスタ間接参照して、命令バイト列をロードしてくる動作だとも言える。
まずは、CPUの命令は単なるバイト列でしかないことを確認しておこう
.section "axw", "axw" .globl main main: movl $0x11223344, mov_inst+3 jmp 1f # プログラムを書きかえたあとに必要 1: nop # プログラムを書きかえたあとに必要 mov_inst: mov $0x1, %rax # 0x48 0xc7 0xc0 0x01 0x00 0x00 0x00 ret
いくつか説明していないことが含まれているが、とりあえず実行してみる。
(gdb) start Temporary breakpoint 1 at 0x404028 Starting program: /home/w0/src/pllp/docs/1/a.out Temporary breakpoint 1, 0x0000000000404028 in main () (gdb) display /x5i $pc Invalid number "5i". (gdb) display /5i $pc 1: x/5i $pc => 0x404028 <main>: movl $0x11223344,0x404039 0x404033 <main+11>: jmp 0x404035 <main+13> 0x404035 <main+13>: nop 0x404036 <mov_inst>: mov $0x1,%rax 0x40403d <mov_inst+7>: retq (gdb) display /8xb mov_inst 2: x/8xb mov_inst 0x404036 <mov_inst>: 0x48 0xc7 0xc0 0x01 0x00 0x00 0x00 0xc3 (gdb) stepi 0x0000000000404033 in main () 1: x/5i $pc => 0x404033 <main+11>: jmp 0x404035 <main+13> 0x404035 <main+13>: nop 0x404036 <mov_inst>: mov $0x11223344,%rax 0x40403d <mov_inst+7>: retq 0x40403e: rex.RXB 2: x/8xb mov_inst 0x404036 <mov_inst>: 0x48 0xc7 0xc0 0x44 0x33 0x22 0x11 0xc3 (gdb) stepi 0x0000000000404035 in main () 1: x/5i $pc => 0x404035 <main+13>: nop 0x404036 <mov_inst>: mov $0x11223344,%rax 0x40403d <mov_inst+7>: retq 0x40403e: rex.RXB 0x40403f: rex.XB 2: x/8xb mov_inst 0x404036 <mov_inst>: 0x48 0xc7 0xc0 0x44 0x33 0x22 0x11 0xc3 (gdb) stepi 0x0000000000404036 in mov_inst () 1: x/5i $pc => 0x404036 <mov_inst>: mov $0x11223344,%rax 0x40403d <mov_inst+7>: retq 0x40403e: rex.RXB 0x40403f: rex.XB 0x404040: rex.XB cmp (%r8),%spl 2: x/8xb mov_inst 0x404036 <mov_inst>: 0x48 0xc7 0xc0 0x44 0x33 0x22 0x11 0xc3 (gdb) stepi 0x000000000040403d in mov_inst () 1: x/5i $pc => 0x40403d <mov_inst+7>: retq 0x40403e: rex.RXB 0x40403f: rex.XB 0x404040: rex.XB cmp (%r8),%spl 0x404043: sub %al,0x4e(%rdi) 2: x/8xb mov_inst 0x404036 <mov_inst>: 0x48 0xc7 0xc0 0x44 0x33 0x22 0x11 0xc3 (gdb) p /x $rax $1 = 0x11223344 (gdb)
以下の部分で、main の先頭にある mov 命令によるストアの実行後、mov $1, %rax の命令が書きかわっていることを確認しよう。
(gdb) display /5i $pc 1: x/5i $pc => 0x404028 <main>: movl $0x11223344,0x404039 <= このストアの実行後 0x404033 <main+11>: jmp 0x404035 <main+13> 0x404035 <main+13>: nop 0x404036 <mov_inst>: mov $0x1,%rax <= mov $0x1, %rax が 0x40403d <mov_inst+7>: retq (gdb) display /8xb mov_inst 2: x/8xb mov_inst 0x404036 <mov_inst>: 0x48 0xc7 0xc0 0x01 0x00 0x00 0x00 0xc3 (gdb) stepi 0x0000000000404033 in main () 1: x/5i $pc => 0x404033 <main+11>: jmp 0x404035 <main+13> 0x404035 <main+13>: nop 0x404036 <mov_inst>: mov $0x11223344,%rax <= mov $0x11223344, %rax に書きかわっている 0x40403d <mov_inst+7>: retq 0x40403e: rex.RXB 2: x/8xb mov_inst 0x404036 <mov_inst>: 0x48 0xc7 0xc0 0x44 0x33 0x22 0x11 0xc3
また、最後に、rax の値が、0x11223344 になっており、実際に命令が書きかわって効果があらわれていることを確認しよう。
(gdb) p /x $rax $1 = 0x11223344
"mov $0x1, %rax" という文は、メインメモリ上では、0x48 0xc7 0xc0 0x01 0x00 0x00 0x00 というバイト列として表現される。
objdump というコマンドに、"-d" と、実行ファイルを渡すと、 その実行ファイルに含まれる命令一覧と その命令のバイト列を知ることができる
$ objdump -d a.out セクション axw の逆アセンブル: 0000000000404028 <main>: 404028: c7 04 25 39 40 40 00 movl $0x11223344,0x404039 40402f: 44 33 22 11 404033: eb 00 jmp 404035 <main+0xd> 404035: 90 nop 0000000000404036 <mov_inst>: 404036: 48 c7 c0 01 00 00 00 mov $0x1,%rax 40403d: c3 retq
これを見れば、mov $0x1,%rax が、0x48 0xc7 0xc0 0x01 0x00 0x00 0x00 になることが確認できる。(筆者も別に覚えているわけではなくて、これを見てカンニングしている)
このバイト列の意味は、
# x86_64 はリトルエンディアンなので、32bit値をバイト毎に表示すると順序が反対に表示されることに注意 48 c7 c0 01 00 00 00 mov $0x1,%rax mov imm,rax imm=0x00000001
と、なっていて、最初の3byteで、即値(immediate)をraxに転送するmov命令だとあらわしていて、後ろの4byteで、転送するimmの32bit値をあらわしている。 後ろの4byteはそのまま32bit値なので、これを書きかえれば、命令中の即値が変わる。
# x86_64 はリトルエンディアンなので、32bit値をバイト毎に表示すると順序が反対に表示されることに注意 48 c7 c0 44 33 22 11 mov $0x11223344,%rax mov imm,rax imm=0x11223344
このように書きかえているのが、上のプログラムだ。 このプログラムの挙動を見れば、CPUの命令というのはメインメモリに配置されたバイト列だということがわかるだろう。 (これも大事なのでわからない人は連絡ください)
いくつか説明していないことが出てきたのでその点の説明をしておこう。
まず、
.section "axw", "axw"
これは…これはですね…リンカのところで説明します(そればっかやな…)。
簡単に説明すると、OSの上で動くプログラムのアドレスは、「読み取り可能」「書き込み可能」「実行可能」というみっつの属性を持っていて、その属性を指示する文がこの.sectionになる。
OS上のプログラムでは、アドレスに対する操作と、アドレスの属性があっていないと、 メモリ保護の仕組みが働いて、プログラムが止められてしまう。 今は、命令バイト列に対する書き込み操作をしたいのだが、一般的なプログラムでは、 命令バイト列に対して書き込みをすることはまずないので、命令バイト列に対する書き込みは禁止されている。 つまり、この.section文を書かないでプログラムを実行すると、OSによってプログラムが停止させられてしまう。 (section文を消して確認してみてほしい)
次に、
jmp 1f # プログラムを書きかえたあとに必要 1: nop # プログラムを書きかえたあとに必要
この部分は、Intel のマニュアルにこうしろと書いてあるから書いている。 x86_64 では、命令列を書きかえたあとは、一旦この命令を実行しないといけない。 これが必要な理由は、CPUハードウェアの実装によるので、正しい説明はできないが、 簡単に説明しておくと、CPUの命令をバイト列として実行時に書きかえるのは、滅多にやらないことなので、 CPU内部では、データ用バイト列が流れる道と、命令用のバイト列が流れる道が別々に設計してあることが多い。 そのふたつの道をちゃんと同期するために、この処理が必要だ。
それから、
movl $0x11223344, mov_inst+3 ... mov_inst: ...
この、mov_inst…これも…詳しくはリンカのところで説明しゅる…
一応、簡単に説明しておこう。"main関数" のところで
main:
と、書けば、"ラベルというCのmain関数のようなもの"になると説明した。
ここの
mov_inst:
も、main: と同じくラベルになる。ラベルは、整数値アドレスと同じように、アセンブリ言語内で、アドレスのように扱うことができる。
label: mov 0x800000, %rax # movのようにオペランドにメモリアドレスを取れる命令は、 mov label, %rax # 同じようにラベルをオペランドに取ることができる
リンカは、大量の命令バイト列を一個のファイルにまとめるという処理をし、命令が配置されるアドレスを確定する。 ということは、つまり、命令が配置されるアドレスは、リンクの処理が終わるまで確定しないということである。
label: add $1, %rax # この add 命令が配置されるアドレスは、リンクが終わるまで確定しない mov 0x800000, %rax # mov label, %rax #
しかし、データや命令が配置されるアドレスが知りたい場合はよくあるので、これをなんとかしたい。 そういう時にラベルが使われる。
nop nop nop label: add $1, %rax # mov 0x800000, %rax # mov label, %rax #
リンク後、このようにadd命令の上に 3byte 分の命令が追加されたとしよう。 プログラムがアドレス0番から始まっているとすると、add 命令が配置されるアドレスは、3 byte目だ。
この時、リンカは、ラベルの置かれているアドレスを確定させて、 整合性が取れるようにそのラベルを参照している命令の一部を書きかえる。
nop nop nop label: # ← ここは3byte目 add $1, %rax # mov 0x800000, %rax # mov 0x3, %rax # ラベル "label" への参照を、確定したlabelのアドレス(0x3) におきかえる。
このようになる。
上で書いた例をもう一度見てみよう
.section "axw", "axw" .globl main main: movl $0x11223344, mov_inst+3 # ラベルmov_inst から +3 byte した位置を参照 jmp 1f 1: nop mov_inst: # 書きかえたい命令の位置にラベルを置く mov $0x1, %rax # 0x48 0xc7 0xc0 0x01 0x00 0x00 0x00 のうち、書きかえたいのは命令の中の3byte目から ret
今やりたいことは、mov 命令の中の32bit値の書きかえだった。 ところが、リンクが終わるまで、mov 命令のアドレスは確定しない。 そこで、書きかえたいmov命令にラベルを置いて、リンク後に確定したmov命令の位置を参照できるようにしている。
gdb が出力した命令のダンプを見てほしい
(gdb) display /5i $pc 1: x/5i $pc => 0x404028 <main>: movl $0x11223344,0x404039 0x404033 <main+11>: jmp 0x404035 <main+13> 0x404035 <main+13>: nop 0x404036 <mov_inst>: mov $0x1,%rax 0x40403d <mov_inst+7>: retq
書きかえたい対象の mov 命令のアドレスが0x404036(環境に依存して変わります)になっていて、 それを書き変えるストアのmov 命令が、movl $0x11223344,0x404039 (= 0x404036 + 3) になって、 ラベルを参照していた命令のオペランドが、確定後のラベルのアドレスに書き換わっている点を確認しよう。
(よくわからなければ、先にリンカの説明を見たほうがいいかもしれない (注:まだリンカの説明は書いてない))
さて、命令は単なるバイト列だというのが確認できたところで、次へ進もう。次は分岐命令だ。 分岐命令は、プログラムの流れを変える命令で、高級言語から使えるループ、if文、関数などを実現するために使われている。
通常、命令実行後、プログラムカウンタは命令サイズ分インクリメントされて次の命令のアドレスを指すようになるが、 分岐命令の実行後は、次の命令ではなく、オペランドで指定されたアドレスを指すようになり、 次の命令は、そこから実行される。
.globl main main: mov $0, %rax loop: add $1, %rax jmp loop
x86_64 では、分岐命令の名前は "jmp" になっている。 このプログラムを実行してみよう。
(gdb) display /1i $pc 1: x/i $pc <error: No registers.> (gdb) display $pc 2: $pc = <error: No registers.> (gdb) display $rax 3: $rax = <error: No registers.> (gdb) start Temporary breakpoint 1 at 0x401106 Starting program: /home/w0/src/pllp/docs/1/a.out Temporary breakpoint 1, 0x0000000000401106 in main () 1: x/i $pc => 0x401106 <main>: mov $0x0,%rax 2: $pc = (void (*)()) 0x401106 <main> 3: $rax = 4198662 (gdb) stepi 0x000000000040110d in loop () 1: x/i $pc => 0x40110d <loop>: add $0x1,%rax 2: $pc = (void (*)()) 0x40110d <loop> 3: $rax = 0 (gdb) 0x0000000000401111 in loop () 1: x/i $pc => 0x401111 <loop+4>: jmp 0x40110d <loop> 2: $pc = (void (*)()) 0x401111 <loop+4> 3: $rax = 1 (gdb) 0x000000000040110d in loop () 1: x/i $pc => 0x40110d <loop>: add $0x1,%rax 2: $pc = (void (*)()) 0x40110d <loop> 3: $rax = 1 (gdb) 0x0000000000401111 in loop () 1: x/i $pc => 0x401111 <loop+4>: jmp 0x40110d <loop> 2: $pc = (void (*)()) 0x401111 <loop+4> 3: $rax = 2 (gdb) 0x000000000040110d in loop () 1: x/i $pc => 0x40110d <loop>: add $0x1,%rax 2: $pc = (void (*)()) 0x40110d <loop> 3: $rax = 2 (gdb)
stepi を繰り返すと、プログラムカウンタが、loop ラベルの付いた命令にもどって、何度もadd命令を繰り返し実行し、raxレジスタの値が一個ずつ増えていくことを確認しよう。
プログラムカウンタの値も単なる整数だ。rax などの汎用レジスタに格納された整数値(アドレス)を、プログラムカウンタにコピーすることもできる。 汎用レジスタが指すアドレスへの分岐を、間接分岐 (indirect branch) と呼ぶ。
.globl main main: mov $loop, %rcx mov $0, %rax loop: add $1, %rax jmp *%rcx
loopラベルのアドレス値を、rcxレジスタに入れ、最後に jmp 命令を使って rcx レジスタの値をプログラムカウンタへコピーし、プログラムの流れをloopラベルが指す位置に戻している。これもgdbで実行して動作を確認しておいてほしい。
"mov $loop, %rax"というのは、少しわかりにくいかもしれない。この x86_64 アセンブリ言語固有のわかりにくい点について説明しておこう。
x86_64 では、オペランドの数字文字列に "$" を付けるか付けないかは大きな違いがある。 "$" が付いていればそのオペランドは即値、"$" が付いていなければそのオペランドはアドレス値だ。
mov 16, %rax // アドレス値 16 から、64bit 値をロードしてraxに格納する mov $16, %rax // 即値16をraxに格納する
これは、とにかく紛らわしくて、慣れても何度も間違えてしまうのだが、間違えないように注意してほしい。
このルールは、ラベルにも適用されて、"$"が付いたら即値、"$"が付かなければメモリオペランドだ。
mov label, %rax // リンク後解決された label のアドレス値からロード mov $label, %rax // リンク後解決された label のアドレスを即値としてレジスタに格納
上の例では、
mov $loop, %rcx
と、書いているが、これは、「loopラベルが置かれたアドレスの値を即値として rcx に格納する」という意味だ。
ここで、"$" を書き忘れると、
mov loop, %rcx
「loopラベルが置かれたアドレスから64bit 値をロードして、rcxに格納する」という意味になる。このふたつは、*意味が全く違う* 点に注意してほしい。
(x86系以外の多くのCPUでは、ロード命令と即値movに別のニーモニックが割り当てられていることが多いので、もっと区別しやすい)
続いて説明するのは、条件分岐命令 だ。
上の分岐命令を使ったプログラムは、無限ループになっていて、一旦実行すると終了しない。 これをもう少し発展させよう。
.globl main main: mov $10, %rcx mov $0, %rax loop: add %rcx, %rax sub $1, %rcx jnz loop end: ret
これは、1〜10 までの総和 を求めるプログラムだ。gdbで以下のようにすれば rax が 55 になることが確認できる。
(gdb) start Temporary breakpoint 1 at 0x401106 Starting program: /home/w0/src/pllp/docs/1/a.out Temporary breakpoint 1, 0x0000000000401106 in main () (gdb) break end Breakpoint 2 at 0x40111d (gdb) continue Continuing. Breakpoint 2, 0x000000000040111d in end () (gdb) p $rax $1 = 55 (gdb)
また新たな gdb のコマンドを使っているので説明しておこう。breakとcontinueだ。
"break" コマンドは、コマンド引数に渡したアドレスに ブレークポイント と呼ばれるものを設定するコマンドだ。 このアドレスには、ラベルも使える。上の例では "break end" と書いたが、これは、end ラベルが置かれたアドレスにブレークポイントを設定する。 "break" コマンドは、非常によく使うコマンドなので、1文字で書くことができて、"b" でもよい。
"continue" コマンドは、"break"コマンドで設定したブレークポイント まで、プログラムの実行を進めるコマンドだ。 上の例では、直前に end: ラベルの位置に、ブレークポイントを設定したので、その位置までプログラムを進める。
上のgdbの結果は、プログラムカウンタが end ラベルに到達したときに、rax の値を表示すると 55 が表示されるという結果だ。
分岐の説明にもどろう。 jnz 命令は、条件分岐命令 (conditional branch) と呼ばれる命令の一種だ。 条件分岐命令 は演算の結果(条件)を見て、条件が成立した場合に、分岐する(オペランドの値をプログラムカウンタに格納する)命令である。 使われる条件には色々あるが、jnz 命令では、直前の演算結果がゼロでなかった場合に分岐(jnz = Jump if Not Zero)する。
プログラムの挙動を解説していこう。
mov $10, %rcx mov $0, %rax
まずRCX、RAXを初期化する。RCXは、ループカウンタとして、RAXは合計値を入れるレジスタとして使う。
add %rcx, %rax
合計値をとりたいので、RCXをRAXに足す。 (RCXの値は下のsub命令で減っていくので、ここで加算する値は、10,9,8,...,1 というように減っていく)
sub $1, %rcx jnz loop
RCXの値から1を引いて、その結果がゼロでなければ loop: ラベルが置かれたアドレスへジャンプ
end: ret
RCX の値が、ゼロになれば、終了。
というプログラムだ。長くなるのでもう書かないが、納得するまでgdbで動作を確認してほしい。
条件分岐命令について、もう少し詳しく説明しよう。条件分岐は、「演算の結果(条件)」を見る、と書いた。 x86_64 では、この条件は、eflagsというフラグレジスタの値のことだ。
レジスタの説明のところで、
と、書いていた。x86_64 の eflags レジスタは、このフラグレジスタに該当するレジスタで、演算結果の追加情報を格納する (MIPSのようにフラグレジスタに該当するレジスタが無いアーキテクチャもある)
.globl main main: mov $0xffffffffffffffff, %rax add $1, %rax ret
(gdb) start Temporary breakpoint 1 at 0x4004d6 Starting program: /mnt/d/wsl/src/pllp/docs/1/a.out Temporary breakpoint 1, 0x00000000004004d6 in main () (gdb) stepi 0x00000000004004dd in main () (gdb) p $rax $1 = -1 (gdb) p $eflags $2 = [ PF ZF IF ] (gdb) stepi 0x00000000004004e1 in main () (gdb) p $eflags $3 = [ CF PF AF ZF IF ]
gdb のプロンプトから print $eflags すると eflags の値を確認できる。 eflags は 32bit のレジスタだが、gdbは気を効かせて、各ビットの名前を表示してくれる。
ユーザーが書くプログラムで重要なのは、CF,ZF と、ここでは消えているが、OF,ZFだ。 PF, AF は、もう過去の遺物なので二度と見なくていい。 他のフラグはOS、仮想マシンを書く人向けなので、ユーザーは見る必要はないだろう。 (あ、DFがあった。DFは説明しないので各自で調べて)
それぞれの簡単な意味は以下のとおりだ
詳細な意味は、命令によって変わるので、詳しく知る必要がある場合は、Intel の命令マニュアルを読もう。 (正直なところ、筆者も詳しく知る必要に迫られたことがないので、挙動を完全に把握しているわけではない)
jnz 命令は、このうち、ZF を条件として使う条件分岐で、ZF フラグがセットされていなかった場合に、分岐する。 他にも色々な条件分岐命令が用意されているが、それはC言語との対応を見るところで説明しよう。(注 : まだ書いてないです)
sub $1, %rcx jnz loop
もう一度この部分を見てみよう。sub 命令は結果がゼロになると、eflagsのZFをセットする。ゼロでない場合は、ZFをクリアする。
jnz 命令は、eflags を見て、ZF がクリアされていれば、オペランドで指定されたアドレスへ分岐、 ZF がセットされていれば、オペランドを無視して、直後にある命令を引き続き実行していく。
つまり、上のように書くことで、RCX がゼロになるまで回り続けるループを表現できる。
次に紹介する分岐命令は、関数呼び出し命令だ。
.globl main func: add $1, %rax ret func2: call func ret func3: call func2 ret main: mov $0, %rax call func call func3 ret
(gdb) display /4gx $rsp 1: x/4xg $rsp <error: No registers.> (gdb) display $rsp 2: $rsp = <error: No registers.> (gdb) display $rax 3: $rax = <error: No registers.> (gdb) display /2i $pc 4: x/2i $pc <error: No registers.> (gdb) start Temporary breakpoint 1 at 0x4009bf Starting program: /mnt/d/wsl/src/pllp/docs/1/a.out Temporary breakpoint 1, 0x00000000004009bf in main () 1: x/4xg $rsp 0x7ffffffee388: 0x0000000000400c26 0x0000000000000000 0x7ffffffee398: 0x0000000100000000 0x00007ffffffee4c8 2: $rsp = (void *) 0x7ffffffee388 3: $rax = 4196799 4: x/2i $pc => 0x4009bf <main>: mov $0x0,%rax 0x4009c6 <main+7>: callq 0x4009ae <func> (gdb) stepi 0x00000000004009c6 in main () 1: x/4xg $rsp 0x7ffffffee388: 0x0000000000400c26 0x0000000000000000 0x7ffffffee398: 0x0000000100000000 0x00007ffffffee4c8 2: $rsp = (void *) 0x7ffffffee388 3: $rax = 0 4: x/2i $pc => 0x4009c6 <main+7>: callq 0x4009ae <func> 0x4009cb <main+12>: callq 0x4009b9 <func3> (gdb) 0x00000000004009ae in func () 1: x/4xg $rsp 0x7ffffffee380: 0x00000000004009cb 0x0000000000400c26 0x7ffffffee390: 0x0000000000000000 0x0000000100000000 2: $rsp = (void *) 0x7ffffffee380 3: $rax = 0 4: x/2i $pc => 0x4009ae <func>: add $0x1,%rax 0x4009b2 <func+4>: retq (gdb) 0x00000000004009b2 in func () 1: x/4xg $rsp 0x7ffffffee380: 0x00000000004009cb 0x0000000000400c26 0x7ffffffee390: 0x0000000000000000 0x0000000100000000 2: $rsp = (void *) 0x7ffffffee380 3: $rax = 1 4: x/2i $pc => 0x4009b2 <func+4>: retq 0x4009b3 <func2>: callq 0x4009ae <func> (gdb) 0x00000000004009cb in main () 1: x/4xg $rsp 0x7ffffffee388: 0x0000000000400c26 0x0000000000000000 0x7ffffffee398: 0x0000000100000000 0x00007ffffffee4c8 2: $rsp = (void *) 0x7ffffffee388 3: $rax = 1 4: x/2i $pc => 0x4009cb <main+12>: callq 0x4009b9 <func3> 0x4009d0 <main+17>: retq (gdb) 0x00000000004009b9 in func3 () 1: x/4xg $rsp 0x7ffffffee380: 0x00000000004009d0 0x0000000000400c26 0x7ffffffee390: 0x0000000000000000 0x0000000100000000 2: $rsp = (void *) 0x7ffffffee380 3: $rax = 1 4: x/2i $pc => 0x4009b9 <func3>: callq 0x4009b3 <func2> 0x4009be <func3+5>: retq (gdb) 0x00000000004009b3 in func2 () 1: x/4xg $rsp 0x7ffffffee378: 0x00000000004009be 0x00000000004009d0 0x7ffffffee388: 0x0000000000400c26 0x0000000000000000 2: $rsp = (void *) 0x7ffffffee378 3: $rax = 1 4: x/2i $pc => 0x4009b3 <func2>: callq 0x4009ae <func> 0x4009b8 <func2+5>: retq (gdb) 0x00000000004009ae in func () 1: x/4xg $rsp 0x7ffffffee370: 0x00000000004009b8 0x00000000004009be 0x7ffffffee380: 0x00000000004009d0 0x0000000000400c26 2: $rsp = (void *) 0x7ffffffee370 3: $rax = 1 4: x/2i $pc => 0x4009ae <func>: add $0x1,%rax 0x4009b2 <func+4>: retq (gdb) 0x00000000004009b2 in func () 1: x/4xg $rsp 0x7ffffffee370: 0x00000000004009b8 0x00000000004009be 0x7ffffffee380: 0x00000000004009d0 0x0000000000400c26 2: $rsp = (void *) 0x7ffffffee370 3: $rax = 2 4: x/2i $pc => 0x4009b2 <func+4>: retq 0x4009b3 <func2>: callq 0x4009ae <func> (gdb) 0x00000000004009b8 in func2 () 1: x/4xg $rsp 0x7ffffffee378: 0x00000000004009be 0x00000000004009d0 0x7ffffffee388: 0x0000000000400c26 0x0000000000000000 2: $rsp = (void *) 0x7ffffffee378 3: $rax = 2 4: x/2i $pc => 0x4009b8 <func2+5>: retq 0x4009b9 <func3>: callq 0x4009b3 <func2> (gdb) 0x00000000004009be in func3 () 1: x/4xg $rsp 0x7ffffffee380: 0x00000000004009d0 0x0000000000400c26 0x7ffffffee390: 0x0000000000000000 0x0000000100000000 2: $rsp = (void *) 0x7ffffffee380 3: $rax = 2 4: x/2i $pc => 0x4009be <func3+5>: retq 0x4009bf <main>: mov $0x0,%rax (gdb) 0x00000000004009d0 in main () 1: x/4xg $rsp 0x7ffffffee388: 0x0000000000400c26 0x0000000000000000 0x7ffffffee398: 0x0000000100000000 0x00007ffffffee4c8 2: $rsp = (void *) 0x7ffffffee388 3: $rax = 2 4: x/2i $pc => 0x4009d0 <main+17>: retq 0x4009d1 <main+18>: nopw %cs:0x0(%rax,%rax,1) (gdb)
x86_64 の call 命令は、1命令で次の動作をする。
ret 命令は、call 命令と対応する命令で、次の動作をする。
と、なる。この挙動がなぜ関数呼び出しになるのか。
プログラムで出てくる基本的なデータ構造のひとつに、スタックと呼ばれるものがある。
スタックは、push、pop というふたつの操作ができるデータ構造で、pop すると、最後にpush したデータを取り出すことができる。
1. push 2 2. push 3 3. push 4 4. pop → 4が取り出せる 5. pop → 3が取り出せる 6. push 5 7. pop → 5が取り出せる 8. pop → 2が取り出せる
(TODO:わかりやすい図を入れたい)
プログラムを書くときには、よく使う処理を関数にまとめることが多い(関数の説明やメリットは、別の書籍等にたくさん書かれているのでそちらを参照してほしい)。 関数を使うときの挙動は、このスタックと同じ構造をしている。
1. 関数2を呼び出す 2. 関数3を呼び出す 3. 関数4を呼び出す 4. 関数4が終了 → 関数3に戻る 5. 関数3が終了 → 関数2に戻る 6. 関数5を呼び出す 7. 関数5が終わる → 関数2に戻る 8. 関数2が終わる
これは、スタックがあれば、関数を呼んだあとに、正しく元の位置に戻ってくるという動作が実現できることを意味している。
スタックは、メインメモリとアドレス値一個あれば実現できる単純なデータ構造なので、昔のCPUでは命令として実装されていた。 今のCPUでは、スタックは命令レベルでは実装されていないが、スタックの構造は関数呼び出しを実現するために有用なので、 命令を組み合わせてスタックを作るのが一般的だ。
文脈なしでスタックと言った場合、抽象的なデータ構造のことを言うが、 アセンブリの近くにいるときの文脈で、スタックと言った場合、もう少し意味が狭く、具体的な意味を持つ。
アセンブリ言語の文脈では、スタックとは、OSから割り当てられた特定のメモリ領域のことを言う。
スタックポインタは、このスタックを指すレジスタのことを言う。 スタックを命令レベルでサポートするCPUでは、CPUの仕様で決められたレジスタをスタックポインタとして使う。 スタックを命令レベルでサポートしないCPUでは、ソフトウェア的に特定のレジスタをスタックポインタとして決めて使う。
x86_64は昔のCPU仕様を引き継いでいるので、スタックを命令レベルでサポートする側のCPUだ。 x86_64では、RSP レジスタをスタックポインタとして使う。
(gdb) start Temporary breakpoint 1 at 0x4004db Starting program: /mnt/d/wsl/src/pllp/docs/1/a.out Temporary breakpoint 1, 0x00000000004004db in main () (gdb) p /x $rsp $1 = 0x7ffffffee3e8
プログラム開始直後に RSP の値を表示してみよう。このアドレスが、OSから割り当てられたスタック領域を指すアドレスだ。
スタックのサイズは、OS毎に異なるが、Linuxでは、ulimitという、プロセス毎に指定された値で決まる。
ulimit の値は、ulimit コマンドで確認できる。ulimitで指定できる値は色々あるが、スタックサイズを指定する場合は、ulimit -s だ
$ ulimit -a core file size (blocks, -c) 0 data seg size (kbytes, -d) unlimited scheduling priority (-e) 0 file size (blocks, -f) unlimited pending signals (-i) 7823 max locked memory (kbytes, -l) 64 max memory size (kbytes, -m) unlimited open files (-n) 1024 pipe size (512 bytes, -p) 8 POSIX message queues (bytes, -q) 819200 real-time priority (-r) 0 stack size (kbytes, -s) 8192 cpu time (seconds, -t) unlimited max user processes (-u) 7823 virtual memory (kbytes, -v) unlimited file locks (-x) unlimited $ ulimit -s 8192
単位はkbytes単位と書いてあって、値は 8192 なのでスタックは8MB分割り当てられる
call,ret 命令の説明に戻ろう。
call 命令はさきほど、以下のように説明した。
これは、「スタックに戻りアドレスをpushして、指定されたアドレスへジャンプする」命令だ。逆にretは
「スタックに積んである戻りアドレスをpopして、そのアドレスへジャンプする」命令だ。
挙動を細かく見ていこう。
0x00000000004009c6 in main () 1: x/4xg $rsp 0x7ffffffee388: 0x0000000000400c26 0x0000000000000000 0x7ffffffee398: 0x0000000100000000 0x00007ffffffee4c8 2: $rsp = (void *) 0x7ffffffee388 3: $rax = 0 4: x/2i $pc => 0x4009c6 <main+7>: callq 0x4009ae <func> 0x4009cb <main+12>: callq 0x4009b9 <func3> (gdb) 0x00000000004009ae in func () 1: x/4xg $rsp 0x7ffffffee380: 0x00000000004009cb 0x0000000000400c26 0x7ffffffee390: 0x0000000000000000 0x0000000100000000 2: $rsp = (void *) 0x7ffffffee380 3: $rax = 0 4: x/2i $pc => 0x4009ae <func>: add $0x1,%rax 0x4009b2 <func+4>: retq
0x4009c6 にあるcallq命令の動作として、
の、3点を確認しよう。
次に、ret 命令
0x00000000004009b2 in func () 1: x/4xg $rsp 0x7ffffffee380: 0x00000000004009cb 0x0000000000400c26 0x7ffffffee390: 0x0000000000000000 0x0000000100000000 2: $rsp = (void *) 0x7ffffffee380 3: $rax = 1 4: x/2i $pc => 0x4009b2 <func+4>: retq 0x4009b3 <func2>: callq 0x4009ae <func> (gdb) 0x00000000004009cb in main () 1: x/4xg $rsp 0x7ffffffee388: 0x0000000000400c26 0x0000000000000000 0x7ffffffee398: 0x0000000100000000 0x00007ffffffee4c8 2: $rsp = (void *) 0x7ffffffee388 3: $rax = 1 4: x/2i $pc => 0x4009cb <main+12>: callq 0x4009b9 <func3> 0x4009d0 <main+17>: retq
ret 命令の動作として、
の二点を確認しよう。
これらが確認できたら、call func3 の挙動も同じように確認して、 関数呼び出しが、func3 → func2 → func と深くなっていっても、元のmainの位置を忘れることなく正確に戻ってこれる点も確認しておいてほしい。
簡単ではあるが、x86_64 のアセンブリの読み方、書き方、実行のしかたについて説明した。
アセンブリ言語は、難しいと主張する人がいるが、仕様を完全に理解しやすいという点では、高級言語よりずっと簡単だ。
例えば、C 言語で、
int a = 5;
と書いた場合、これが何をやっているか、正確に、不足なく説明できる人はいるだろうか。
それに対して、アセンブリでは
mov $5, %rax
と書いた場合、「64bitレジスタRAXに64bit値の5を格納する」と言えば、正確に、不足なく説明できていると言えるだろう。 (まあ筆者もこの命令バイト列が実行可能ページと実行不可ページにまたがってた場合の挙動なんて知らんけど…)
アセンブリ言語は、一個づつ分解して理解していくことが可能だ。 入力が何で、どういう副作用が起こるのか、マニュアルを見れば数ページの中に正確に全て書かれている。
一個づつ確認して勉強できるのは、高級言語にはないアセンブリ言語のメリットだ。 ここで紹介した命令は、数多くある命令のうちのほんの一部だが、わからない命令が出てきたときも、ゆっくり一個づつ確認していけばいいと思う。
さて、アセンブリ言語について説明したので、せっかくなので機械語についても説明しておこう。
機械語(マシン語、machine language)は、アセンブルが終わったあとの、バイト列のことを指す。
正直な話をすると、機械語の知識が役立つ場面はあまりない。
アセンブリ言語の知識は、色々な場面で役立つ実用的で重要な知識であることは間違いないが、 それと比べると、機械語への理解は、そんなに必須ではなくて、知ってたからと言って辛い場面でサバイブしやすくなるということは特にない。
自分でアセンブラやリンカ、デバッガを作る人には重要な知識だが、OSを書く場合ですら機械語の知識が役立つ場面はほとんど無いだろう。
それでもせっかくなので解説しておこう。 正しく理解できれば、「機械語も単なるバイト列で特別なことなんか何もない」という感覚が身に付けられるはずだ。
まあよくわからなければ飛ばしてもらって構わない。リンカの説明を読む時に、知っていたほうがいい点もあるが、必須というほどでもないだろう。
ここでは、x86_64 を使う。x86_64 の機械語は、現代に生き残っている機械語の中では最も複雑と言ってよくて、 あんまり学習には向いてないのだが、筆者は身近なもので学習するのが一番だと思っているので、 ここはあえて x86_64 を使っていこうと思う。
x86_64 の機械語が理解できる頃には、他のCPUの機械語なんてクソ簡単に見えるようになっているはずだ。
https://software.intel.com/en-us/articles/intel-sdm に、Intel のマニュアルがあるので、それを開いてほしい。
ここでまず見てほしいのは、volume 2D (2018/10 時点) の Appendix A だ。
ここには、opcode table という表が書かれている。 opcode tableは、バイトと命令の対応を書いた表だ。
このふたつのテーブルは、命令バイト列の1byte目が、どの命令と対応するのかが書かれている。
オペランドを取る場合は、この先頭バイト列のあとに、オペランドを示すバイト列が続く。 Grpと書かれている部分は、後続のバイト列によって命令が決まる。
x86 の 32bit と、x86_64 では、少し対応が変わって、命令の名前の横にi64と書かれいている命令は、x86_64では使えない。(読み方の詳細はマニュアルに書かれているのでそちらを参照してほしい)
まずは、簡単なオペランドを取らない命令から見ていこう。 オペランドを取らない命令で、1byte でよく使うのは、nop、ret などがある。
.globl main main: nop ret
gcc に -c を付けてコンパイルしよう。これまで通り a.out まで作ってしまってもよいが、 a.out は libc(TODO : libcの説明を書いてない) がリンクされていて、今は必要のない命令列が追加されてしまう。
-c を付けてコンパイルすると、one_byte.o というファイルができるはずだ。objdump -d を使ってこれを逆アセンブルしよう。
$ gcc -c one_byte.s $ objdump -d one_byte.o one_byte.o: ファイル形式 elf64-x86-64 セクション .text の逆アセンブル: 0000000000000000 <main>: 0: 90 nop 1: c3 retq
nop が 0x90、ret が 0xc3 になっていて、opcode tableにもその対応が書かれていることを確認してほしい。
opcode table には、near RET(0xc3) と far RET(0xcb) が書かれているが、x86_64 のユーザプログラムでは、near RET だけ見ておけばよいのでこれの説明は省略する。 オペランド付きのRET(0xc2, 0xca) は、Linuxでは使うことはないので、これも見なくてよい。
次に即値をオペランドに取る命令だ。即値mov命令がこれに該当する。また、x86_64 では、RAX レジスタは他のレジスタより優遇されていて、算術命令のうち、rax をオペランド取る命令は、1byte命令になる。
.globl main main: mov $0x11223344, %eax mov $0x11223344, %ecx add $0x11223344, %eax sub $0x11223344, %eax ret
これもobjdump -d で見てみよう
0: b8 44 33 22 11 mov $0x11223344,%eax 5: b9 44 33 22 11 mov $0x11223344,%ecx a: 05 44 33 22 11 add $0x11223344,%eax f: 2d 44 33 22 11 sub $0x11223344,%eax 14: c3 retq
1byte 命令のあとに、32bit即値が続くのが確認できる。
ここでは、64bit レジスタではなく、32bit レジスタを使っている。
x86_64 では、多くの命令は、8bit命令と32bit命令の二種類の命令が用意されている。 16bit命令や64bit命令をを使う場合は、32bit 命令の前にプレフィクスを付ける。
16bit 命令を使うときは、命令の前に Operand size prefix(0x66) を付ける。 64bit 命令を使うときは、命令の前に REX prefix を付けて、REX prefix 中の W ビットを 1にする。REX prefix については、あとで詳しく解説する。とりあえずここで使うのは、0x48 だ。
.globl main main: mov $0x11, %al # 8bit 命令は別のオペコードが割り当てられている mov $0x1122, %ax # 16bit 命令は0x66が付く mov $0x11223344, %eax # 32bit 命令はプレフィクスが付かない mov $0x1122334455667788, %rax # 64bit 命令は、REXプレフィクスが付く。この場合は0x48 ret
0: b0 11 mov $0x11,%al 2: 66 b8 22 11 mov $0x1122,%ax 6: b8 44 33 22 11 mov $0x11223344,%eax b: 48 b8 88 77 66 55 44 movabs $0x1122334455667788,%rax 12: 33 22 11 15: c3 retq
8bit命令だけが、オペコードが 0xb0 になっていて、その他はオペコードは0xb8、16bitおよび64bit命令にはプレフィクスが付いていることを確認しよう
(ここまで書いた )
x86系命令セットの特徴として、多くの命令のオペランドにメモリを直接指定できるという点がある。
説明を簡単にするために、ここまでは使ってこなかったが、add命令などは、レジスタを経由しなくともメモリ上の値を直接操作することができる。
x86_64は、32bit x86命令セットとそれなりの互換性を持たせながら、64bitに拡張し、レジスタ数も増やすという無茶なノルマを達成するために、 REX プレフィクスというプレフィクスが増えた。
書くと長くなるので各自で自習してください
ついに、リンカの説明をするときが来た。
ここに至るまでに、何度「リンカのところで説明する」と書いただろうか? ここまで読んできた人ならば、 リンカというものが、なにやら色々やっているんだな、というのはわかってきたのではないかと思う。
筆者が常々思っていることのひとつに、「C言語に関する書籍は、リンカの説明をおざなりにしすぎだ」というのがある。
多くのC言語の書籍は、
と、いう解説がなされがちである。この説明を見たら、多くの人が、「え、リンクってなんですか?」と、思うに違いない。
アセンブラには、「人間が読めるニーモニックを、機械が読める機械語に変換する」みたいな、最低限の説明が付くものの、 リンカの説明は「リンクをします」のひとことだけである!
ここでは、いつも雑な説明をされがちな、リンカについて説明をしていきたいと思う。
C言語の言語仕様には、明示的にリンクについて書かれてはいないものの、 extern 指定子など、言語仕様の一部に、リンクの処理を無視して説明できない仕様を含んでいるのは間違いない。 リンクについて知れば、C言語への理解も、もう一歩深まるだろう。
低レベルプログラミングを習得するためにLinux デバイスドライバについて学習するのは良い方法かもしれない。
普段は、様々な問題からプログラマを守ってくれるOSではあるが、 道を外したプログラミングをする場合、このOSの保護が邪魔になる場合がある。
OSの保護を回避する手段として、「OSなし(ベアメタル)プログラミング」という世界がある。 これは、非常に楽しいプログラミングではあるが、場合によってはprintfで数値を画面に出力したり、mallocでメモリを確保するだけでも かなりの苦労を伴う手法である。
そこで、別の方法として、Linuxデバイスドライバを書くという方法もある。
Linuxデバイスドライバまわりの開発環境は、非常によく整備されていて、 printf ぐらい気軽に文字列を出力できるし、mallocぐらい気軽にメモリを割り当てられるし、 プログラムにミスがあってエラーが出れば、エラーが発生した箇所を教えてくれる機能が付いている。 「Linuxデバイスドライバ」という、優れた書籍があるのも嬉しい点だ。
また、Linuxデバイスドライバについて深く学ぶと、演習で使うようなtoy OSにはない、 現実世界で広く使われるOSに必要なものを肌で感じられるようになるというメリットもある (例えば、toyOSではCPUの速度に迫るような高速な周辺デバイスのことは考えられていないなど)。 あと現実的な話をしてしまうと、Linuxドライバを書いてほしい/修正してほしいという仕事は世の中にたくさんあり、 Linuxドライバが書けるようになっていると、職にあぶれないという点も見逃せない。
この章では、本文書の説明で使える程度の範囲内で、Linuxデバイスドライバの書きかたについて説明していく。 より詳しい使いかたに興味がある人は、書籍や、Linuxのソースコードを参照してほしい。
低レベルプログラミングに興味がある人なら、OS自作や、ベアメタルプログラミングに手を出したことがある人も多いのではないだろうか。
しかし、現在のPCのマザーボードには、昔のOSに匹敵するような巨大なソフトウェアが搭載されているのが普通で、 もっと小さなハードウェアで実行されるベアメタルプログラムと比較すると、まだ厚いレイヤーの上で動くソフトウェアしか作れないという問題がある。 PCベアメタルプログラミングに手を出したことがある人も、BIOSコールを使って、HDDにアクセスしたり、 UEFIサービスを使って、ファイルシステムにアクセスするとき、「これは何か求めていたものとは違うのではないか?」と思いながらプログラムを書いていたはずだ。
ここでは、PCよりもレイヤーの薄いZyboを使って、ベアメタルプログラミングにチャレンジしていこうと思う。 (ちなみにRaspberry Piも、大きめのファームウェアを持っていて、ARMがブートするのは、色々な初期化が終わったあとだ)
かつて、コンピュータというのは、高速なCPUと、中速なメモリ、低速なI/O から構成されていた。
しかし、近年は、CPUの速度向上がかなり停滞してきているのに対し、 周辺I/Oデバイスの速度向上は止まるどころか、高速なデバイスが次々に投入され、 その性能は、CPUに迫るか、それを大きく上まわるものも登場してきた。
遅いと言われていたストレージは、家庭用の物でもusec単位で処理が実行されており、 100GbpsイーサやInfinibandの帯域はCPUのmemsetに迫るぐらいになり、 GPUの演算スループット性能およびメモリ帯域はCPUの何倍も大きくなってきている。
ここでは、現代の高速デバイスが、どのように接続され、ユーザからどのように使うのかといった点について解説していきたいと思う。