私は、プログラミング言語の低レベル操作がどのように機能するか、特にそれが OS/CPU とどのように相互作用するかについて、より深く理解しようとしています。Stack Overflow のスタック/ヒープ関連のスレッドの回答はすべて読んだと思いますが、どれも素晴らしいものです。しかし、まだ完全に理解していないことが 1 つあります。
有効な Rust コードになりがちな擬似コードでこの関数を検討してください ;-)
fn foo() {
let a = 1;
let b = 2;
let c = 3;
let d = 4;
// line X
doSomething(a, b);
doAnotherThing(c, d);
}
スタックが行 X でどのようになるか想定します。
Stack
a +-------------+
| 1 |
b +-------------+
| 2 |
c +-------------+
| 3 |
d +-------------+
| 4 |
+-------------+
さて、スタックの仕組みについて私が読んだことはすべて、スタックが LIFO ルール (後入れ先出し) に厳密に従うというものでした。.NET、Java、またはその他のプログラミング言語のスタック データ型と同じです。
しかし、そうだとすると、行 X の後はどうなるのでしょうか。明らかに、次に必要なのはa
とを操作することですが、そのためには、OS/CPU (?) がまずとb
を取り出してとに戻る必要があります。しかし、次の行にとが必要になるため、自滅することになります。d
c
a
b
c
d
それで、私は何を考えるだろうかその通り舞台裏では何が起きているのでしょうか?
関連する別の質問です。次のように他の関数の 1 つに参照を渡すことを考えてみましょう。
fn foo() {
let a = 1;
let b = 2;
let c = 3;
let d = 4;
// line X
doSomething(&a, &b);
doAnotherThing(c, d);
}
私の理解では、これは のパラメータが や のように基本的に同じメモリアドレスを指していることを意味します。しかしdoSomething
、これはa
b
foo
スタックをポップアップしてa
、b
ハプニング。
この2つの事例から、私はまだ十分に理解できていないと感じていますその通りスタックがどのように機能し、どのように厳密に従うのか後入先出法ルール。
ベストアンサー1
コール スタックはフレーム スタックとも呼ばれます。
物事は積み重ねられたLIFO原則の後には、ローカル変数ではなく、呼び出される関数のスタックフレーム全体(「呼び出し」)があります。ローカル変数は、いわゆる関数プロローグそしてエピローグ、 それぞれ。
フレーム内では変数の順序は完全に指定されていません。コンパイラフレーム内のローカル変数の位置を「並べ替える」プロセッサが可能な限り速くフェッチできるように、それらの配置を適切に最適化します。重要な事実は、固定アドレスに対する変数のオフセットはフレームの存続期間中一定である- つまり、フレーム自体のアドレスなどのアンカーアドレスを取得し、そのアドレスの変数へのオフセットを操作するだけで十分です。このようなアンカーアドレスは、実際にはいわゆるベースまたはフレームポインタこれは EBP レジスタに格納されます。一方、オフセットはコンパイル時に明確にわかっているため、マシン コードにハードコードされます。
このグラフィックはウィキペディア典型的なコールスタックの構造を示します1 :
アクセスしたい変数のオフセットをフレーム ポインターに含まれるアドレスに追加すると、変数のアドレスが取得されます。簡単に言うと、コードはベース ポインターからの一定のコンパイル時オフセットを介して直接アクセスするだけです。これは単純なポインター演算です。
例
#include <iostream>
int main()
{
char c = std::cin.get();
std::cout << c;
}
gcc.godbolt.org私たちに与える
main:
pushq %rbp
movq %rsp, %rbp
subq $16, %rsp
movl std::cin, %edi
call std::basic_istream<char, std::char_traits<char> >::get()
movb %al, -1(%rbp)
movsbl -1(%rbp), %eax
movl %eax, %esi
movl std::cout, %edi
call [... the insertion operator for char, long thing... ]
movl $0, %eax
leave
ret
.. for main
. コードを 3 つのサブセクションに分割しました。関数のプロローグは、最初の 3 つの操作で構成されます。
- ベースポインタがスタックにプッシュされます。
- スタックポインタはベースポインタに保存されます
- ローカル変数のためのスペースを確保するためにスタック ポインタが減算されます。
次に、cin
EDI レジスタ2に移動され、get
呼び出されます。戻り値は EAX にあります。
ここまでは順調です。ここで興味深いことが起こります。
8ビットレジスタALで指定されたEAXの下位バイトが取得され、ベースポインタの直後のバイトに格納される: つまり-1(%rbp)
、ベースポインタのオフセットは です-1
。このバイトは変数ですc
オフセットは負です。x86 ではスタックが下向きに大きくなるためです。次の操作はc
EAX に格納されます。EAX は ESI に移動され、cout
EDI に移動され、挿入演算子が引数としてcout
およびで呼び出されます。c
ついに、
- の戻り値は
main
EAX: 0 に格納されます。これは暗黙のreturn
ステートメントのためです。xorl rax rax
の代わりにが表示される場合もありますmovl
。 - 出発して呼び出しサイトに戻る。
leave
このエピローグを省略し、暗黙的に- スタックポインタをベースポインタに置き換え、
- ベース ポインタをポップします。
この操作とがret
実行された後、フレームは事実上ポップされますが、cdecl 呼び出し規約を使用しているため、呼び出し元は引数をクリーンアップする必要があります。stdcall などの他の規約では、呼び出し先が、たとえばバイト数を に渡すことによって、クリーンアップする必要がありますret
。
フレームポインタ省略
ベース/フレームポインターからのオフセットではなく、スタックポインター(ESB)からのオフセットを使用することもできます。これにより、フレームポインター値を格納するEBPレジスターが任意の用途に使用できるようになりますが、一部のマシンではデバッグが不可能、そして一部の機能では暗黙的にオフになっていますこれは、x86 などのレジスタが少ないプロセッサ用にコンパイルする場合に特に便利です。
この最適化はFPO(フレームポインタ省略)として知られており、-fomit-frame-pointer
GCCと-Oy
Clangではによって設定されます。デバッグが可能な場合にのみ、最適化レベルが0を超えるたびに暗黙的にトリガーされることに注意してください。それ以外のコストはかかりません。詳細については、ここそしてここ。
1コメントで指摘されているように、フレーム ポインターはおそらく戻りアドレスの後のアドレスを指すことを意図しています。
2 R で始まるレジスタは、E で始まるレジスタの 64 ビット版であることに注意してください。EAX は、RAX の下位 4 バイトを表します。わかりやすくするために、32 ビット レジスタの名前を使用しました。