アセンブリコードを読んでみた

WRITER: admin
2021/07/02

 

はじめに


新卒で入社して気が付けば3年目に突入していたUnityクライアントエンジニアの大川です。

 

4か月ほど前の話ですが、上司からとあるシートを共有されました。

自分が今プログラマーとしてどのくらいのレベルがあるか知るための指標になるようです。

読み進めていくといろいろ自分に足りない知識が多いと痛感させられました。

 

その中で、「アセンブリコードを読むことが出来る」という文章がありまして、

今まで読むことも使うこともないとスルーしてきた分野であったため、この機会に少し勉強してみようと思いました。

 

業務でもプライベートでも使用している64bit IntelCpuのアセンブリコードをC言語から出力して、実際に解読して行きたいと思います。

 

本記事が、アセンブリを知るきっかけになっていただけると幸いです。

 

アセンブリコードとは


実行ファイルは機械語の2進数の羅列で、人間が読んでも良くわかりません。

アセンブリは二進数の命令を人間が読める文字と記号で表現されているため、

アセンブリを書いて機械語に変換をかければCPUに直接命令を出すことが出来ます。

しかし、メモリ・ポインター・アドレスの概念をC言語以上に意識しなければならないため、

実現したいロジック以外に考えることが多くてロジックに集中しにくく、生産性が悪くなります。

そのため、C/C++言語でロジックを考えてそこから先はコンパイラに任せるのが一般的です。

 

命令はCPUによって異なるので、アセンブリコードもCPUによって変わります。

 

実行・確認環境


DockerでUbuntuのコンテナを建てて、コードをコンパイルして確認していきます。

こちらがアセンブリコードを読むために作成したリポジトリです。

Dockerの実行方法に関しては、リポジトリのREADMEをご参照ください。

 

workディレクトリ配下に、検証するカテゴリでディレクトリを分けて、

その中にMakefile・Cコード・アセンブリコード・実行ファイル・README(メモ用)が入っています。

 

Makefileで二つのタスクを用意しています。

 

こちらでCコードからアセンブリコードと実行ファイルを生成します。

 

こちらはアセンブリコードから実行ファイルを生成して、

アセンブリコードを直接編集して動作を確認するために使用します。

 

レジスタ


アセンブリを読み進めていく上で、一時的に値を保存するためのレジスタが頻繁に登場します。

レジスタには色々種類や役割がありまして、こちらに分かりやすくまとめられていたので、

参照しつつ本記事を読み進めていただければと思います。

 

Hello World


まずは基本のHelloWorldです。

 

こちらをアセンブリコードにするとこちらになります。

 

今回注目する箇所はこちらです。

 

まずわかることは、実行ファイルの中に”HelloWorld”が直接入るようです。

 

leaq

指定されたアドレスから64bitを指定されたレジスタに読み込む命令で、

“HelloWorld”の先頭アドレスをrdiレジスタに格納しているように見えます。

leaはLeadでqは64bitを示しています。

 

.LC0(%rip)

.LC0はラベルといってそれ以下のデータの場所を表すアドレスです。

ripレジスタは実行場所のアドレスを保持するもので、インストラクションポインタと呼ばれています。

インストラクションポインタからどのくらい後ろのアドレスに”HelloWorld”が格納されているかを表しています。

 

そしてそのあとに

call puts@PLT

で、文字出力する処理が書かれているアドレスに実行場所をジャンプさせています。

(putsの中身には触れないことにしましょう)

 

関数の第一引数はrdiレジスタに格納するというルールがありまして、

ジャンプした先でもrdiレジスタを見て文字を出力します。

 

関数について


関数がアセンブラではどのように扱われているか見ていきます

 

引数


まずは引数です。

こちらのシンプルな関数のアセンブリコードを見たいと思います。

 

sum2の中身がこちらで

 

呼び出しはこちらになっています

呼び出し元を見ると、第一引数はedi、第二引数はesiに格納するみたいです。

 

関数の中では、

 

一度メモリに格納して、メモリから再度読み込んで値を利用しています。

(rbpレジスタについては、後ほど触れます)

 

movl %edi, -4(%rbp)

movlは4バイトの値をコピーする命令で

rbpレジスタが指すアドレスの4バイト後ろから4バイトの領域にediの中身をコピーするという意味になります。

 

関数内でさらに関数を呼び出す場合、

引数を渡すために、edi,esiを利用するのでデータが失われてしまうためメモリに格納しています。

 

そして最後に

addl %edx, %eax

addlは4バイトの値を加算する命令で、eaxに対してedxの値を加算しています。

プログラムの式で表すと、こちらと同じ意味になります。

eax += edx

 

また、eaxレジスタには戻り値を格納するというルールがあります。

 

スタック


関数で使用される、スタック領域について詳しく見ていきましょう。

 

先ほどのsum2関数を再度例にあげます。

 

スタックを操作するために2つのレジスタが登場します

rbp ベースポインタ

rsp スタックポインタ

 

関数の先頭の

は、

pushqでベースポインタの中身8バイトをスタックポインタに積んで、

スタックポインタのアドレスが8バイト移動し、

movqでスタックポインタの中身をベースポインタにコピーしています。

 

図示化すると以下のようになります。

領域の確保は、スタックという言葉とは逆で下方向に確保するので、図のような書き方になっています。

 

メモリとレジスタは別々なので、

rspをrbpにコピーした後は、メモリ内のrbpは古い値になりますね。

 

しかし、8バイトの容量しか確保していないので、これを超える場合はどうなるのでしょうか?

 

こちらの場合を見てみましょう。

 

 

sum2の中に

subq $16, %rsp

でスタックポインタの示す場所をを16バイト下げていますね。

 

図示するとこちらになります。

 

スタックポインタの位置を変更することで、次に容量を確保するときに使用した領域と被らないようにしています。

 

ret命令で、スタックポインタとベースポインタを一つ前の位置を指すようにしていると読み取れます。

図を見ていただけると分かりますが、過去のベースポインタのアドレスはメモリ上に保存されているので、

それをスタックポインタとベースポインタにコピーする処理がretで行われていると思います。

 

引数が多い時


レジスタの数には限りがあるので、引数の数にも限界があるように見えてきますが、どのようにして対応しているのか見てみましょう。

 

 

 

こちらを確認すると、

7個目の引数からレジスタにコピーすることをやめて、スタックに直接積んでますね。

 

movl 16(%rbp), %eax

そしてアクセスするときはベースポインタの上を見るようになってます。

 

図示して流れを整理してみました。

 

図示してみると、ベースポインタより下のアドレスは上方向、

ベースポインタより上のアドレスは下方向に領域を見るようになっているようです。

 

条件分岐


プログラムの基本の条件分岐を見てみます。

見てみるコードがこちらです。

 

 

cmpl $1, -4(%rbp)

こちらで

if (i == 1)

の比較を行っています。

比較結果はフラグレジスタに保存されて次の行でフラグレジスタを見てジャンプするかどうかを決めているようです。

 

jne .L2

こちらの命令はおそらく、Jump If Not Equalの略で

ジャンプ先のL2で減算処理をしていることから、コードのelse文に飛んでいることが分かります。

 

条件を満たしている場合は、ジャンプせず次の行の

addl $1, -4(%rbp) で加算処理を行った後、

jmp .L3でL3にジャンプしてMain関数のリターン処理を行っています。

 

ここから条件を変えていろいろ見ていきます。

 

条件を i !=1に変えてみると、

je .L2

に変わりました。

Jump If Equalですね。

 

i >= 1

cmpl $0, -8(%rbp)

jle .L2

Jump If Less Equal

 

i <= 1

cmpl $1, -8(%rbp)

jg .L2

Jump If Greater

 

イコールを外して、i > 1, i < 1にして、

アセンブリコードを確認してみたところジャンプ命令は同じでした。

 

i > 1
cmpl $1, -8(%rbp)
jle.L2

 

i < 1
cmpl $0, -8(%rbp)
jg.L2

 

jge = Jump If Greater Equaljl = Jump If Less 命令はあるのか気になったので、

出力されたアセンブリを上記の命令に変更して実行ファイルを出力してみたところ、

正常に動作したので2つの命令も存在しているようです。

 

次に論理積を見ていきましょう。

 

&&は、一つ目の条件を満たさなかった場合、次の比較は行わないので、アセンブリもそのままですね。

 

&に変えてみるとどうなるでしょう?

 

 

やっていることが結構変わりました、

 

cmpl $1, -8(%rbp)

こちらで、i == 1を比較し、

sete %dl

sete = Set Equal 比較した結果がtrueである場合、指定されたレジスタに値1をセットして、falseの場合は0をセットする命令です。

 

そのあとも同様の処理をして、

i == 1の結果が dl

j == 1の結果が al

に格納されるので、

dl = 00000001

al = 00000000

になりますね。

 

dlはedxの下1byteの領域、alはeaxの下1byteの領域で、

参考

 

andl %edx, %eax

こちらでedxとeaxの&演算を行い、

 

testb %al, %al

test命令は、2オペランドの論理積を行った結果で、フラグレジスタにフラグを立てます。

今回のプログラムの場合は0になりますね。

 

そして次の命令

je .L2

で等しい場合はL2にジャンプします。

 

L2は減算処理なので、elseの部分にあたります。

 

i == 1 & j == 1

の結果は0で、elseにジャンプするのですが、

アセンブリは等しいとelseにジャンプするという意味になっています。

つまり、cpuは0=equal, 1=not equalで扱っているように見えます。(紛らわしいですね)

 

論理和の場合、条件が反転してor演算に変わるだけで、動きは同じでした。

 

ループ


次にループ処理ですが、条件分岐とジャンプで実現されています。

なので、基本は条件分岐と変わりません。

 

Cコード

 

アセンブリコード

 

まとめ


基本的な処理のアセンブリコードを解読してみました。

アセンブリコードを読むことで、プログラミング言語のその先の処理が見えて面白いなと思いました。

今回はC言語でしたが、C++のclassはアセンブリコードでどのように表現されているのか見てみるのも新しい発見がありそうです。