agenda
- self-introduction
- 実行環境
- Hello,world
- コンパイル時にオプションをつけてみる
- objdumpを使ってみる
- 見てみよう
- レジスタ
- 命令
- 読んでみよう
- あとがき
self-introduction
IPFactory所属の1年生 konvatです。低めのレイヤに興味があります
実行環境
ThinkPad X1 Carbon (7th gen)
CPU: Intel Core i7-10710U
RAM: 16GB
OS: Ubuntu 19.10 64bit
GCC: gcc (Ubuntu 9.2.1-9ubuntu2) 9.2.1 20191008
実行環境により実行結果は異なります。
Hello,world
まずはなんの変哲もないHello,worldを出力するプログラムを書きます
# include <stdio.h>
int main(void)
{
printf("Hello,world\n");
return 0;
}
さて、これをコンパイルして実行すれば普通に動くのですが、ちょっと寄り道をしましょう。
コンパイル時にオプションをつけてみる
gccにオプションをつけます。
gcc hello.c -o hello -Wall -Wextra -g -O0 -static
オプションについては、ここのサイトが参考になると思います。詳しくはそちらを参照してください。
軽く説明すると、-oは実行ファイルに名前をつけるオプションです。
今回はhelloという名前にしていますが、-oオプションで名前を指定しないと、
a.outという名前で実行ファイルが生成されます。
-Wall、-Wextraは、コンパイル時に各種警告を出すようにするオプションです。
-Wオプションで指定できる警告の種類はたくさんあります(数えたところ320個ほど)。
見てみたい方はgcc --help=warningsで見ることができます。
今回は、-Wallと-Wextraを併用することによってほとんどの警告を表示するようにします。
-gはgdb等のデバッガを使うときに役に立つ情報をバイナリに残しておくオプションです。
-Oは最適化をするオプションです。-Oxのxの部分に数字を入れて最適化の度合いを指定します。
今回は-O0というオプションをつけましたが、
これは指定できる最適化の度合いの中で一番低い最適化の度合いにしておくというオプションです。
-staticは静的にリンクするオプションです。今回はこのあと読むものが読みやすくなるようつけました。
objdumpを使ってみる
さて、先程のコマンドを実行するとコンパイルができて実行ファイルが生成できたはずです。
今回はこの中身のmain関数を見てみることによって内部的にどんなことをしているのかというのを
少し見てみたいと思います。
objdump -S hello | less
objdumpコマンドは、オブジェクトファイルの内容を表示することのできるコマンドで、
実行ファイルを調査したりするのに便利なコマンドです。
今回は-Sオプションと-Mオプションをつけました。
-Sオプションは、ソースコードを含めて逆アセンブル情報を表示するオプションです。
オブジェクトファイルとは、軽く説明するとコンパイラがソースコードをバイナリに変換したものです。
今回は、実行ファイルを読むのでコンパイルの後のリンクという処理も終わった状態のものです。
このあたりについては、Cのプログラムがどういう仕組みで実行されるかについて
最低でもふわっとした理解が必要です。このサイトとかが参考になると思います。
-Mオプションでこの後見られるアセンブリをintel記法で記述することを指定します。見やすいですし。
また、|(パイプ)で繋いだlessコマンドによって、ファイル内の文字列を検索します。
ファイルが開けた後、
/<main>
と検索すると、main関数の部分を見ることができます。
見てみよう
さて、先程までの操作で実行ファイルを逆アセンブルした情報が見られてmain関数の部分が検索できました。
すると、main関数の部分の前後は省略しますがこんな感じになりました。
0000000000401d35 <main>:
# include <stdio.h>
int main(void)
{
401d35: f3 0f 1e fa endbr64
401d39: 55 push rbp
401d3a: 48 89 e5 mov rbp,rsp
printf("hello,world\n");
401d3d: 48 8d 3d c0 22 09 00 lea rdi,[rip+0x922c0] # 494004 <_IO_stdin_used+0x4>
401d44: e8 b7 fc 00 00 call 411a00 <_IO_puts>
return 0;
401d49: b8 00 00 00 00 mov eax,0x0
}
401d4e: 5d pop rbp
401d4f: c3 ret
うーん、16進数とアセンブリだ!今回は、アセンブリの部分を読んでいきたいと思います。
実行環境が64bitプロセッサ、64bitOSなのでこのアセンブラはx64です。
ここで使うレジスタと命令について、これまた軽く説明しておきます。
レジスタ
eax
アキュムレータレジスタ。昔は名前の通り算術演算用のレジスタだったが、
現在は他の汎用レジスタと差はない。
rsp
スタックポインタレジスタ。スタックの先頭アドレスを保持する。
rbp
ベースポインタレジスタ。現在のスタックフレームのアドレスが格納されている。参考
rdi
ディスティネーションインデックスレジスタ。もともとメモリ位置を示すためのレジスタだったが、
今ではstring型の命令においてのコピー先(ディスティネーション)を示すための専用レジスタ。
その他の場合は汎用レジスタとして使用できる。
rip
インストラクションポインタ。
プログラムカウンタ(PC)とも呼ばれる。次の命令のアドレスが格納されている。
命令
以下、opをオペランド、op1を第1オペランド、op2を第2オペランドとする。
push
push op ;rspをデクリメントし、rspのメモリアドレスにopの値(内容)を書き込む
pop
pop op ;rspのメモリアドレスから値を読み出し、opに格納した後、rspをインクリメントする
mov
mov op1, op2 ;op2の内容をop1にコピー
lea
lea op1, [op2] ;op2に格納されている、アドレスの値そのものをop1に格納する
call
call op ;rspをデクリメントし、rspのメモリアドレスにripの値を書き込んだ後、ripに呼び出し先のアドレス(op)を設定する
ret
ret ;rspのメモリアドレスから値を読み出し、それをripに設定した後、rspをインクリメントする
読んでみよう
一通り必要な説明が終わりましたので早速読んでいきましょう。
関数を呼び出して最初にやる処理
int main(void)
{
401d35: f3 0f 1e fa endbr64
401d39: 55 push rbp
401d3a: 48 89 e5 mov rbp,rsp
最初の1行、endbr64も命令で、end branch 64bitの略。詳しくはここ
次の2行は、関数が実行されるとき最初に実行される処理で、rbpはスタックの底のアドレス、
rspはスタックの一番上のアドレスを指しています。
push rbpでrbpの内容、つまりスタックの底のアドレスですから、現在のアドレスであり、
main(void)の呼び出し元のアドレスをバックアップすることによって、
main(void)が終了したときにmain(void)の呼び出し元の処理を再開するときに使えるようにします。
これが終わったら、mov rbp, rspでrspの値をrbpに書き込みます。
これによって最新のスタックのアドレスが、
main(void)のスタックフレームのベースアドレスとして使われるようになります。
printf()の実行
printf("hello,world\n");
401d3d: 48 8d 3d c0 22 09 00 lea rdi,[rip+0x922c0] # 494004 <_IO_stdin_used+0x4>
401d44: e8 b7 fc 00 00 call 411a00 <_IO_puts>
さて次。lea rdi, [rip+0x922c0]で、
ripに格納されているアドレスに0x922c0を加算したアドレスをrdiに書き込んでいます。
rip+0x922c0というアドレスは、色々調べてみると、どうやら"Hello,world"、
つまりHello,worldという文字列の先頭アドレスらしい。
rdiを使用して、この後使われる関数に渡しているようです。
なるほど、だからrdiを使うのでしょうか?
さあいよいよprintf()の実行です。
call 411a00 <_IO_puts>で411a00番地にジャンプし、関数を呼び出します。
411a00番地って何だ...と思いこれもまた色々調べたところ、どうやらglibc/libio/input.cの中の
_IO_puts(const char *str)という関数の先頭アドレスのようです。
ということは、call 411a00はglibc/libio/input.cの
_IO_puts(const char *str)を呼び出す処理ということですね?
なるほど、call 411a00 <_IO_puts>の<_IO_puts>ってこういう意味があったのか〜!?
なんで余計なものがついているんだろうと思っていましたが、やっぱりちゃんと意味はあったようです。
結局、内部的に実行されていたのはprintf()ではなく、_IO_puts(const char *str)でした。
これはgccの最適化によるもののようです。
もっと詳しく掘り下げてしまうと長くなってしまうので、
また今度別記事でglibc/libio/input.cを読んでみたいと思います。(時間かかると思いますが)
return 0;
return 0;
401d49: b8 00 00 00 00 mov eax,0x0
}
長くなってしまいましたが、次はreturn 0;の処理です。
mov eax, 0x0で、eaxに0x0、つまり0を書き込みます。
つまり、return 0;の0ですね。eaxは戻り値の格納に使われるレジスタなので、
文字通りreturn 0;のための処理ですね。
関数の終了
401d4e: 5d pop rbp
401d4f: c3 ret
さて、最後の処理です。
pop rbpでrbpにpush rbpの処理でバックアップしておいた
main(void)の呼び出し元のアドレスを元のように格納し、呼び出し元に戻れるよう準備します。
そして、retでmain(void)の呼び出し元に戻り、次の処理を実行します。
あとがき
ということで、Hello,worldを出力するだけのプログラムのmain(void)の部分だけですが
アセンブリを読んで、何をしているのかちょっとだけ詳しく読んでみました。
間違い等あればご指摘いただけるとありがたいです。最後まで読んで頂き、ありがとうございました。