unsigned int one ( unsigned int, unsigned int );
unsigned int two ( unsigned int, unsigned int );
unsigned int myfun ( unsigned int x, unsigned int y, unsigned int z )
{
unsigned int a,b;
a=one(x,y);
b=two(a,z);
return(a+b);
}
コンパイルと逆アセンブル
arm-none-eabi-gcc -c fun.c -o fun.o
arm-none-eabi-objdump -D fun.o
コンパイラによって作成されたコード
00000000 <myfun>:
0: e92d4800 push {fp, lr}
4: e28db004 add fp, sp, #4
8: e24dd018 sub sp, sp, #24
c: e50b0010 str r0, [fp, #-16]
10: e50b1014 str r1, [fp, #-20]
14: e50b2018 str r2, [fp, #-24]
18: e51b0010 ldr r0, [fp, #-16]
1c: e51b1014 ldr r1, [fp, #-20]
20: ebfffffe bl 0 <one>
24: e50b0008 str r0, [fp, #-8]
28: e51b0008 ldr r0, [fp, #-8]
2c: e51b1018 ldr r1, [fp, #-24]
30: ebfffffe bl 0 <two>
34: e50b000c str r0, [fp, #-12]
38: e51b2008 ldr r2, [fp, #-8]
3c: e51b300c ldr r3, [fp, #-12]
40: e0823003 add r3, r2, r3
44: e1a00003 mov r0, r3
48: e24bd004 sub sp, fp, #4
4c: e8bd4800 pop {fp, lr}
50: e12fff1e bx lr
簡単な答えは、メモリはコンパイル時と実行時の両方で「割り当てられる」ということです。コンパイル時にコンパイラがスタック フレームのサイズと誰がどこに行くかを決定するという意味でのコンパイル時。メモリ自体が動的なものであるスタック上にあるという意味でのランタイム。スタック フレームは、malloc() や free() とほぼ同じように、実行時にスタック メモリから取得されます。
呼び出し規則を知っておくと役立ちます。x は r0 に、y は r1 に、z は r2 に入ります。その場合、x は fp-16 に、y は fp-20 に、z は fp-24 にホームを持ちます。次に、one() の呼び出しには x と y が必要なので、スタック (x と y) からそれらをプルします。one() の結果は、a のホームである fp-8 に保存される a に入ります。等々。
関数 one は実際にはアドレス 0 ではありません。これは、リンクされたバイナリではなく、オブジェクト ファイルの逆アセンブリです。オブジェクトが残りのオブジェクトおよびライブラリとリンクされると、外部関数がある場所などの不足部分がリンカーによってパッチされ、one() および two() への呼び出しが実際のアドレスを取得します。(また、プログラムはアドレス 0 から開始されない可能性があります)。
私はここで少しごまかしましたが、コンパイラで最適化が有効になっておらず、このような比較的単純な関数がスタック フレームの理由がないことはわかっていました。
少しだけ最適化してコンパイルする
arm-none-eabi-gcc -O1 -c fun.c -o fun.o
arm-none-eabi-objdump -D fun.o
スタックフレームがなくなり、ローカル変数はレジスタに残ります。
00000000 : 0: e92d4038 push {r3, r4, r5, lr} 4: e1a05002 mov r5, r2 8: ebfffffe bl 0 c: e1a04000 mov r4, r0 10: e1a01005 mov r1, r5 14: ebfffffe bl 0 add 18: e0800004 r0、r0、r4 1c: e8bd4038 ポップ {r3、r4、r5、lr} 20: e12fff1e bx lr
コンパイラが代わりに行うことを決定したのは、スタックに保存することで、より多くのレジスタを使用できるようにすることです。なぜr3を救ったのかは謎ですが、それはまた別の話です...
関数 r0 = x、r1 = y、および r2 = z を呼び出し規則に従って入力すると、r0 と r1 をそのままにしておくことができます (one(y,x) でもう一度試して、何が起こるかを確認します)。再び使用されることはありません。呼び出し規約では、r0-r3 は関数によって破棄される可能性があるため、z を後で保持する必要があるため、r5 に保存する必要があります。one() の結果は、呼び出し規則ごとに r0 です。two() は r0-r3 を破壊する可能性があるため、後で使用するために a を保存する必要があります。two() の呼び出しの後、とにかく two の呼び出しにも r0 が必要なので、r4現在、a を保持しています。one を呼び出す前に z を r5 に保存しました (r2 では r5 に移動しました)。 z を保存した r5 を r1 に移動し、two() を呼び出します。呼び出し規約ごとの two() の結果。基本的な数学プロパティからの b + a = a + b であるため、返される前の最終的な加算は b + a である r0 + r4 であり、結果は規則に従って、関数から何かを返すために使用されるレジスタである r0 に入ります。スタックをクリーンアップし、変更されたレジスタを復元します。
myfun() は bl を使用して他の関数を呼び出したため、bl はリンク レジスタ (r14) を変更します。最終リターン (bx lr) であるため、lr はスタックにプッシュされます。規則では、関数内の r0 ~ r3 を破棄できますが、他のレジスタは破棄できないため、r4 と r5 は使用したためスタックにプッシュされます。なぜr3がスタックにプッシュされるのかは、呼び出し規約の観点からは必要ありません.64ビットメモリシステムを見越して行われたのだろうか.2回の完全な64ビット書き込みは、1回の64ビット書き込みと1回の32ビット書き込みよりも安価です. ただし、スタックの配置を知る必要があるため、これは単なる理論です。このコードで r3 を保持する理由はありません。
この知識を利用して、割り当てられたコードを逆アセンブルし (arm-...-objdump -D something.something)、同じ種類の分析を行います。特に、main() という名前の関数と、main という名前ではない関数 (意図的に main() を使用していません) では、スタック フレームのサイズが意味をなさないか、他の関数よりも意味をなさない可能性があります。上記の最適化されていないケースでは、合計 6 個の x、y、z、a、b とリンク レジスタ 6*4 = 24 バイトを格納する必要があり、結果としてサブ sp、sp、#24 になりました。スタック ポインターとフレーム ポインターの関係について少し説明します。フレームポインターを使用しないようにコンパイラーに指示するコマンドライン引数があると思います。-fomit-frame-pointer を使用すると、いくつかの命令が節約されます
00000000 <myfun>:
0: e52de004 push {lr} ; (str lr, [sp, #-4]!)
4: e24dd01c sub sp, sp, #28
8: e58d000c str r0, [sp, #12]
c: e58d1008 str r1, [sp, #8]
10: e58d2004 str r2, [sp, #4]
14: e59d000c ldr r0, [sp, #12]
18: e59d1008 ldr r1, [sp, #8]
1c: ebfffffe bl 0 <one>
20: e58d0014 str r0, [sp, #20]
24: e59d0014 ldr r0, [sp, #20]
28: e59d1004 ldr r1, [sp, #4]
2c: ebfffffe bl 0 <two>
30: e58d0010 str r0, [sp, #16]
34: e59d2014 ldr r2, [sp, #20]
38: e59d3010 ldr r3, [sp, #16]
3c: e0823003 add r3, r2, r3
40: e1a00003 mov r0, r3
44: e28dd01c add sp, sp, #28
48: e49de004 pop {lr} ; (ldr lr, [sp], #4)
4c: e12fff1e bx lr
最適化すると、さらに多くの節約になります...