アセンブラーを使用してCをより深く理解する

この記事はインスピレーションとして役立ちました: アセンブラーを勉強しいるC理解しています。 トピックは興味深いものの、継続はうまくいきませんでした。 多くの人がコードを書き、それがどのように機能するかを理解したいと思っています。 そのため、コードの基本構造の分析とともに、Cコードが逆コンパイル後にどのように見えるかに関する一連の記事を開始します。



読者には、少なくとも以下の基本的な知識が必要です。





しかし、もしあなたがそれらを持っていなくて、あなたがそのトピックに興味があるなら、記事を読む過程でこれらすべてを素早くグーグルにすることができます。 この記事は初心者を対象としたものではありませんが、初心者が何かを始めることができるように、私は多くの簡単なことを注意深く噛みました。



何を使用しますか?



  1. 最新の標準をサポートするCコンパイラが必要です。 ideone.comのオンラインコンパイラを使用できます。
  2. また、逆コンパイラも必要です。再び、 godbolt.orgのオンライン逆コンパイラを使用できます。
  3. また、アセンブラー用のコンパイラーを使用することもできます。これは上記のリンクから入手できます。


なぜすべてがオンラインになっているのですか? 異なるバージョンやオペレーティングシステムに起因する紛争を解決するのに便利だからです。 多くのコンパイラーがあり、十分な逆コンパイラーもあります。議論の中でそれぞれの機能を考慮したくありません。



学習へのより徹底的なアプローチでは、コンパイラのオフラインバージョンを使用することをお勧めします。現在のgcc、OlyDbg、およびNASMの束を取得できます。 違いは最小限でなければなりません。



最も単純なプログラム



この記事は、冒頭で引用したものを繰り返すことを目的としていません。 ただし、最初から始める必要があるため、マテリアルの一部は強制的に交差します。 理解を願っています。



最初に学ぶべきことは、コンパイラーは、ゼロレベル(-O0)を最適化するときでさえ、プログラマーによって書かれたコードを削減できることです。 したがって、コードは次のとおりです。



int main(void) { 5 + 3; return 0; }
      
      





以下と変わりません:



 int main(void) { return 0; }
      
      





したがって、逆コンパイル時にコードが意味のあるものに変換されるのを確認できるように記述する必要があるため、例は少なくとも奇妙に見えるかもしれません。



次に、コンパイルフラグが必要です。 -O0-m32の 2つで十分です。 これにより、ゼロ最適化レベルと32ビットモードが設定されます。 最適化を行うと、明らかなはずです。コードの解釈をasmで見たくはありませんが、最適化されていません。 モードでは、レジスターが少ない-エッセンスへの注目度が高いことも明らかです。 私は定期的にこれらのフラグを変更して、素材をより深く掘り下げていきます。



したがって、gccを使用する場合、コンパイルは次のようになります。



gcc source.c -O0 -m32 -o source



したがって、godboltを使用する場合は、コンパイラーの選択の横にある入力行でこれらのフラグを指定する必要があります。 (私がgcc 4.4.7でデモンストレーションする最初の例は、後で変更します)



これで、最初の例を見ることができます:



 int main(void) { register int a = 1; //    1 return a; //     }
      
      





したがって、次のコードはこれに一致します。



 push ebp mov ebp, esp push ebx mov ebx, 1 mov eax, ebx pop ebx pop ebp ret
      
      





最初の2行は関数のプロローグ(より正確には3行ですが、3行目を説明します)に対応しており、関数の記事でそれらを分析します。 今は注意しないでください。最後の3行にも同じことが当てはまります。 asmがわからない場合は、これらのコマンドの意味を見てみましょう。



アセンブラーの指示は次のとおりです。



ニーモニックdst src

つまり



指示受信者、ソース



ここで、AT&T構文の順序が異なることを予約する必要があります。その後、それに戻りますが、NASMに似た構文に興味があります。



mov命令から始めましょう。 この命令は、メモリからレジスタ、またはレジスタからメモリに移動します。 この例では、番号1をebxレジスタに移動します。



レジスターを簡単に見てみましょう。x86アーキテクチャーには、8つの32ビット汎用レジスターがあります。つまり、これらのレジスターは、プログラマー(この場合はコンパイラー)がプログラムを作成するときに使用できます。 コンパイラは、特別な場合にebp、esp、esi、およびediレジスタを使用しますが、これについては後で検討し、コンパイラは他のすべてのニーズにeax、ebx、ecx、およびedxレジスタを使用します。



したがって、 mov ebx、1は、 レジスタregister int a = 1に直接対応します。



そして、値1がebxレジスタに移動されたことを意味します。



mov eax ebxは、ebxレジスタからの値がeaxレジスタに移動されることを意味します。



push ebxpop ebxの 2つの行があります。 「スタック」の概念に精通している場合、コンパイラは最初にebxをスタックに配置し、それによってレジスタの古い値を記憶し、プログラムが終了した後、この値をスタックからebxレジスタに戻したことに気付きます。



コンパイラーがebxレジスターから値1をeaxに入れるのはなぜですか? これは、C関数呼び出しの規則によるものです。 いくつかのポイントがありますが、それらはすべて今私たちに興味がありません。 重要なことは、可能であれば結果がeaxで返されることです。 したがって、ユニットがeaxで終わる理由は明らかです。



しかし今、論理的な質問は、なぜebxが必要なのですか? なぜmov eaxを1すぐに書くことができなかったのですか? 最適化のレベルがすべてです。 私は言った:コンパイラは私たちのコードをカットすべきではなく、私たちはreturn 1を書かず、レジスタ変数を使用しました。 つまり、コンパイラーは最初に値をレジスターに入れてから、規則に従って結果を返しました。 最適化レベルを他のレベルに変更すると、ebxレジスタは実際には必要ないことがわかります。



ちなみに、godboltを使用する場合、Cの行にマウスを合わせると、この行が強調表示されていれば、asmのこの行に対応するコードが強調表示されます。



スタック



例を複雑にして、レジスタ変数の使用を停止しましょう(あまり使用しませんか?)。 このコードがどうなるか見てみましょう:



 int main(void) { int a = 1; //   1 int b = a + 5; //  'a' 5    'b' return b; //    }
      
      





ASM:



 push ebp mov ebp, esp sub esp, 16 mov DWORD PTR [ebp-8], 1 mov eax, DWORD PTR [ebp-8] add eax, 5 mov DWORD PTR [ebp-4], eax mov eax, DWORD PTR [ebp-4] leave ret
      
      





繰り返しますが、上の3行と下の2行をスキップします。ローカル変数ができたので、スタック上のメモリにメモリが割り当てられます。 したがって、次の魔法があります: DWORD PTR [ebp-8] 、それはどういう意味ですか? DWORD PTRはダブルワード変数です。 ワードは16ビットです。 この用語は16ビットプロセッサの時代に広まり、正確に16ビットがレジスタに配置されました。 このような大量の情報は言葉と呼ばれるようになりました。 つまり、この場合、dword(ダブルワード)2 * 16 = 32ビット= 4バイト(通常のint)です。



ebpレジスタには、現在の関数のスタックの最上部のアドレスが含まれています(後でこれに戻ります)。したがって、アドレス自体を上書きしないように4バイトシフトされ、変数の値を追加します。 この場合、変数aに対して8バイトだけシフトされます。 しかし、次のコードを見ると、変数bが4バイトのオフセットであることがわかります。 角括弧は住所を示します。 つまり、この行は次のように機能します。ebpに格納されているアドレスに基づいて、コンパイラは値1をサイズ4バイトのebp-8アドレスに配置します。 なぜプラス8ではなくマイナス8。 この関数に渡されるパラメーターはプラスに対応するため、これについては後で説明します。



次の行は、値1をeaxレジスタに移動します。 詳細な説明は必要ないと思います。



次に、追加(追加)する新しいaddステートメントがあります。 つまり、eax(1)の値に5が加算され、値6がeaxになります。



その後、値6を変数bに移動する必要があります。これは次の行で行われます(変数bはスタック上のオフセット4にあります)。



最後に、変数bの値を返す必要があるため、移動する必要があります

eaxレジスタの値( mov eax、DWORD PTR [ebp-4] )。



前のものですべてが明確であれば、より複雑なものに進むことができます。



興味深く、あまり明白ではないもの。



次のように書くとどうなりますか:int var = 2.5;



みなさんは、varには2の値があると正しく答えると思いますが、小数部はどうなりますか? それは破棄され、無視され、型変換が行われますか? 見てみましょう:



ASM:

 mov DWORD PTR [ebp-4], 2
      
      





コンパイラ自体は、小数部分を不要なものとして破棄しました。



このように書くとどうなりますか:int var = 2 + 3;



ASM:

 mov DWORD PTR [ebp-4], 5
      
      





そして、コンパイラ自体が定数を計算できることを学びます。 そしてこの場合:2と3は定数なので、それらの合計はコンパイル段階で計算できます。 したがって、そのような定数の計算に煩わされることはありません;コンパイラがあなたのために仕事をすることができます。 たとえば、時間から秒への変換は、時間* 60 * 60と書くことができます。しかし、ここでの例は、コードで宣言されている定数に操作を置くことです。



このコードを書くとどうなりますか:



 int a = 1; int b = a * 2;
      
      





 mov DWORD PTR [ebp-8], 1 mov eax, DWORD PTR [ebp-8] add eax, eax mov DWORD PTR [ebp-4], eax
      
      





面白いですね。 コンパイラーは乗算の演算を使用しないことにしましたが、2を乗算した2つの数値を単純に加算しました(これらの行については詳しく説明しません。前の資料から理解する必要があります)



オペレーション「乗算」はオペレーション「加算」よりも時間がかかると聞いたことがあるかもしれません。 これらの理由により、コンパイラはこのような単純なことを最適化します。



しかし、私たちは彼のために仕事を複雑にし、これを書きます:



 int a = 1; int b = a * 3;
      
      





ASM



 mov DWORD PTR [ebp-8], 1 mov edx, DWORD PTR [ebp-8] mov eax, edx add eax, eax add eax, edx mov DWORD PTR [ebp-4], eax
      
      





新しいedxレジスタの使用にだまされないでください; eaxやebxほど悪くはありません。 少し時間がかかるかもしれませんが、ユニットがedxレジスタに入り、次にeaxレジスタに入り、その後eax値がそれ自体で追加され、その後edxから別のユニットが既に追加されていることがわかります。 したがって、1 + 1 + 1になりました。



ご存知のように、彼はこれを際限なく行うことはありません。すでに* 4で、コンパイラーは以下を生成します。



 mov DWORD PTR [ebp-8], 1 mov eax, DWORD PTR [ebp-8] sal eax, 2 mov DWORD PTR [ebp-4], eax mov eax, 0
      
      





それで、新しいsal命令があります、それは何をしますか? これは左へのバイナリシフトです。 次のCコードと同等:



 int a = 1; int b = a << 2;
      
      





この演算子がどのように機能するか本当に理解していない人のために:



0001は、2つのゼロによって左にシフト(または右に追加)されます:0100(つまり、10番目の数値システムでは4)。 その中心では、左への2桁のシフトは4の乗算です。



5を掛けると、コンパイラが1つのsalと1つのaddを作成し、異なる数値を自分でテストできることは面白いです。



22時にgodbolt.orgのコンパイラーは降伏して乗算を使用しますが、この数までさまざまな方法で抜け出そうとしています。 減算でも、まだ説明していないいくつかの命令を使用します。



さて、それらは花であり、次のコードについてどう思いますか:



 int a = 2; int b = a / 2;
      
      





あなたが減算を期待するなら、悲しいかな、いいえ。 コンパイラは、より洗練されたメソッドを生成します。 演算「除算」は乗算よりもさらに遅いため、コンパイラーもねじれます。



 mov DWORD PTR [ebp-4], 2 mov eax, DWORD PTR [ebp-4] mov edx, eax shr edx, 31 add eax, edx sar eax mov DWORD PTR [ebp-8], eax
      
      





このコードでは、gcc 4.4.7を例としてあげる前に、かなり新しいバージョン(gcc 7.2)のコンパイラーを選択したと言わなければなりません。 初期の例には大きな違いはありませんでした;この例では、5行目のコードで異なる命令を使用しています。 そして、7.2によって生成された例は、私にとって説明しやすくなりました。



変数aが8ではなくオフセット4でスタック上にあり、このわずかな違いをすぐに忘れることに注意してください。 キーポイントはmov edx、eaxで始まります。 ただし、現時点では、この行の値をスキップします。 shr命令は、右へのバイナリシフトを実行します(つまり、 shr edxがあった場合は2で除算します、1 )。 そして、ここで、一部の人が考えることができるでしょう、なぜ実際にshr edx、1を書いてはいけないのですか? しかし、それほど単純ではありません。



少し最適化して、それが何に影響するかを見てみましょう。 実際、コードで整数除算を行います。 変数「a」は整数型であり、2はint型の定数であるため、Cロジックに従って結果が小数になることはありません。 整数の除算はより高速で簡単なので、これは良いことですが、符号付きの数値があります。つまり、shr命令で除算したときの負の数値は正解とは異なる場合があります。 (これはすべて、符号付きタイプの範囲の中央で0がブレークするという事実によるものです)。 署名された部門を署名なしに置き換えた場合:



 unsigned int a = 2; unsigned int b = a / 2;
      
      





その後、期待どおりになります。 godboltはshr命令でユニットを省略し、これはNASMでコンパイルされないことを考慮する価値がありますが、それはそこで暗示されています。 2を4に変更すると、2番目のオペランドが2として表示されます。



前のコードを見てください。 sar eaxを見ると、これはshrと同じですが、符号付きの数値についてのみです。 残りのコードでは、負の数を除算するとき(またはコードはわずかに変更されますが、負の数で除算するとき)にこの単位を単純に考慮します。 コンピューター上で負の数値がどのように表されるかを知っていれば、なぜ31ビットずつ右にシフトしてこの値を元の数値に加算するのかを推測するのは難しくありません。



大きい数で割ると、さらに簡単です。 そこでは、除算が乗算に置き換えられ、定数が第2オペランドとして計算されます。 あなたがその方法に興味があるなら、あなたは自分で頭を打ち破ることができます、複雑なことは何もありません。 メモリで実数がどのように表現されるかを理解する必要があります。



おわりに



最初の記事では、すでに十分な資料があります。 それは切り上げて在庫を取る時間です。 アセンブラの基本的な構文に精通し、コンパイラが計算で最も単純な最適化を引き受けることができることがわかりました。 レジスタ変数とスタック変数の違いを見ました。 他にもいくつかあります。 これは入門記事であり、明らかなことに多くの時間を費やさなければなりませんでしたが、それらは誰にとっても明白ではありません。将来、C言語のより微妙な点を理解するでしょう。



パート2



All Articles