C言語関数が呼び出されたときのメモリ内のスタックの動的変化の詳細な分析(カラー画像)ローカル変数がスタックにどのように入れられ、スタックから出されるか

著作権に関する声明:この記事はブロガーのオリジナル記事であり、ブロガーの許可なしに複製することはできません。ようこそ私に連絡してくださいqq2488890051https://blog.csdn.net/kangkanglhb88008/article/details/89739105
まず、次の知識とプロセスを理解してください

*フォンノイマンシステムのコンピュータプログラム命令コードは、実行するために事前にハードディスクからメモリにロードされます(ハーバードアーキテクチャのコンピュータ命令コードが外部メモリで直接実行される場合は、詳細については私の記事を参照してください、コンピュータフォンヌオImanアーキテクチャとHarvardアーキテクチャの違い、およびプロセッサパフォーマンス評価標準)、これらの命令コードはプロセスのコードセグメントのメモリに格納され、同じ関数の命令コードはアドレス順に格納されます(コンパイラによって決定されます)(つまり、命令アドレス+ 1が自動的に次の命令のアドレスを取得できる限り)、関数呼び出しが発生すると、別の関数の連続アドレスコードセグメントに入力されるため、関数が呼び出されたときに、事前にスタックにプッシュする必要があります。この関数の後に命令のアドレスを保存します。

*スタックの上部と下部の定義は、アドレスの高さではなく、スタックとスタックの位置で定義されます。スタックとスタックの場所は、スタックの上部と呼ばれます(ただし、Windowsオペレーティングシステムでのスタックの増加は高いからです)アドレスから下位アドレスへ)、スタックはラストイン、ファーストアウトであり、呼び出された関数はスタックにプッシュされるため、関数が戻ると、最初に再利用されます。つまり、再生されたスペースはレイヤーごとにロールバックされます。

*スタックはスタックですが、スタックと呼ばれることもあります。ヒープはヒープです。ランダムに名前を付けないでください。メモリ内のスタックがどのようにスペースを割り当てるか、およびその違いについては、私の記事「コンピュータプログラムのストレージ割り当ての詳細な説明とC言語関数呼び出しプロセスの概要」を参照してください。

*プログラム全体がスタックを維持します(オペレーティングシステムを実行している場合、複数のプロセスが存在する可能性があり、複数の独立したスタックが存在します。たとえば、シングルチップベアメタルプログラムにはスタックが1つしかありません)。このスタックは動的に変化し、変数は割り当てと解放はすべて、スタックの最上位でのポインターの動的な移動に関するものです。解放する必要のあるスタック内の変数を保存して引き続き使用する必要がある場合は、popメソッドを使用すると、スタックはEAXなどの特定のcpuレジスタに同時に保存されます。 、このレジスタを使用して、変数値または最終関数の戻り値を一時的に保存できます。

* cpuには複数のレジスタがあり、これらは主に現在アクティブな関数の一時的な値に使用され(関数が実行されているときは、ここではアクティブ関数と呼びます)、レジスタの内容と現在のアクティブな関数を動的に変更する可能性があります相互作用か何かですが、EAX、ESP、EBP、EIPの4つについてもっと心配しています。説明は次のとおりです。

 

*アクティビティ関数は、一部のレジスタのローカル変数と値をスタックにプッシュします(レジスタのアドレスはマクロを介してebxなどとして定義されているため、レジスタのアドレスはスタックにプッシュされません。つまり、レジスタアドレスは公開されています。これについて詳細については、この記事の詳細な説明、組み込みマイクロプロセッサの構造、および電源投入からプログラムの実行開始までのプロセスの説明を参照してください)。cpu全体にはそのようなレジスタのセットしかありませんが、関数呼び出しには多くの層が含まれる可能性があるため、現在の関数呼び出しは次の機能の後、アクティブな機能が次の機能になります。このとき、このレジスタのセットの値は、最初にスタックに置かれ、つまり保存されてから、新しいアクティブな機能の操作をサポートするために使用されます。新しいアクティブな機能の場合終了後、スタックに保存されたばかりの値がこのレジスタのセットに再割り当てされるため、呼び出し前の実行状態が復元されます。

*プログラムにはスタックが1つしかありませんが、関数の階層呼び出しが存在する可能性があり、各関数はこの合計スタックに部分スタック(スタックフレームとも呼ばれます)を持ちます。

たとえば、現在main関数mainにあるスタックは、次のとおりです。

 

ここでのローカル変数はmain関数で定義されており、ebx、esi、ediが具体的に何をするのか、なぜそれらがスタックされているのか(確かにいくつかのレコード情報)、各アクティブ関数がこれら3つを配置することがわかっている限り、理解できません。レジスターの値をスタックにプッシュするだけです。EBPレジスタの値は、現在アクティブな機能スタックフレームの最下位アドレスを格納し、ESPは、現在のスタックフレームの最上位アドレスを格納します。cpuによって実行される次の命令のアドレスはEIPレジスタから直接読み取られます。EIPレジスタは毎回実行される命令のアドレスを格納するために使用されるため、各実行の前に次の命令のアドレスを手動で入力する必要があります。入ってください。つまり、後で表示される関数呼び出しの後で、スタックから事前にスタックに配置されていたアドレスをポップアウトします。(次の命令のアドレスがポップの場合、このアセンブリ命令は同時にアドレスをEIPレジスタに入力する必要があります)

 

//メイン関数で実行される命令プロセスのアセンブリコード。最初の列は命令のアドレスを表します。無関係な命令コードを削除しました
011C1540push ebp //スタックをプッシュしてebpを保存します(これはメイン関数を呼び出した関数です)スタックフレームのスタックボトムアドレス。誰がメイン関数を呼び出したかはわかりません。オペレーティングシステムである必要があります)、プッシュ操作はesp-4
011C1541 mov ebp、esp // espの値をebpに渡すことを意味することに注意してください。現在のebpを設定
011C1543sub esp、0F0h //関数のスペースを開き、範囲は(ebp、ebp-0xF0)
011C1549 push ebx
011C154A push esi
011C154B push edi
011C154C lea edi、[ebp-0F0h] // ediをebp-に設定0xF0次のいくつかの命令は
011C1552mov ecx、3Chを調べる必要はありません//関数空間内のdwordの数0xF0 >> 2 = 0x3C
011C1557 mov eax、0CCCCCCCCh
011C155C rep stos dword ptr es:[edi]
// rep命令の目的上記の命令を繰り返すことです
。ECXの値は繰り返し回数です。//STOS命令の機能は、eaxの値をES:EDIが指すアドレスにコピーし、次にEDI + 4
をコピーすることです。//print_out(0、 2)
013D155E push 2 // 2番目の実際のパラメーターがスタックに
プッシュされます013D1560push 0 //最初の実際のパラメーターがスタックにプッシュされます
013D1562 call print_out(13D10FAh)//戻りアドレスがスタック(この場合は013D1567)にプッシュされ、次にprint_out関数
013D1567 add esp、8が呼び出されます// 2つの実際のパラメーターがスタックからポップされます
// callコマンドでは、暗黙の操作がダウンすることに注意してください命令のアドレスは、いわゆるリターンアドレスであるスタックにプッシュされます。//
呼び出された関数がreturnステートメントに対して実行されると、関数を終了する準備が整います。リターンプロセスは
013D141C mov eax、1 //戻り値は
eax013D1421popに渡されます。edi
013D1422 pop esi
013D1423 pop ebx //ポップの登録
013D1424add esp、0D0h //次の3つのコマンドは、VSの__RTC_CheckEspを呼び出し、スタックオーバーフローをチェックします
013D142A cmp ebp、esp
013D142C call @ ILT + 315(__ RTC_CheckEsp)(13D1
013D1431 mov esp、ebp // ebpの値をespに
渡します。つまり、呼び出し前にespの値を復元します。013D1433popebp // pop ebp、ebpの値を復元します
。013D1434ret//戻りアドレスをEIPに書き込みます。これは、popEIPと同等です。
ここで、別の関数print_outがmain関数で呼び出され、そのスタックは次のように変更されます。

 

関数の階層呼び出しは、実際には異なるアクティブな関数のコンテンツの繰り返しスタックであることがわかります(同じ方法)。print_out関数が別の関数を呼び出す場合、別のスタックフレームを追加するのと同じです。

次に、このスタッキングのプロセスとシーケンスを分析しましょう。

main関数は他の関数からも呼び出されますが、スタックが下位アドレスに大きくなるため、ここでは追跡しません。main関数(つまり、現在アクティブな関数)の実行プロセスが最初にmainで定義されていることがわかります。ローカル変数がスタックにプッシュされ、続いて3つのレジスタの内容がプッシュされます。このとき、実行を続けて、関数prin_outが呼び出されていることを確認します。このとき、スタック内に2つの4バイトスペースが開かれます(見つかっただけなので) C言語での2つの変数の宣言である2つのint型の正式なパラメーター)と同時に、これらの2つのスペースを0と2で埋めます。これにより、関数パラメーターの宣言と初期化が完了します(まだメイン関数スタックフレームでは、呼び出された関数の正式なパラメーターの宣言と割り当てが、呼び出された関数自体によって割り当てられたスペースではなく、呼び出し元の関数で行われていることがわかります。これは、上記の実際のパラメーターです。 1、2はスタックに存在し、print_out関数に入る前に、main関数はprint_out関数の次の命令アドレス(つまり、上の図の戻りアドレス)をスタックに保存する必要があります(このプロセスはprint_outアセンブリを呼び出します)。命令は自動的に完了します。実際、次の命令のアドレスは、割り当てられた2つの実際のパラメーター、つまり命令add esp、8が占めるスペースを再利用する操作です。急いでいません。後で詳しく分析します。これは、print_out関数が終了した後、main関数が続行する方法を知っているためです。(質問点:このprint_out関数の次の命令のアドレスをprint_out関数にして、実行がほぼ完了したときにそれをmain関数に伝えることはできませんか?もちろん、print_out関数自体が次の命令が誰であるかを知らないため、異なる可能性があります。関数呼び出しは、外部関数(呼び出し元)をまったく知りません)。リターンアドレスもスタックにプッシュされると、print_out関数に入ることができます。

                     

print_out関数に入った後は、main関数に入ったときと同じ方法で、最初に、呼び出し元(main関数)のスタック最下位アドレスがスタックにプッシュされます。

メイン関数のスタックフレームの一番下のアドレス、つまり図の赤い矢印が指すメモリユニットのアドレスは、スタック内のebp(main)の値です(目的は、print_out関数の呼び出しが完了した後、メイン関数が再びアクティブな関数になることです。mainスタックフレームが現在のスタックフレームになります。EBPが正しい位置、つまり赤い矢印をすばやく指すことができるように、EBPレジスタのアドレス値を入力します。このとき、ESPはもちろんメイン機能のスタックフレームであるediの位置を指す必要があります。スタックの一番上の位置は現在です。このように見ると、print_out関数が呼び出されなかったときのスタックの外観に復元されます。これは上の右の図で、完璧なので、完璧です)、print_out関数に入ることができます。

次に、現在アクティブな関数(print_out関数)のローカル変数に必要な合計スペースを割り当てます(ebx、esi、およびediの3つのレジスタの値もスタックにプッシュされるため(20バイトである必要があります)、ここでの8の割り当ては必ずしも正確ではありません) 、ただし、簡単にするために、それほど厳密ではありませんが、原則は正しいです)次に、スタックローカル変数ebx、esi、ediの3つのレジスタ値をプッシュし、returnステートメントに遭遇したら、対応する操作プロセスを実行します。このとき、print_out関数は実行が終了しようとしていることを認識しているため、この関数のスタックフレームの回復を開始し、戻り値をEXAレジスタに保存するだけです(戻り値があり、戻り値がない場合は、関数voidタイプの場合、戻り値をEXAレジスタに保存する必要はありません)、ローカル変数とebx、esi、およびediの3つのレジスタの値は無意味な値であるため、それらを破棄する、つまりespレジスタを配置するだけです内容はebpレジスタのアドレス値に直接割り当てられます。つまり、espとebpは同じメモリユニットを指します。このとき、スタックの最上位がebp(main)になり、次の図に示すように、スタックメモリの回復を実現します。対応するアセンブリコードはmovesp、ebp、

 

このとき、事前にスタックにプッシュされたメインファンクションスタックフレームのスタックボトムアドレスにebpレジスタを入力します。つまり、ebp(メイン)がスタックからポップアウトされ、同時にebpレジスタに割り当てられます。

つまり、pop ebp // pop ebpを実行し、同時にアドレス値をebpレジスタに割り当てます。つまり、以下に示すように、ebpの値を復元します。つまり、ebpはメイン関数のスタックフレームの最下部を指します。


このとき、print_out関数のスタックフレームに戻りました。このとき、main関数のスタックフレームに到達していますが、main関数の命令コードセグメントに到達していません。

次に、ret命令がprint_out関数に送られます。つまり、以下に示すように、リターンアドレス(メインスタックフレームに格納されている)がEIPに書き込まれます。これはpopEIPと同等です。


この時点で、print_out関数は完全に実行され、メイン関数の命令セクションに戻ります。明らかに、次の命令は、メイン関数のprint_out関数の正式なパラメーターに割り当てられた2つの可変スペースを再利用し続けることです(メイン関数は元々呼び出されていました)仮パラメータの関数割り当てのプロセスも、メイン関数に属する命令です)、つまり、次の命令です。

add esp、8 //以下に示すように、2つの実際のパラメーターがスタックからポップされます。つまり、2つの実際のパラメーターのスペースが再利用されます。


つまり、main関数でprint_out関数を呼び出す命令の後の命令は、命令add esp、8(コンパイラは、これら2つの命令間の関係を認識できるため、動的ではありません)であるため、最初に呼び出されます。 print_out関数でスタックにプッシュされる戻りアドレスは、実際のパラメーターのスペースを再利用する命令のアドレスである命令add esp、8のアドレスです。最初に同じことを言ったので、次の命令のアドレスはどうですか。関数の命令コードは連続したアドレス空間に格納されるため、次に実行する命令のアドレスを取得するには、add esp、8命令のアドレス+1を追加するだけで済みます。

このようにして、print_out関数の呼び出し全体が完了し、print_out関数が呼び出されなかったときのメイン関数スタックフレームが元の状態に復元されます。上の図に示すように、完全で完全です。

 

次に、例を見てみましょう。上記の分析基盤を使用すると、次の分析基盤を同じ方法で簡単に分析できます。内部のアセンブリ命令コードは明確で明確であり、プロセス全体が明確で明確です。

 

 

/ ------------------------------------------------- -------------------------------------------------- ---------------- /


ここで、関数が呼び出されたときのスタックの変更プロセスを要約しましょう。

1.呼び出し元は、呼び出された関数の正式なパラメーターに必要なスペースを自分のスタックフレームに開きます。

2. push関数呼び出しの終了後に実行する必要のあるアドレス値、つまりリターンアドレス。これは、実際には、最初のステップで正式なパラメーター用に開かれたスペースを再利用する命令のアドレスです。

3.呼び出された関数を入力し、呼び出し元の関数のスタックフレームのスタックボトムアドレスをプッシュします

4.新しい関数の現在のスタックフレームでローカル変数にスペースを割り当てた後、ローカル変数をスタックにプッシュします

5.呼び出された関数は、関数が間もなく終了することを示すreturnステートメントを検出し、スタックフレームのスペースの再利用を開始します。

        1)戻り値がある場合は、戻り値をEAXに割り当てます。ない場合は、この手順を無視します。

        2)ローカル変数スペースを再利用します。つまり、espは呼び出し元の関数のスタックフレームの最上位を指します。

        3)ebpが主機能スタックフレームのスタック下部を指すように、事前に保存された主機能スタックフレームのスタック下部アドレスをebpレジスタに割り当てます。

        4)リターンアドレスをEIPレジスタに入力すると、メイン関数が呼び出された関数に対して最初に開いた2つの正式なパラメータスペースの命令アドレスを指します。

        5)正式なパラメータ空間の回復

これにより、メイン関数のスタックフレームが復元され、その関数が呼び出されなかったときにスタックフレームに戻ります。

 

上記からいくつかの結論を導き出すことができます。関数は実際には動的な概念であり、その存在はメモリ、つまり対応するスタックフレームにのみ反映され、スタックフレームがリサイクルされると、関数は終了します。アップ。

 

最後に、そのような問題について説明しましょう。呼び出された関数が戻り値をcpuのeaxレジスタとedxレジスタに渡すのを見たところ、呼び出し元の関数はこれら2つのレジスタの値を読み取るだけで呼び出された関数を取得できます。戻り値ですが、2つのレジスタeaxとedxはどちらも32ビットであるため、合計8バイトのデータを返すことができます。基本的なタイプのデータ(char、int、float、double(8バイトを占める)など)の場合、ポインタタイプ)は問題ありませんが、構造タイプのデータを返したい場合、メンバーの合計サイズが8バイトを超える場合(一般的な方法は構造ポインターを渡すことです。ただし、言語で許可される方法として、コンパイラーを明確にする必要があります。この方法を達成する方法)、原則は何ですか?

回答:コンパイラによってコンパイルされた同じプログラムは、通常、ターゲットコードの2つのバージョン、デバッグバージョンとリリースバージョンの生成をサポートします。デバッグバージョンのコンパイル結果は通常、デバッガに使用され、コードの最適化が低くなるため、開発者の復元が向上します。 C言語で書かれたソースプログラムの構造。リリースバージョンとは、リリースバージョン、つまり、ソフトウェアがシェルフで使用するためにリリースされることを指します。コンパイラは、コードを高度に最適化し、不要なコードと到達不能ステータスを削除します(コードの最適化について理解したい場合は、コンパイルの原則の本を参照してください)。 、デバッグは容易ではありませんが、運用効率は高くなります。実際、この2つの原則は基本的に同じです。ここでは、デバッグバージョンとリリースバージョンについて簡単に説明します。

最初のケースでは、次の図に示すように、8バイトを超えない構造の戻りプロセス:

 

総括する:

  (1.1)edx:eaxを使用して戻り値を渡します。呼び出し元は、戻り値のアドレスをスタックのadd関数に渡す必要はありません。つまり、プロセスは基本的なデータ型変数の戻りと同じです。

  (2.2)デバッグバージョンは、呼び出し元に一時オブジェクトの戻り値を生成します(これは、リリースバージョンには当てはまりません。上の図の赤いボックスのメモリスペースは存在しませんが、レジスタの値はメイン関数のt変数に直接コピーされます。 、したがって、リリースバージョンの方が効率的です)、一時オブジェクトをmainで指定された変数tのアドレスにコピーします。低効率。一時オブジェクトがメイン関数のスタックフレームにあることがわかります。つまり、メイン関数は、追加関数を呼び出す前に戻り値のタイプサイズを分析し、スペースを割り当てます。呼び出しが完了すると、一時オブジェクトは(戻り値の内容)の値が左側の割り当てられた変数tにコピーされます。この時点で、一時オブジェクトはミッションを完了し、main関数は一時オブジェクトのスペースを再利用します。

 

2番目のケースでは、次の図に示すように、8バイトを超える構造の戻りプロセス:

 

総括する:

  (1)構造が8バイトを超える場合、EDX:EAXで渡すことはできません。このとき、呼び出し元は自分のスタックフレームに戻り値を埋めるための構造を保持し、実際のパラメータがスタックにプッシュされた後、そのアドレスがスタックにプッシュされます。上の青い矢印に示されているように、上へ。呼び出された関数addは、このアドレス(赤い矢印)に従ってこのアドレスに戻り値を設定します。

  (2)main関数では、デバッグバージョンにはリリースバージョンよりも1つ多い一時オブジェクトがあり、非効率的です。リリースバージョンでは、戻り値と一時変数t(図の赤いボックス内の一時オブジェクトは存在しません)のみがあり、デバッグよりもわずかに効率的です。ただし、2つのモデルは基本的に同じです。戻り値のスペースの内容を左側に指定された割り当て変数tのスペース(メイン関数のtを参照)にコピーしてから、戻り値に対応するスペースを再利用する必要があります。全体的な効率構造ポインタよりもまだ低いため(ポインタは4バイトしか占有しないため、eaxレジスタを介して直接返され、ポインタtに割り当てられます)、C言語で構造タイプのデータを返す場合は、ポインタを使用して返すことをお勧めします。 、コードはより効率的に実行されます。

  (3)上記の2つの実験では、リリースバージョンの最適化は比較的強力であり、コンパイラは一部のメンバーが使用されていないと判断するため(tbとtcの2つのメンバーの割り当て、つまり役に立たないコードなど)、main関数でのtの割り当ては不完全です。 )、したがって、コードが同等である限り、コピーする必要はありません(特定の知識については、本の編集原則のコード最適化の章を参照してください)。

上記の2つの実験に対応するアセンブリコードはここに掲載されていません。コンパイラ最適化機能は万能ではありません。基礎となるプロセスを理解した後、将来的にコードを記述し、より高品質でより効率的なコードを記述できるようになります。

ブログをフォローすることを歓迎します。時間があれば、基本的なコンピューター理論に関するわかりやすい科学記事をいくつか書きます。一方では、自分の学習プロセスを記録するために使用できます。また、他の人と共有して、より多くの人に理解してもらうことができます。今日の生活のあらゆる場所でコンピューターがどのように機能するか。

 

参考記事:

関数呼び出し-関数スタックhttps://www.cnblogs.com/rain-lei/p/3622057.html

プログラムのコンパイル後の実行時のメモリ割り当てhttps://www.cnblogs.com/guochaoxxl/p/6977712.html

ヒープとスタックの違いhttps://www.cnblogs.com/yechanglv/p/6941993.html

構造にhttps://www.cnblogs.com/hoodlum1980/archive/2012/07/18/2598185.html返す関数について
---------------------を
著者:biao2488890051
ソース:CSDN
オリジナル:https://blog.csdn.net/kangkanglhb88008/article/details/89739105
著作権:この記事はブロガーのオリジナル記事であり、複製されています。ボーエンのリンクを添付してください。

おすすめ

転載: blog.csdn.net/qq_25814297/article/details/108462206