スタックポインターが何であるかは理解していますが、何に使用されますか?


11

スタックポインターはスタックの先頭を指し、スタックの先頭には、 "LIFO"ベースと呼ばれるデータが格納されています。他の誰かの類推を盗むために、それはあなたが一番上に皿を置いて取る皿の積み重ねのようなものです。スタックポインターOTOHは、スタックの一番上の「皿」を指します。少なくとも、x86についてはそうです。

しかし、なぜコンピュータ/プログラムはスタックポインタが指しているものを「気にする」のでしょうか。言い換えれば、スタックポインターを持っていること、およびそれが機能する場所を知っていることの目的は何ですか?

Cプログラマが理解できる説明をいただければ幸いです。


皿のスタックの上部を見ることができるように、ラムのスタックの上部を見ることができないからです。
tkausl


8
スタックの一番下から皿を取りませ。上に1つ追加し、他の誰かが上からそれを取得します。ここでキューを考えています。
Kilian Foth、2015

@スノーマンあなたの編集は質問の意味を変えるようです。moonman239、スノーマンの変更が正確かどうか、具体的には「このスタックはその構造を説明するのではなく、実際にどのような目的に役立つのか」の追加を確認できますか?
8ビットツリー

1
@ 8bittree編集の説明を参照してください。件名に記載されている質問を質問の本文にコピーしました。もちろん、私は私が何かを変更した可能性に常に開いており、元の作者はいつでも自由にロールバックしたり、そうでなければ投稿を編集したりできます。

回答:


23

このスタックは、その構造を説明するのではなく、実際にどのような役割を果たしますか?

スタックに格納されたデータの構造を正確に説明する多くの答えがあります。これは、あなたが尋ねた質問の反対です。

スタックが果たす目的は次のとおりです。スタックは、コルーチンなしの言語での継続の具体化の一部です

開梱しましょう。

継続は単純に言えば、「私のプログラムで次に何が起こるのか」という質問に対する答えです。すべてのプログラムのあらゆるポイントで、次に何かが起こります。2つのオペランドが計算され、プログラムは合計を計算して続行し、プログラムは合計を変数に割り当てて続行します。

具体化は抽象的な概念の具体的な実装を作るためだけのhighfalutin言葉です。"次は何が起こる?" 抽象概念です。スタックのレイアウト方法は、その抽象的な概念が実際に計算を行う実際のマシンに変換される方法の一部です。

コルーチンは、それらがどこにあったかを記憶し、しばらくの間別のコルーチンに制御を委譲し、後で中断したところから再開することができます必ずしも呼び出されたコルーチンの直後ではありません。C#での "yield return"または "await"を考えてみてください。これは、次のアイテムが要求されたとき、または非同期操作が完了したときの場所を覚えておく必要があります。コルーチンまたは同様の言語機能を持つ言語では、継続を実装するために、スタックよりも高度なデータ構造が必要です。

スタックは継続をどのように実装しますか?他の答えはどのように言う。スタックには、(1)ライフタイムが現在のメソッドのアクティブ化より大きくないことがわかっている変数と一時変数の値、および(2)最新のメソッドのアクティブ化に関連付けられた継続コードのアドレスが格納されます。例外処理を行う言語では、スタックは「エラーの継続」、つまり例外的な状況が発生したときにプログラムが次に行うことに関する情報も格納する場合があります。

この機会に、スタックには「どこから来たのか」とは書かれていないことに注意してください。-デバッグでよく使用されますが。スタックは、次にどこへ行くの、そしてそこに到達したときのアクティベーションの変数の値を教えてくれます。コルーチンのない言語では、次の場所がほとんどの場合、どこから来たのかによって、この種のデバッグが容易になります。しかし、コンパイラーが制御なしで逃れることができる場合、制御がどこから来たかについての情報をコンパイラーが保管する必要はありません。たとえば、テールコールの最適化は、プログラムコントロールのソースに関する情報を破壊します。

スタックを使用してコルーチンなしの言語で継続を実装するのはなぜですか?メソッドの同期アクティブ化の特徴は、それ自体で論理的にアクティブ化のスタックを形成する場合、「現在のメソッドを一時停止し、別のメソッドをアクティブ化し、アクティブ化されたメソッドの結果を知って現在のメソッドを再開する」というパターンであることです。このスタックのような動作を実装するデータ構造を作成することは、非常に安価で簡単です。なぜそんなに安くて簡単なのですか?なぜなら、チップセットは何十年もの間、この種のプログラミングをコンパイラライターにとって簡単にするために特別に設計されてきたからです。


あなたが参照する引用は、別のユーザーによる編集で誤って追加され、それ以降修正されているため、この回答では問題が完全に解決されていないことに注意してください。
ビットツリー

2
私は説明をすることになっているかなり確信して増加明瞭さを。私も来て完全に「スタックはコルーチンなし言語における継続の具体化の一環である」ことを確信していないに近いもの:-)へ

4

スタックの最も基本的な用途は、関数の戻りアドレスを格納することです。

void a(){
    sub();
}
void b(){
    sub();
}
void sub() {
    //should i got back to a() or to b()?
}

Cの観点からはこれですべてです。コンパイラーの観点から:

  • すべての関数引数はCPUレジスタによって渡されます-十分なレジスタがない場合、引数はスタックに置かれます
  • 関数が終了した後(ほとんど)のレジスタは入力前と同じ値になるはずです-使用されたレジスタはスタックにバックアップされます

OSの観点から:プログラムはいつでも中断される可能性があるため、システムタスクが完了した後、CPU状態を復元する必要があるため、すべてをスタックに保存できます

スタック上にあるアイテムの数や、他の誰かが将来追加するアイテムの数は気にしないので、これらはすべて機能します。スタックポインタをどれだけ移動したかを知り、完了後にそれを復元する必要があるだけです。


1
引数はスタックにプッシュされると言った方が正確だと思いますが、多くの場合、タスクに十分な空きレジスターがあるプロセッサーでは、代わりに最適化レジスターが使用されます。これは重要なことですが、言語が歴史的にどのように進化してきたかについては、よりよく一致すると思います。初期のC / C ++コンパイラは、このためにレジスタをまったく使用しませんでした。
Gort the Robot

4

LIFO vs FIFO

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

Poppushはの逆で、スタックポインタを調整して前の場所をポイントし、そこにあったアイテムを削除します(通常はを呼び出した人に返しますpop)。

あなたはおそらくそれに気づいていてbcまだ記憶に残っています。私はそれらがタイプミスではないことをあなたに保証したいだけです。すぐに戻ります。

スタックポインターのない生活

スタックポインターがない場合はどうなるか見てみましょう。もう一度押すことから始めます:

...[ ][ ][ ][ ]...
...[ ][ ][ ][ ]...        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)オペレーションに変更しました。

TL; DR

スタックポインターは、すべてのアクションが発生するスタックの先頭を追跡します。そこそれを取り除くの一種の方法があります(bp[count]top、まだ基本的にスタックポインタです)が、それら両端より複雑にアップし、単にスタックポインタを持つよりも遅いです。スタックのトップがどこにあるかわからないということは、スタックを使用できないことを意味します。

注:x86のランタイムスタックの「ボトム」を指すスタックポインターは、ランタイムスタック全体が上下逆になっていることに関連する誤解である可能性があります。つまり、スタックのベースが高いメモリアドレスに配置され、スタックの先端が低いメモリアドレスに成長します。スタックポインタ、すべてのアクションが発生するスタックの先端を指します。その先端は、スタックのベースよりも低いメモリアドレスにあります。


2

スタックポインターは、(フレームポインターと共に)呼び出しスタックに使用されます(適切な画像があるWikipediaへのリンクをたどります)。

呼び出しスタックには、戻りアドレス、ローカル変数、およびその他のローカルデータ(特に、レジスターの流出コンテンツ、形式)を含む呼び出しフレームが含まれています。

末尾呼び出し(一部の末尾再帰呼び出しは呼び出しフレームを必要としない)、例外処理setjmp&longjmpなど)、シグナル割り込み、および継続についても読みます。呼び出し規約アプリケーションバイナリインターフェイス(ABI)、特にx86-64 ABI(一部の仮引数がレジスターによって渡されることを定義する)も参照してください。

また、Cでいくつかの単純な関数をコーディングし、それを使用gcc -Wall -O -S -fverbose-asm してそれをコンパイルし、生成されたアセン.s ブラーファイルを調べます。

Appelは、ガベージコレクションはスタック割り当てよりも高速である(コンパイラで継続渡しスタイルを使用)ことができると主張する1986年の古い論文を書いていますが、これはおそらく今日のx86プロセッサではおそらく誤りです(特にキャッシュ効果のため)。

呼び出し規約、ABI、およびスタックレイアウトは、32ビットのi686と64ビットのx86-64では異なることに注意してください。また、呼び出し規約(および呼び出しフレームの割り当てまたはポップの責任者)は言語によって異なる場合があります(たとえば、C、Pascal、Ocaml、SBCL Common Lispでは呼び出し規約が異なります...)。

ところで、AVXのような最近のx86拡張機能は、スタックポインターにますます大きなアライメント制約を課しています(IIRC、x86-64の呼び出しフレームは、16バイト、つまり2ワードまたはポインターにアライメントされることを望んでいます)。


1
x86-64で16バイトにアラインすることは、ポインタのサイズ/アラインメントを2倍にすることを意味します。これは、実際にはバイト数よりも興味深いものです。
Deduplicator

1

簡単に言うと、プログラムはそのデータを使用していて、どこにあるかを追跡する必要があるため、プログラムは気にかけます。

関数でローカル変数を宣言する場合、スタックはそれらが格納される場所です。また、別の関数を呼び出す場合、スタックには戻りアドレスが格納されるので、呼び出した関数が終了したときに元の関数に戻り、中断したところから再開できます。

SPがなければ、構造化プログラミングは基本的に不可能です。(それがない場合は回避できますが、独自のバージョンを実装する必要があるため、それほど大きな違いはありません。)


1
スタックなしの構造化プログラミングは不可能だというあなたの主張は誤りです。継続渡しスタイルにコンパイルされたプログラムはスタックを消費しませんが、完全に賢明なプログラムです。
Eric Lippert、2015

@EricLippert:頭上に立ったり、裏返したりするなど、十分に無意味な「完全に賢明」な値の場合。;-)
Mason Wheeler

1
継続渡し、すべてでコールスタックを必要としないことが可能です。事実上、すべての呼び出しは末尾呼び出しであり、戻るのではなくgotoです。「CPSとTCOは暗黙的な関数の戻りの概念を排除するため、それらを組み合わせて使用​​すると、ランタイムスタックの必要性を排除できます。」

@MichaelT:私は、ある理由で「本質的に」不可能だと言った。CPSは理論的にはこれを実現できますが、Eric がこの件に関する一連のブログ投稿で指摘しているように、 CPSで複雑な実世界のコードを書くことは実際には非常に急速に難しくなります。
メイソンウィーラー、

1
@MasonWheeler EricがCPSにコンパイルされたプログラムについて話している。例えば、引用ジョン・ハロップ氏のブログIn fact, some compilers don’t even use stack frames [...], and other compilers like SML/NJ convert every call into continuation style and put stack frames on the heap, splitting every segment of code between a pair of function calls in the source into its own separate function in the compiled form.「[スタック]の独自のバージョンを実装する」とは異なるのです。
Doval

1

x86プロセッサのプロセッサスタックの場合、皿のスタックの類推は本当に不正確です。
さまざまな理由(主に歴史的)により、プロセッサスタックはメモリの上部から下部に向かって増加するため、より適切な例は、天井から吊り下げられたチェーンリンクのチェーンです。スタックに何かをプッシュすると、チェーンリンクが最下位リンクに追加されます。

スタックポインターはチェーンの最下位リンクを参照し、プロセッサーがその最下位リンクの場所を「確認」するために使用されるため、チェーン全体を天井から下に移動することなくリンクを追加または削除できます。

ある意味では、x86プロセッサの内部では、スタックは上下逆になっていますが、通常のスタック用語の敷居が使用されるため、最も低いリンクがスタックの最上部と呼ばれます。


上記で参照したチェーンリンクは、実際にはコンピューターのメモリセルであり、ローカル変数と計算の中間結果を格納するために使用されます。関数がアクセスする必要のある変数の大部分がスタックポインターが参照している場所の近くに存在し、それらへの高速アクセスが望ましいため、コンピュータープログラムはスタックの先頭がどこにあるか(つまり、最も低いリンクがハングする場所)を気にします。


1
The stack pointer refers to the lowest link of the chain and is used by the processor to "see" where that lowest link is, so that links can be added or removed without having to travel the entire chain from the ceiling down.これが良いアナロジーかどうかはわかりません。実際には、リンクが追加または削除されることはありません。スタックポインターは、リンクの1つをマークするために使用するテープのようなものです。あなたがそのテープを紛失した場合、あなたが使用し一番下のリンクだったかを知るための方法はありませんすべてでは。天井からチェーンを下に移動しても、役に立ちません。
Doval

したがって、スタックポインターは、プログラム/コンピューターが関数のローカル変数を見つけるために使用できる参照ポイントを提供しますか?
moonman239 2015

その場合、コンピューターはどのようにローカル変数を見つけるのでしょうか。すべてのメモリアドレスをボトムアップで検索しますか?
moonman239

@ moonman239:いいえ、コンパイル時に、コンパイラはスタックポインタに対して各変数が格納されている場所を追跡します。プロセッサは、このような相対アドレッシングを理解して、変数に直接アクセスできるようにします。
Bart van Ingen Schenau

1
@BartvanIngenSchenauああ、そうか。まるで外出先で助けが必要なときのように、ランドマークとの相対的な位置を911に知らせます。この場合、スタックポインタは通常、最も近い「ランドマーク」であり、したがって、おそらく最良の参照ポイントです。
moonman239

1

この回答は、特に(実行中の)現在のスレッドのスタックポインタ参照しています。

手続き型プログラミング言語では、スレッドは通常、次の目的でスタック1にアクセスします。

  • 制御フロー、つまり「コールスタック」。
    • ある関数が別の関数を呼び出すと、呼び出しスタックはどこに戻るかを記憶します。
    • これは「関数呼び出し」の動作、つまり「中断したところから再開する」ための呼び出しスタックが必要です。
    • 実行の途中で関数呼び出しを行わない(たとえば、現在の関数の最後に達したときに次の関数を指定することのみが許可される)または関数呼び出しをまったく行わない(gotoと条件付きジャンプのみを使用する)他のプログラミングスタイルがあります。 )。これらのプログラミングスタイルは、呼び出しスタックを必要としない場合があります。
  • 関数呼び出しパラメーター。
    • 関数が別の関数を呼び出すと、パラメーターをスタックにプッシュできます。
    • 呼び出しが終了したとき、呼び出し元と呼び出し先は、スタックからパラメーターをクリアする責任を負うのと同じ規則に従う必要があります。
  • 関数呼び出し内に存在するローカル変数。
    • 呼び出し元に属するローカル変数は、そのローカル変数へのポインターを呼び出し先に渡すことにより、呼び出し先にアクセス可能にできることに注意してください。

1:スレッドの使用に特化していますが、その内容は他のスレッドによって完全に読み取り可能であり、スマッシュ可能です。

アセンブリプログラミング、C、およびC ++では、3つの目的すべてを同じスタックで実行できます。他のいくつかの言語では、いくつかの目的は、個別のスタックまたは動的に割り当てられたメモリによって実現される場合があります。


1

以下は、スタックの使用目的を意図的に単純化したバージョンです。

スタックをインデックスカードの山として想像してください。スタックポインタは一番上のカードを指します。

関数を呼び出すとき:

  • 関数を呼び出した行の直後にコードのアドレスをカードに書いて、山の上に置きます。(つまり、スタックポインターを1つインクリメントし、それが指す場所にアドレスを書き込みます)
  • 次に、レジスターに含まれる値をいくつかのカードに書き留め、それらを山に置きます。(つまり、スタックポインタをレジスタの数だけインクリメントし、レジスタの内容をそれが指す場所にコピーします)
  • 次に、マーカーカードを山に置きます。(つまり、現在のスタックポインターを保存します。)
  • 次に、関数が呼び出される各パラメーターの値を1つずつカードに書き込み、それを山に置きます。(つまり、パラメーターの数だけスタックポインターをインクリメントし、スタックポインターが指す場所にパラメーターを書き込みます。)
  • 次に、ローカル変数ごとにカードを追加し、初期値を書き込む可能性があります。(つまり、ローカル変数の数だけスタックポインターをインクリメントします。)

この時点で、関数のコードが実行されます。コードは、各カードがトップとの相対位置を知るためにコンパイルされます。したがって、変数xは上から3番目のカード(つまり、スタックポインター-3)であり、パラメーターyは上から6番目のカード(つまり、スタックポインター-6)であることがわかります。

この方法は、各ローカル変数またはパラメーターのアドレスをコードにベイクする必要がないことを意味します。代わりに、これらのデータ項目はすべて、スタックポインターに関連してアドレス指定されます。

関数が戻るとき、逆の操作は単純です:

  • マーカーカードを探し、その上にあるすべてのカードを捨てます。(つまり、スタックポインタを保存されたアドレスに設定します。)
  • 以前に保存したカードからレジスタを復元し、それらを捨てます。(つまり、スタックポインターから固定値を減算します)
  • 上のカードのアドレスからコードの実行を開始し、それを捨てます。(つまり、スタックポインターから1を減算します。)

スタックは、関数が呼び出される前の状態に戻りました。

これを考慮するときは、2つの点に注意してください。ローカルの割り当てと割り当て解除は、スタックポインタに数値を加算または減算するだけなので、非常に高速な操作です。また、これが再帰でどのように自然に機能するかに注意してください。

これは説明のために単純化しすぎています。実際には、パラメーターとローカル変数は最適化としてレジスターに入れることができ、スタックポインターは通常、マシンではなく、マシンのワードサイズによってインクリメントおよびデクリメントされます。(いくつかのことを挙げます。)


1

ご存じのように、最近のプログラミング言語は、サブルーチン呼び出し(ほとんどの場合、「関数呼び出し」と呼ばれます)の概念をサポートしています。この意味は:

  1. コードの途中で、プログラム内の他の関数を呼び出すことができます。
  2. その関数は、どこから呼び出されたかを明示的に知りません。
  3. それにもかかわらず、その作業が完了してreturnsになると、制御は呼び出しが開始された正確な時点に戻り、呼び出しが開始されたときと同じようにすべてのローカル変数値が有効になります。

コンピュータはそれをどのように追跡しますか?どの関数がどの呼び出しが戻るのを待っているかの継続的な記録を維持します。このレコードはスタックです。これ非常に重要なレコードであるため、通常スタックと呼びます。

また、この呼び出し/リターンパターンは非常に重要であるため、CPUは長い間、特別なハードウェアサポートを提供するように設計されてきました。スタックポインターは、CPUのハードウェア機能です。これは、スタックの先頭を追跡するための専用レジスターで、サブルーチンに分岐してそこから戻るためにCPUの命令によって使用されます。

弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.