(あなたがそれで遊びたい場合に備えて、私はこの答えのすべてのコードの要点を作りました)
2003年のCS101コースでは、asmで最も基本的なことを行ったことがあります。そして、基本的にすべてCまたはC ++でのプログラミングに似ていることに気付くまで、asmとスタックがどのように機能するかを実際に「理解」したことはありませんでした...ただし、ローカル変数、パラメーター、および関数はありません。おそらくまだ簡単に聞こえないでしょう:)お見せしましょう(Intel構文のx86 asmの場合)。
1.スタックとは
スタックは通常、スレッドが開始する前にすべてのスレッドに割り当てられる連続したメモリのチャンクです。何でも保存できます。C ++用語(コードスニペット#1):
const int STACK_CAPACITY = 1000;
thread_local int stack[STACK_CAPACITY];
2.スタックの上部と下部
原則として、stack
配列のランダムセルに値を格納できます(スニペット#2.1)。
stack[333] = 123;
stack[517] = 456;
stack[555] = stack[333] + stack[517];
しかし、どのセルを覚えるのがどれほど難しいか想像してみてください stack
がすでに使用されており、が「無料」。そのため、新しい値を隣り合わせのスタックに格納します。
(x86)asmのスタックの奇妙な点の1つは、最後のインデックスから始めて下位のインデックスに移動することです。stack[999]、stack [998]など(スニペット#2.2):
stack[999] = 123;
stack[998] = 456;
stack[997] = stack[999] + stack[998];
そして、まだのための「公式」名(注意、あなたは今混同しているつもり)stack[999]
であるスタックの底に。
最後に使用されたセル(stack[997]
上記の例)は、スタックの最上位と呼ばれます(スタックの最上位がx86上にある場所を参照)。)。
3.スタックポインタ(SP)
この説明の目的のために、CPUレジスタがグローバル変数として表されていると仮定しましょう(汎用レジスタを参照)。
int AX, BX, SP, BP, ...;
int main(){...}
スタックの最上位を追跡する特別なCPUレジスタ(SP)があります。SPはポインタです(0xAAAABBCCのようなメモリアドレスを保持します)。ただし、この投稿では、配列インデックス(0、1、2、...)として使用します。
スレッドが開始するSP == STACK_CAPACITY
と、プログラムとOSが必要に応じてスレッドを変更します。ルールは、スタックの最上位を超えてスタックセルに書き込むことはできず、SP未満のインデックスは無効で安全ではないため(システム割り込みのため)、
最初にSPをデクリメントしてその後、新たに割り当てられたセルに値を書き込みます。
スタック内の複数の値を連続してプッシュする場合は、それらすべてのスペースを事前に予約できます(スニペット#3)。
SP -= 3;
stack[999] = 12;
stack[998] = 34;
stack[997] = stack[999] + stack[998];
注意。これで、スタックへの割り当てが非常に高速である理由がわかります。これは、レジスタの1つのデクリメントだけです。
4.ローカル変数
この単純な関数(スニペット#4.1)を見てみましょう。
int triple(int a) {
int result = a * 3;
return result;
}
ローカル変数を使用せずに書き直します(スニペット#4.2):
int triple_noLocals(int a) {
SP -= 1;
stack[SP] = a * 3;
return stack[SP];
}
そしてそれがどのように呼ばれているかを見てください(スニペット#4.3):
someVar = triple_noLocals(11);
SP += 1;
5.プッシュ/ポップ
スタックの最上位に新しい要素を追加することは非常に頻繁な操作であるため、CPUにはそのための特別な命令がありpush
ます。このように強制します(スニペット5.1):
void push(int value) {
--SP;
stack[SP] = value;
}
同様に、スタックの最上位要素を取得します(スニペット5.2)。
void pop(int& result) {
result = stack[SP];
++SP;
}
プッシュ/ポップの一般的な使用パターンは、一時的に値を節約することです。たとえば、変数に役立つものがmyVar
あり、何らかの理由でそれを上書きする計算を行う必要があります(スニペット5.3)。
int myVar = ...;
push(myVar);
myVar += 10;
...
pop(myVar);
6.関数パラメーター
次に、スタック(スニペット#6)を使用してパラメーターを渡します。
int triple_noL_noParams() {
SP -= 1;
stack[SP] = stack[SP + 1] * 3;
return stack[SP];
}
int main(){
push(11);
assert(triple(11) == triple_noL_noParams());
SP += 2;
}
7。 return
ステートメント
AXレジスタに値を返しましょう(スニペット#7):
void triple_noL_noP_noReturn() {
SP -= 1;
stack[SP] = stack[SP + 1] * 3;
AX = stack[SP];
SP += 1;
}
void main(){
...
push(AX);
push(11);
triple_noL_noP_noReturn();
assert(triple(11) == AX);
SP += 1;
pop(AX);
...
}
8.スタックベースポインタ(BP)(フレームポインタとも呼ばれます)とスタックフレーム
より「高度な」関数を使用して、asmのようなC ++(スニペット#8.1)で書き直してみましょう。
int myAlgo(int a, int b) {
int t1 = a * 3;
int t2 = b * 3;
return t1 - t2;
}
void myAlgo_noLPR() {
SP -= 2;
stack[SP + 1] = stack[SP + 2] * 3;
stack[SP] = stack[SP + 3] * 3;
AX = stack[SP + 1] - stack[SP];
SP += 2;
}
int main(){
push(AX);
push(22);
push(11);
myAlgo_noLPR();
assert(myAlgo(11, 22) == AX);
SP += 2;
pop(AX);
}
ここで、tripple
(スニペット#4.1)のように、戻る前に結果を格納するために新しいローカル変数を導入することにしたと想像してください。関数の本体は次のようになります(スニペット#8.2):
SP -= 3;
stack[SP + 2] = stack[SP + 3] * 3;
stack[SP + 1] = stack[SP + 4] * 3;
stack[SP] = stack[SP + 2] - stack[SP + 1];
AX = stack[SP];
SP += 3;
ご覧のとおり、関数パラメーターとローカル変数へのすべての参照を更新する必要がありました。これを回避するには、スタックが大きくなっても変化しないアンカーインデックスが必要です。
現在のトップ(SPの値)をBPレジスタに保存することにより、関数の入力直後(ローカルにスペースを割り当てる前)にアンカーを作成します。スニペット#8.3:
void myAlgo_noLPR_withAnchor() {
push(BP);
BP = SP;
SP -= 2;
stack[BP - 1] = stack[BP + 1] * 3;
stack[BP - 2] = stack[BP + 2] * 3;
AX = stack[BP - 1] - stack[BP - 2];
SP = BP;
pop(BP);
}
関数に属し、関数を完全に制御するスタックのスライスは、関数のスタックフレームと呼ばれます。たとえば、myAlgo_noLPR_withAnchor
のスタックフレームはstack[996 .. 994]
(両方のidexeを含む)です。
フレームは関数のBPで始まり(関数内で更新した後)、次のスタックフレームまで続きます。したがって、スタック上のパラメーターは、呼び出し元のスタックフレームの一部です(注8aを参照)。
注:
8a。 ウィキペディアはパラメータについて別の言い方をしていますが、ここではインテルのソフトウェア開発者向けマニュアルを順守しています。1、セクション6.2.4.1スタックフレームベースポインタおよびセクション6.3.2ファーコールおよびRET操作の図6-2 。関数のパラメーターとスタックフレームは、関数のアクティブ化レコードの一部です(関数ペリログのgenを参照)。
8b。BPポイントから関数パラメーターへの正のオフセットと負のオフセットはローカル変数を指します。これは、8cのデバッグに非常に便利です
。 stack[BP]
前のスタックフレームのアドレスを格納し、stack[stack[BP]]
前のスタックフレームなどを格納します。このチェーンに続いて、まだ戻っていないプログラム内のすべての関数のフレームを見つけることができます。これは、デバッガーがスタック
8dを呼び出すことを示す方法です。myAlgo_noLPR_withAnchor
フレームをセットアップする(古いBPの保存、BPの更新、ローカル用のスペースの予約)の最初の3つの命令は、関数プロローグと呼ばれます。
9.呼び出し規約
スニペット8.1では、パラメータをmyAlgo
右から左にプッシュし、結果をに返しましたAX
。パラメータを左から右に渡して、に戻ることもできBX
ます。または、BXとCXでパラメータを渡し、AXで返します。明らかに、呼び出し元(main()
)と呼び出された関数は、これらすべてのものが格納される場所と順序に同意する必要があります。
呼び出し規約は、パラメーターが渡され、結果が返される方法に関する一連のルールです。
上記のコードでは、cdecl呼び出し規約を使用しています。
- パラメータはスタックに渡され、最初の引数は呼び出し時にスタックの最下位アドレスに渡されます(最後にプッシュされた<...>)。呼び出し元は、呼び出し後にパラメーターをスタックからポップバックする責任があります。
- 戻り値はAXに配置されます
- EBPとESPは
myAlgo_noLPR_withAnchor
、呼び出し元(この場合はmain
関数)が保持する必要があります。これにより、呼び出し元(関数)は、呼び出しによって変更されていないレジスターに依存できます。
- 他のすべてのレジスタ(EAX、<...>)は、呼び出し先が自由に変更できます。呼び出し元が関数呼び出しの前後に値を保持したい場合は、値を他の場所に保存する必要があります(これはAXで行います)
(出典:例えば、スタックオーバーフローのドキュメントから「32ビットCDECL」;著作権2016年までにicktoofayとピーター・コルド。; CC BY-SA 3.0の下でライセンスアンのフルスタックオーバーフローのドキュメントのコンテンツのアーカイブはここで、archive.orgで見つけることができますこの例は、トピックID3261と例ID11196によって索引付けされています。)
10.関数呼び出し
今最も興味深い部分。データと同様に、実行可能コードもメモリに格納され(スタックのメモリとはまったく関係ありません)、すべての命令にアドレスがあります。
特に命令がない場合、CPUはメモリに格納されている順序で命令を次々に実行します。ただし、CPUにメモリ内の別の場所に「ジャンプ」して、そこから命令を実行するように命令することはできます。asmでは任意のアドレスにすることができ、C ++などの高級言語では、ラベルでマークされたアドレスにのみジャンプできます(回避策はありますが、控えめに言ってもきれいではありません)。
この関数を見てみましょう(スニペット#10.1):
int myAlgo_withCalls(int a, int b) {
int t1 = triple(a);
int t2 = triple(b);
return t1 - t2;
}
そして、tripple
C ++の方法を呼び出す代わりに、次のようにします。
tripple
のコードをmyAlgo
本文の先頭にコピーします
- で
myAlgo
エントリー飛び越えますtripple
とのコードをgoto
tripple
のコードを実行する必要がある場合は、tripple
呼び出し直後にコード行のスタックアドレスを保存して、後でここに戻って実行を続行できるようにします(PUSH_ADDRESS
以下のマクロ)
- 1行目(
tripple
関数)のアドレスにジャンプして最後まで実行します(3.と4.一緒にCALL
マクロです)
tripple
(ローカルをクリーンアップした後)の最後に、スタックの一番上からリターンアドレスを取得し、そこにジャンプします(RET
マクロ)
C ++では特定のコードアドレスにジャンプする簡単な方法がないため、ジャンプの場所をマークするためにラベルを使用します。以下のマクロがどのように機能するかについては詳しく説明しません。マクロが私が言うことを実行すると信じてください(スニペット#10.2):
#define PUSH_ADDRESS(labelName) { \
void* tmpPointer; \
__asm{ mov [tmpPointer], offset labelName } \
push(reinterpret_cast<int>(tmpPointer)); \
}
#define TOKENPASTE(x, y) x ## y
#define TOKENPASTE2(x, y) TOKENPASTE(x, y)
#define LABEL_NAME(num) TOKENPASTE2(lbl_, num)
#define CALL_IMPL(funcLabelName, callId) \
PUSH_ADDRESS(LABEL_NAME(callId)); \
goto funcLabelName; \
LABEL_NAME(callId) :
#define CALL(funcLabelName) CALL_IMPL(funcLabelName, __LINE__)
#define RET() { \
int tmpInt; \
pop(tmpInt); \
void* tmpPointer = reinterpret_cast<void*>(tmpInt); \
__asm{ jmp tmpPointer } \
}
void myAlgo_asm() {
goto my_algo_start;
triple_label:
push(BP);
BP = SP;
SP -= 1;
stack[BP - 1] = stack[BP + 2] * 3;
AX = stack[BP - 1];
SP = BP;
pop(BP);
RET();
my_algo_start:
push(BP);
BP = SP;
SP -= 2;
push(AX);
push(stack[BP + 2]);
CALL(triple_label);
stack[BP - 1] = AX;
SP -= 1;
pop(AX);
push(AX);
push(stack[BP + 3]);
CALL(triple_label);
stack[BP - 2] = AX;
SP -= 1;
pop(AX);
AX = stack[BP - 1] - stack[BP - 2];
SP = BP;
pop(BP);
}
int main() {
push(AX);
push(22);
push(11);
push(7777);
myAlgo_asm();
assert(myAlgo_withCalls(11, 22) == AX);
SP += 1;
SP += 2;
pop(AX);
}
注:
10a。リターンアドレスはスタックに格納されているため、原則として変更できます。これがスタックスマッシング攻撃の仕組みです
10b。triple_label
(ローカルのクリーンアップ、古いBPの復元、戻り)の「最後」にある最後の3つの命令は、関数のエピローグと呼ばれます。
11.組み立て
それでは、の実際のasmを見てみましょうmyAlgo_withCalls
。Visual Studioでこれを行うには:
- ビルドプラットフォームをx86に設定します(x86_64ではありません)
- ビルドタイプ:デバッグ
- myAlgo_withCalls内のどこかにブレークポイントを設定します
- 実行し、ブレークポイントで実行が停止したら、Ctrl + Alt + Dを押します。
asmのようなC ++との違いの1つは、asmのスタックがintではなくバイトで動作することです。したがって、1つのスペースを予約するためにint
、SPは4バイトずつデクリメントされます。
ここに行きます(スニペット#11.1、コメントの行番号は要点からのものです):
; 114: int myAlgo_withCalls(int a, int b) {
push ebp ; create stack frame
mov ebp,esp
; return address at (ebp + 4), `a` at (ebp + 8), `b` at (ebp + 12)
sub esp,0D8h ; reserve space for locals. Compiler can reserve more bytes then needed. 0D8h is hexadecimal == 216 decimal
push ebx ; cdecl requires to save all these registers
push esi
push edi
; fill all the space for local variables (from (ebp-0D8h) to (ebp)) with value 0CCCCCCCCh repeated 36h times (36h * 4 == 0D8h)
; see https://stackoverflow.com/q/3818856/264047
; I guess that's for ease of debugging, so that stack is filled with recognizable values
; 0CCCCCCCCh in binary is 110011001100...
lea edi,[ebp-0D8h]
mov ecx,36h
mov eax,0CCCCCCCCh
rep stos dword ptr es:[edi]
; 115: int t1 = triple(a);
mov eax,dword ptr [ebp+8] ; push parameter `a` on the stack
push eax
call triple (01A13E8h)
add esp,4 ; clean up param
mov dword ptr [ebp-8],eax ; copy result from eax to `t1`
; 116: int t2 = triple(b);
mov eax,dword ptr [ebp+0Ch] ; push `b` (0Ch == 12)
push eax
call triple (01A13E8h)
add esp,4
mov dword ptr [ebp-14h],eax ; t2 = eax
mov eax,dword ptr [ebp-8] ; calculate and store result in eax
sub eax,dword ptr [ebp-14h]
pop edi ; restore registers
pop esi
pop ebx
add esp,0D8h ; check we didn't mess up esp or ebp. this is only for debug builds
cmp ebp,esp
call __RTC_CheckEsp (01A116Dh)
mov esp,ebp ; destroy frame
pop ebp
ret
そしてasmfor tripple
(スニペット#11.2):
push ebp
mov ebp,esp
sub esp,0CCh
push ebx
push esi
push edi
lea edi,[ebp-0CCh]
mov ecx,33h
mov eax,0CCCCCCCCh
rep stos dword ptr es:[edi]
imul eax,dword ptr [ebp+8],3
mov dword ptr [ebp-8],eax
mov eax,dword ptr [ebp-8]
pop edi
pop esi
pop ebx
mov esp,ebp
pop ebp
ret
この投稿を読んだ後、アセンブリが以前ほど不可解に見えないことを願っています:)
投稿の本文からのリンクといくつかのさらなる読み物は次のとおりです。