スタックポインターはスタックの先頭を指し、スタックの先頭には、 "LIFO"ベースと呼ばれるデータが格納されています。他の誰かの例えを盗むために、それはあなたが一番上に皿を置いて取る皿の積み重ねのようなものです。スタックポインターOTOHは、スタックの一番上の「皿」を指します。少なくとも、x86についてはそうです。
しかし、なぜコンピュータ/プログラムはスタックポインタが指しているものを「気にする」のでしょうか。言い換えれば、スタックポインターを持っていることと、それが機能する場所を知っていることの目的は何ですか?
Cプログラマが理解できる説明をいただければ幸いです。
このスタックは、その構造を説明するのではなく、実際にどのような役割を果たしますか?
スタックに格納されているデータの構造を正確に説明する多くの答えがあります。これは、あなたが尋ねた質問の反対です。
スタックが果たす目的は次のとおりです。スタックはコルーチンのない言語での継続の具体化の一部です。
開梱しましょう。
継続簡単に言えば、「プログラムで次に何が起こるのか」という質問に対する答えです。すべてのプログラムのあらゆるポイントで、次に何かが起こります。 2つのオペランドが計算され、プログラムは合計を計算して続行し、プログラムは合計を変数に割り当てて続行します。
具体化は、抽象的な概念の具体的な実装を行うための単なる高潔な言葉です。 "次は何が起こる?"抽象概念です。スタックのレイアウト方法は、その抽象的な概念が実際に計算を行う実際のマシンに変換される方法の一部です。
Coroutinesは、それらがどこにあったかを記憶し、しばらくの間別のコルーチンに制御を譲り、後で中断したところから再開できる関数ですが、notは直後に必ずコルーチン収量と呼ばれます。 C#での「yield return」または「await」を考えてください。C#では、次のアイテムが要求されたとき、または非同期操作が完了したときの場所を覚えておく必要があります。コルーチンまたは同様の言語機能を持つ言語では、継続を実装するために、スタックよりも高度なデータ構造が必要です。
スタックは継続をどのように実装しますか?他の答えはどのように言う。スタックには、(1)ライフタイムが現在のメソッドのアクティブ化より大きくないことがわかっている変数と一時変数の値、および(2)最新のメソッドのアクティブ化に関連付けられた継続コードのアドレスが格納されます。例外処理のある言語では、スタックは「エラーの継続」、つまり例外的な状況が発生したときにプログラムが次に行うことに関する情報も格納する場合があります。
この機会に、スタックには「どこから来たのか」とは表示されていません。 -デバッグでよく使用されますが。スタックは次にどこに行くのかとそこに到達したときのアクティベーションの変数の値はを伝えます。コルーチンのない言語では、次の場所がほとんどの場合、どこから来たのかによって、この種のデバッグが容易になります。しかし、requirementはありません。コンパイラーが、制御を行わずに逃げることができる場合に、制御がどこから来たかについての情報を格納します。たとえば、テールコールの最適化は、プログラムコントロールのソースに関する情報を破棄します。
なぜスタックを使用してコルーチンなしの言語で継続を実装するのですか?メソッドの同期アクティブ化の特徴は、それ自体で論理的にアクティブ化のスタックを形成する場合、「現在のメソッドを一時停止し、別のメソッドをアクティブ化し、アクティブ化されたメソッドの結果を知って現在のメソッドを再開する」というパターンであることです。このスタックのような動作を実装するデータ構造を作成することは、非常に安価で簡単です。なぜそんなに安くて簡単なのですか?なぜなら、チップセットは何十年もの間、この種のプログラミングをコンパイラー作成者にとって簡単にするために特別に設計されてきたからです。
スタックの最も基本的な用途は、関数の戻りアドレスを格納することです。
void a(){
sub();
}
void b(){
sub();
}
void sub() {
//should i got back to a() or to b()?
}
cの観点からはこれですべてです。コンパイラーの観点から:
OSの観点から:プログラムはいつでも中断される可能性があるため、システムタスクが完了した後、CPU状態を復元する必要があるため、すべてをスタックに保存できます
スタックに既にあるアイテムの数や、他の誰かが将来追加するアイテムの数は気にしないので、これらすべてが機能します。スタックポインタをどれだけ移動したかを知り、完了後にそれを復元するだけです。
LIFOは、Last In、First Outの略です。同様に、スタックに入れられた最後のアイテムは、スタックから取り出された最初のアイテムです。
お皿の類推( 最初のリビジョン )で説明したのは、キューまたはFIFO、先入れ先出しです。
2つの主な違いは、LIFO /スタックが同じ端からプッシュ(挿入)とポップ(削除)し、FIFO /キューが反対端から行う点です。
// Both:
Push(a)
-> [a]
Push(b)
-> [a, b]
Push(c)
-> [a, b, c]
// Stack // Queue
Pop() Pop()
-> [a, b] -> [b, c]
スタックの内部で何が起こっているのか見てみましょう。ここにいくつかのメモリがあります、各ボックスはアドレスです:
...[ ][ ][ ][ ]... char* sp;
^- Stack Pointer (SP)
そして、現在空のスタックの一番下を指しているスタックポインターがあります(スタックが大きくなっても小さくなっても、ここでは特に関係がないので無視しますが、実際には、どの操作が追加されるかを決定します、およびSPから差し引く)。
それでは、もう一度a, b, and c
をプッシュしましょう。左側のグラフィック、中央の「高レベル」操作、右側のC風の疑似コード:
...[a][ ][ ][ ]... Push('a') *sp = 'a';
^- SP
...[a][ ][ ][ ]... ++sp;
^- SP
...[a][b][ ][ ]... Push('b') *sp = 'b';
^- SP
...[a][b][ ][ ]... ++sp;
^- SP
...[a][b][c][ ]... Push('c') *sp = 'c';
^- SP
...[a][b][c][ ]... ++sp;
^- SP
ご覧のとおり、Push
を実行するたびに、スタックポインターが現在指している場所に引数が挿入され、スタックポインターが次の場所を指すように調整されます。
今ポップしましょう:
...[a][b][c][ ]... Pop() --sp;
^- SP
...[a][b][c][ ]... return *sp; // returns 'c'
^- SP
...[a][b][c][ ]... Pop() --sp;
^- SP
...[a][b][c][ ]... return *sp; // returns 'b'
^- SP
Pop
はPush
の反対であり、スタックポインターを調整して前の場所をポイントし、そこにあったアイテムを削除します(通常、pop
を呼び出した人にアイテムを返します)。 。
おそらく、b
とc
がまだメモリ内にあることに気づいたでしょう。私はそれらがタイプミスではないことを保証したいだけです。すぐに戻ります。
スタックポインターがない場合はどうなるか見てみましょう。もう一度押すことから始めます:
...[ ][ ][ ][ ]...
...[ ][ ][ ][ ]... Push(a) ? = 'a';
えーと、うーん...スタックポインターがない場合、それが指しているアドレスに何かを移動することはできません。たぶん、トップではなくベースを指すポインターを使用できます。
...[ ][ ][ ][ ]... char* bp; // "base pointer"
^- bp bp = malloc(...);
...[a][ ][ ][ ]... Push(a) *bp = 'a';
^- bp
// No stack pointer, so no need to update it.
...[b][ ][ ][ ]... Push(b) *bp = 'b';
^- bp
ええとああ。スタックのベースの固定値を変更できないため、a
を同じ場所にプッシュしてb
を上書きしました。
ええと、プッシュした回数を追跡してみませんか。また、ポップした時間を追跡する必要もあります。
...[ ][ ][ ][ ]... char* bp; // "base pointer"
^- bp bp = malloc(...);
int count = 0;
...[a][ ][ ][ ]... Push(a) bp[count] = 'a';
^- bp
...[a][ ][ ][ ]... ++count;
^- bp
...[a][b][ ][ ]... Push(a) bp[count] = 'b';
^- bp
...[a][b][ ][ ]... ++count;
^- bp
...[a][b][ ][ ]... Pop() --count;
^- bp
...[a][b][ ][ ]... return bp[count]; //returns b
^- bp
うまく動作しますが、*pointer
がpointer[offset]
よりも安価であることを除いて、実際には以前とほぼ同じです(余分な計算はありません)。これは私にとっては損失のようです。
もう一度やってみましょう。 Pascal文字列スタイルを使用して配列ベースのコレクションの終わりを見つける(コレクション内のアイテム数を追跡する)代わりに、C文字列スタイル(最初から最後までスキャン)を試してみましょう。
...[ ][ ][ ][ ]... char* bp; // "base pointer"
^- bp bp = malloc(...);
...[ ][ ][ ][ ]... Push(a) char* top = bp;
^- bp, top
while(*top != 0) { ++top; }
...[ ][ ][ ][a]... *top = 'a';
^- bp ^- top
...[ ][ ][ ][ ]... Pop() char* top = bp;
^- bp, top
while(*top != 0) { ++top; }
...[ ][ ][ ][a]... --top;
^- bp ^- top return *top; // returns '('
あなたはすでにここで問題を推測しているかもしれません。初期化されていないメモリは0であるとは限りません。したがって、a
を配置するトップを探すと、ランダムなガベージが含まれている未使用のメモリの場所をスキップしてしまいます。同様に、一番上までスキャンすると、a
を超えてスキップしてしまい、最終的に0
である別のメモリロケーションが最終的に見つかるまでプッシュし、戻ってランダムに返しますその直前のゴミ。
これは簡単に修正できます。Push
とPop
に操作を追加するだけで、スタックの先頭が常に更新されて0
でマークされるようになります。そのようなターミネータでスタックを初期化する必要があります。もちろん、これはスタック内の実際の値として0
(またはターミネーターとして選択した任意の値)を持つことができないことも意味します。
その上、O(1)演算であったものもO(n)演算に変更しました。
スタックポインタはスタックの先頭を追跡し、アクションのallが発生します。それを取り除く方法はいくつかあります(bp[count]
とtop
は基本的にスタックポインターです)が、どちらも単純にスタックポインターを持つよりも複雑で遅くなります。スタックのトップがどこにあるかわからないということは、スタックを使用できないことを意味します。
注:x86のランタイムスタックの「ボトム」を指すスタックポインターは、ランタイムスタック全体が上下逆になっていることに関連する誤解である可能性があります。言い換えると、スタックのベースが高いメモリアドレスに配置され、スタックの先端が低いメモリアドレスに成長します。スタックポインターdoesは、すべてのアクションが発生するスタックの先端を指し、その先端はスタックのベースよりも低いメモリアドレスにあります。
コールスタック にはスタックポインターが(フレームポインターと共に)使用されます(適切な画像があるウィキペディアへのリンクをたどります)。
呼び出しスタックには、戻りアドレス、ローカル変数、およびその他のローカルデータ(特に spilled レジスターの内容;形式)を含む呼び出しフレームが含まれています。
テールコール (一部のテール再帰コールはコールフレームを必要としない)、 例外処理 ( setjmp&longjmp など)についても参照してください。一度に多くのスタックフレームをポップする場合があります)、 シグナル & 割り込み 、および 継続 。 呼び出し規約 および アプリケーションバイナリインターフェース (ABI)も参照してください。特に x86-64 ABI (一部の正式な引数がレジスタ)。
また、いくつかの単純な関数をCでコーディングしてから、gcc -Wall -O -S -fverbose-asm
を使用してコンパイルし、生成された.s
アセンブラファイルを調べます。
Appelは古い1986年の論文を書いて ガベージコレクションはスタック割り当てよりも高速である可能性があります (コンパイラで Continuation-Passing Style を使用)と主張していますが、今日のx86プロセッサではおそらくこれは誤りです(特にキャッシュ効果のため)。
呼び出し規約、ABI、およびスタックレイアウトは、32ビットのi686と64ビットのx86-64では異なることに注意してください。また、呼び出し規約(および呼び出しフレームの割り当てまたはポップの責任者)は言語によって異なる場合があります(たとえば、C、Pascal、Ocaml、SBCL Common LISPには異なる呼び出し規約があります...)。
ところで、 [〜#〜] avx [〜#〜] のような最近のx86拡張機能は、スタックポインターにますます大きな整列制約を課しています(IIRC、x86-64の呼び出しフレームは16に整列する必要があります)バイト、つまり2つのワードまたはポインター)。
簡単に言うと、プログラムはそのデータを使用していて、どこにあるかを追跡する必要があるため、プログラムは気にします。
関数でローカル変数を宣言する場合、スタックはそれらが格納される場所です。また、別の関数を呼び出すと、スタックには戻りアドレスが格納されるため、呼び出した関数が終了したときに元の関数に戻り、中断したところから再開できます。
SPがなければ、私たちが知っているような構造化プログラミングは本質的に不可能です。 (それがない場合は回避できますが、独自のバージョンを実装する必要があるため、それほど大きな違いはありません。)
X86プロセッサのプロセッサスタックの場合、皿のスタックの類推は本当に不正確です。
さまざまな理由(主に歴史的)により、プロセッサスタックはメモリの上部からメモリの下部に向かって成長するため、より適切な例は、天井から吊り下げられたチェーンリンクのチェーンです。何かをスタックにプッシュすると、チェーンリンクが最下位リンクに追加されます。
スタックポインターはチェーンの最下位リンクを参照し、その最下位リンクがどこにあるかを「確認」するためにプロセッサーによって使用されるため、チェーン全体を天井から下に移動することなくリンクを追加または削除できます。
ある意味では、x86プロセッサーの内部では、スタックは上下逆ですが、通常のスタック用語の敷居が使用されるため、最低のリンクはスタックのtopと呼ばれます。
上記で参照したチェーンリンクは、実際にはコンピューターのメモリセルであり、ローカル変数と計算の中間結果を格納するために使用されます。関数がアクセスする必要がある変数の大部分がスタックポインターが参照している場所の近くに存在し、それらへの高速アクセスが望ましいため、コンピュータープログラムはスタックの先頭がどこにあるか(つまり、最も低いリンクがハングする場所)を気にします。
この回答は具体的にtheスタックポインタ(実行中の)現在のスレッドの]を参照しています。
手続き型プログラミング言語では、通常、スレッドはa stackにアクセスできます。1 以下の目的のため:
注意1:スレッドの使用に専念しますが、その内容は他のスレッドによって完全に読み取り可能です smashable -。
アセンブリプログラミング、C、およびC++では、3つの目的すべてを同じスタックで実行できます。他のいくつかの言語では、いくつかの目的が個別のスタック、または動的に割り当てられたメモリによって実現される場合があります。
以下は、スタックの使用目的を意図的に単純化したバージョンです。
スタックをインデックスカードの山として想像してください。スタックポインタは一番上のカードを指します。
関数を呼び出すとき:
この時点で、関数のコードが実行されます。コードは、各カードがトップとの相対位置を知るためにコンパイルされます。したがって、変数x
が上から3番目のカード(つまり、スタックポインター-3)であり、パラメーターy
が上から6番目のカード(つまり、スタックポインター- 6.)
このメソッドは、各ローカル変数またはパラメーターのアドレスをコードにベイクする必要がないことを意味します。代わりに、これらのデータ項目はすべて、スタックポインターに関連してアドレス指定されます。
関数が戻るとき、逆の操作は単純です:
スタックは、関数が呼び出される前の状態に戻りました。
これを検討するときは、2つの点に注意してください。ローカルの割り当てと割り当て解除は、スタックポインタに数値を加算または減算するだけなので、非常に高速な操作です。これが再帰でどのように自然に機能するかに注意してください。
これは、説明のために単純化しすぎています。実際には、パラメーターとローカルは最適化としてレジスターに入れることができ、スタックポインターは通常、マシンのワードサイズではなく、マシンのワードサイズによってインクリメントおよびデクリメントされます。 (いくつかのことを挙げます。)
ご存知のように、最近のプログラミング言語はサブルーチン呼び出し(ほとんどの場合「関数呼び出し」と呼ばれます)の概念をサポートしています。この意味は:
return
sになると、制御は呼び出しが開始された正確な時点に戻り、呼び出しが開始されたときと同じようにすべてのローカル変数値が有効になります。コンピュータはそれをどのように追跡しますか?どの関数がどの呼び出しが戻るのを待っているかの継続的な記録を維持します。このレコードはスタックです。これは非常に重要なレコードであるため、通常はtheスタックと呼びます。
また、この呼び出し/戻りのパターンは非常に重要であるため、CPUは長い間、特別なハードウェアサポートを提供するように設計されてきました。スタックポインターは、CPUのハードウェア機能です。これは、スタックの先頭を追跡するための専用レジスターであり、サブルーチンに分岐してそこから戻るためにCPUの命令によって使用されます。