スタックの旅。 パート1





以前の資料では、コンピューター上のプログラムの実行に関連する中心的な概念の1つである、プログラムのメモリへの配置について検討しました。 それでは、ほとんどのプログラミング言語と仮想マシンの主力であるコールスタックを見てみましょう。 クロージャ関数、バッファオーバーフロー、再帰などの驚くべきことを紹介します。 ただし、すべてに独自の時間があります。最初は、スタックがどのように機能するかについての基本的なアイデアを得る必要があります。



スタックは非常に重要です。スタックのおかげで、 関数は完了後に制御を戻す場所を「知っている」からです。 この関数は、プログラムの基本的な構成要素です。 一般に、プログラムは内部的に非常に単純です。 プログラムは関数で構成され、関数はその操作の過程で他の関数を呼び出すことができ、どの関数もスタックにデータをプッシュしてそこから削除します。 関数の完了後もデータを存続させたい場合、そのスペースはスタック上ではなく、ヒープ上に割り当てられます。 上記は、比較的低レベルのCで記述されたプログラムだけでなく、JavaScriptやC#などのインタープリター言語にも同様に適用されます。 これらのことを知っておくと、プログラムをデバッグする必要がある場合や、パフォーマンスを微調整する必要がある場合や、プログラム内で何が起こっているのかを理解するために役立ちます。



それでは始めましょう。 関数を呼び出すとすぐに、 スタック上にスタックフレームが作成されます 。 スタックフレームには、 ローカル変数と、呼び出し元の関数によって渡された引数が含まれています。 さらに、フレームには、適切なタイミングで呼び出し元の関数に制御を返すために、呼び出された関数によって使用されるサービス情報が含まれています。 スタックの正確な内容とメモリ内のレイアウトは、使用するプロセッサアーキテクチャと呼び出し規約によって異なる場合があります。 この記事では、C( cdecl )で採用されている呼び出し規則を持つx86スタックについて検討します。 上の図は、スタックの最上部にあるスタックフレームを示しています。



3つのプロセッサレジスタがすぐにわかります。 スタックポインター espは、スタックの最上部を指すように設計されています。 一番上に近いのは常にスタックに追加されたオブジェクトですが、そこからまだ削除されていません 。 同様に、実生活では、物事はプレートのスタックまたは100ドル札のパックを使用しています。



オブジェクトがスタックに追加およびスタックから削除されると、 espレジスタに格納されているアドレスが変更されますが、スタックからまだ削除されていない最後に追加されたオブジェクトを常に示します。 多くのプロセッサ命令は、実行の副産物としてespレジスタの値を変更します。 ESPレジスタなしでスタックを実装すると問題が発生します。



Intelプロセッサの場合、他の多くのアーキテクチャと同様に、スタックはメモリアドレス低い方向に成長します 。 したがって、この場合の上部は、有効なデータが格納されるスタック上の最小アドレスに対応します。この場合、これはlocal_buffer変数です。 私は、 espからlocal_bufferへの矢印が何を意味するかを明確にすべきだと思います。 彼らが言うように、それはすべてです-矢印はlocal_bufferが占める最初のバイトを 正確指し 、これはespレジスタに保存されたアドレスに対応します。



次の行には、スタック上の位置を追跡するために使用される別のレジスタ— ebpレジスタ— ベースポインタまたはスタックフレームのベースへのポインタがあります 。 このレジスタは、スタックフレーム内の位置を示すためのものです。 ebpレジスタのおかげで、 現在の関数には常に引数とローカル変数にアクセスするための一種の参照ポイントがあります。 関数が実行を開始または停止すると、レジスタに保存されているアドレスが変更されます。 図に示すように、スタックフレーム内の任意のオブジェクトをebpに対するオフセットとして非常に簡単にアドレス指定できます。



espとは異なり、 ebpレジスタの操作は、プロセッサではなく、主にプログラム自体によって実行されます。 ebpレジスタの標準的な使用を放棄するだけで、パフォーマンスを向上できる場合があります。これには、いくつかのコンパイラフラグが関与します。 Linuxカーネルは、この手法の使用例です。



最後に、 eaxレジスタは、呼び出し関数によって返されたデータを格納するために伝統的に使用されています-このステートメントは、Cでサポートされているほとんどのタイプに有効です。



スタックフレームに含まれるデータを解析しましょう。 この図は、フレームの正確なバイト内容を、左から右へのアドレスの成長方向とともに示しています。これは、デバッガで通常見られるものです。 そして、図面自体は次のとおりです。







ローカル変数local_bufferは、ヌル終了ASCII文字列を表すバイトの配列です。 このような行は、すべてのCプログラムの不変の属性であり、行サイズは7バイトであり、ほとんどの場合、キーボード入力またはファイルからの読み取りの結果として取得されたものです。 この配列では、8バイトを保存できるため、1バイトは未使用のままです。 このバイトの値は不明です。 実際、データは時々スタックに追加されたり、スタックから削除されたりします。この「追加および削除操作の無限のダンス」では、書き込むまでメモリの内容を事前に知ることはできません。 Cコンパイラは、スタックフレームをゼロで初期化すること自体に負担をかけません。 したがって、そこに含まれるデータは事前​​に不明であり、ある程度ランダムです。 プログラマーにとってコンパイラーのこの振る舞いはどれほどの血を流したのでしょう!



さらに進みます。 local1は4バイトの整数で、図は各バイトの内容を示しています。 これは大きな数字のようです-8の後にこれらすべてのゼロを見てください。しかし、ここで私たちの直感はひどく役立っています。



Intelプロセッサは、 直接バイト順 (文字通り「ポイント」)を使用します 。これは、数値が下位バイトからメモリに格納されることを意味します 。 つまり、最下位バイトは最下位アドレスのメモリ位置に保存されます。 図と図では、マルチバイト数のバイトが伝統的に左から右の順に描かれています。 直接バイト順の場合、最下位バイトは左端の位置に表示されます。これは、数値の表現および書き込みの通常の方法とは異なります。



この「先の尖った」用語はすべて、ジョナサンスウィフトのガリバー旅行記に由来することを知っておくと良いでしょう。 リリパットの住民が卵を鋭い端から掃除したように、Intelプロセッサも下位バイトから始まる数字を処理します。



したがって、変数local1には実際に数字8が格納されます(はい、タコの触手の数と同じです)。 param1に関しては、最初から2番目のオクテットにデュースがあるため、結果として2 * 256 = 512の数値が得られます(各オクテットは0〜255の範囲であるため、256を乗算します)。 param2は数値1 * 256 * 256 = 65536を格納します。



スタックフレームのサービス情報には、 呼び出し関数のスタックフレームのアドレス(図ではebpに保存されている)と、この関数の完了時に制御を移すべき命令のアドレス(図-リターンアドレス)の2つのコンポーネントが含まれています。 この情報により、制御を返すことができるため、呼び出しがないかのようにプログラムをさらに実行できます。



次に、スタックフレームの「誕生」のプロセスを見てみましょう。 スタックは、通常見ている方向には成長せ 、最初は混乱する可能性があります。 たとえば、スタックを8バイト増やすには、プログラマーはespレジスタに格納されている値から8を引きます。 減算は、何かを増やす奇妙な方法です。 おかしいですね。



たとえば、簡単なCプログラムを見てみましょう。







コマンドラインでパラメーターなしでプログラムが起動されたとします。 Linuxでプログラムを実行するとき、コントロールが最初に取得するのは標準Cライブラリに含まれるコードです。このコードはプログラムのmain()関数を呼び出し、この場合、 argc変数は0になります( 実際、変数はパラメータに対応する「1」-プログラムが実行されている名前ですが、簡単にするためにこの瞬間は省略しましょう )。 main()関数が呼び出されると、次のことが起こります。







ステップ2と3、および4(以下で説明)は、 「プロローグ」と呼ばれる一連の命令に対応し、ほぼすべての機能で発生します。ebpレジスタの現在の値がスタックにプッシュされ、 espレジスタの値がebpレジスタにコピーされます。新しいスタックフレーム。 main()関数のプロローグは他の関数と同じですが、唯一の違いは、プログラムの起動時にebpレジスタにゼロが含まれていることです。



スタックのargcの下にあるものを見ると、さらにデータが表示されます-プログラムが起動された名前の行へのポインター、コマンドライン(従来のargv C-array)を介して渡されたパラメーター行へのポインター、また、環境変数へのポインタと直接これらの変数自体。 ただし、この段階ではこれは特に重要ではないので、 add()関数の呼び出しに向かって進みます。







main()関数は、 espレジスタの現在の値から最初に12を減算して必要な場所を割り当ててから、値を変数aおよびbに割り当てます。 メモリに保存されている値は、デバッガーと同様に、16進数形式で直接バイト順で図に示されています。 値を割り当てた後、 main()関数はadd()関数を呼び出し、実行を開始します。







遠ければ遠いほど面白い! さらに別のプロローグがありますが、スタック内のスタックフレームのシーケンスがリンクリストに編成され、 ebpレジスタがこのリストの最初の要素へのリンクを格納する方法を明確に見ることができます。 これに基づいて、デバッガーのスタックトレースと高レベル言語の例外オブジェクトが実装されます。 ebpおよびespレジスタが同じ場所を指している場合に、関数の実行が始まる典型的な状況に注目しましょう。 また、スタックを拡張するために、 espレジスタに格納されている値が減算されることを思い出してください。



次のことに注意することが重要です-ebpレジスタからメモリにデータをコピーすると、バイトの格納順序に一見理解できない変更が発生します。 事実、レジスターには「バイト順」のようなものは存在しません。 つまり、レジスタを考慮すると、「高アドレスまたは低アドレス」があるとは言えません。 したがって、デバッガーは、人間の知覚にとって最も便利な方法でレジスターに保管されている値を表示します。 したがって、「左から右」および「リトルエンディアン」マシンの標準表記を使用すると、レジスタからメモリへのコピー操作の結果、バイトが順序を逆にしたという誤解を招く印象を与えます。 私は、図面に示されている絵ができるだけ現実に近いものになりたかったので、そのような図面です。



最も難しい部分が背後にあるため、追加を行います。







ここには、追加に役立つ不明なレジスタがありますが、全体として特別なことや驚くことはありません。 add()関数がその役割を果たし、この時点から、スタック上のすべてのアクションが逆の順序で実行されます。 しかし、これについては改めて説明します。



これらの行を読んだすべての人は持久力に値するので、オタクの誇りを持って、上記のすべての手順を示すこの単一の小さな図を紹介します。



それほど難しくはありません。棚にすべてを置くだけです。 ところで、小さな箱は大いに役立ちます。 一般的なコンピューターサイエンスの「小さな正方形」がなければ、どこにもありません。 私の図面が、スタックの成長とメモリの内容の変化の両方を直感的に示す、何が起こっているのかを明確に描くことができることを願っています。 綿密な検査で、当社のソフトウェアは単純なチューリングマシンとそれほど違いはありません。



これで、スタックに沿った旅の最初の部分は終了です。 将来の記事では、「バイト」ジャングルに新たに没頭するのを待っています。その後、この基礎の上に構築できる高レベルのプログラミング言語を確認します。 ではまた来週。



スマートソフト社員が作成した資料

smart-soft.ru



All Articles