LoginSignup
9
1

More than 3 years have passed since last update.

Hello,worldをちょっとだけよく見てみる

Last updated at Posted at 2019-12-10

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を出力するプログラムを書きます

hello.c
#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は最適化をするオプションです。-Oxxの部分に数字を入れて最適化の度合いを指定します。
今回は-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, rsprspの値を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 411a00glibc/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 rbprbppush rbpの処理でバックアップしておいた
main(void)の呼び出し元のアドレスを元のように格納し、呼び出し元に戻れるよう準備します。
そして、retmain(void)の呼び出し元に戻り、次の処理を実行します。

あとがき

ということで、Hello,worldを出力するだけのプログラムのmain(void)の部分だけですが
アセンブリを読んで、何をしているのかちょっとだけ詳しく読んでみました。
間違い等あればご指摘いただけるとありがたいです。最後まで読んで頂き、ありがとうございました。

9
1
7

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
1