次のコードがあります。 2つのint32を取る関数があります。次に、それへのポインターを受け取り、3つのint8を受け取る関数にキャストして呼び出します。実行時エラーが予想されましたが、プログラムは正常に動作します。なぜこれが可能か?
main.cpp:
#include <iostream>
using namespace std;
void f(int32_t a, int32_t b) {
cout << a << " " << b << endl;
}
int main() {
cout << typeid(&f).name() << endl;
auto g = reinterpret_cast<void(*)(int8_t, int8_t, int8_t)>(&f);
cout << typeid(g).name() << endl;
g(10, 20, 30);
return 0;
}
出力:
PFviiE
PFvaaaE
10 20
ご覧のとおり、最初の関数のシグネチャには2つの整数が必要で、2番目の関数には3つの文字が必要です。 Charはintよりも小さく、なぜaとbがまだ10と20に等しいのか疑問に思いました。
他の人が指摘したように、これは未定義の動作であるため、原則として何が起こるかについてはすべての賭けがずれています。しかし、x86マシンを使用していると仮定すると、なぜこれが発生するのかについてはもっともらしい説明があります。
X86では、g ++コンパイラーは常にスタックにプッシュして引数を渡すわけではありません。代わりに、最初のいくつかの引数をレジスターに隠しておきます。 f
関数を逆アセンブルする場合、最初のいくつかの命令が引数をレジスターから移動し、明示的にスタックに移動することに注意してください。
Push rbp
mov rbp, rsp
sub rsp, 16
mov DWORD PTR [rbp-4], edi # <--- Here
mov DWORD PTR [rbp-8], esi # <--- Here
# (many lines skipped)
同様に、呼び出しがmain
でどのように生成されるかに注意してください。引数はこれらのレジスタに配置されます。
mov rax, QWORD PTR [rbp-8]
mov edx, 30 # <--- Here
mov esi, 20 # <--- Here
mov edi, 10 # <--- Here
call rax
レジスタ全体が引数を保持するために使用されているため、引数のサイズはここでは関係ありません。
さらに、これらの引数はレジスタを介して渡されるため、スタックのサイズを誤って変更する心配はありません。一部の呼び出し規約(cdecl
)は呼び出し側にクリーンアップを任せますが、他の呼び出し規約(stdcall
)は呼び出し先にクリーンアップを要求します。ただし、スタックは変更されないため、どちらも重要ではありません。
他の人が指摘したように、それはおそらくndefined behaviorですが、古い学校のCプログラマーはこのタイプのことを知っています。
また、私がこれから言おうとしている訴訟文書や法廷請願書を起草している言語弁護士を感じることができるので、undefined behavior discussion
の呪文を唱えます。靴を一緒にタップしながらundefined behavior
を3回言うとキャストされます。そして、それは言語弁護士を消滅させるので、なぜ奇妙なことが起訴されずにうまくいくのかを説明できます。
私の答えに戻る:
以下で説明するのは、コンパイラ固有の動作です。私のシミュレーションはすべて、32ビットx86コードとしてコンパイルされたVisual Studioを使用しています。私はそれが同様の32ビットアーキテクチャ上のgccとg ++で同じように動作すると思います。
コードがたまたま機能する理由といくつかの警告がここにあります。
関数呼び出し引数がスタックにプッシュされると、逆の順序でプッシュされます。 f
が正常に呼び出されると、コンパイラはb
引数の前にa
引数をスタックにプッシュするコードを生成します。これは、printfなどの可変引数関数を容易にするのに役立ちます。したがって、関数f
がa
およびb
にアクセスしているときは、スタックの一番上の引数にアクセスしているだけです。 g
を介して呼び出されたときに、スタック(30)に追加の引数がプッシュされましたが、最初にプッシュされました。次に20がプッシュされ、次にスタックの一番上にある10がプッシュされました。 f
はスタックの上位2つの引数のみを調べています。
IIRCは、少なくともクラシックANSI Cでは、charsとshortsであり、スタックに配置される前に常にintに昇格されます。そのため、g
を指定して呼び出すと、リテラル10と20が8ビットの整数ではなくフルサイズの整数としてスタックに配置されます。ただし、f
を再定義して32ビットの整数ではなく64ビットのlongを取得すると、プログラムの出力が変化します。
void f(int64_t a, int64_t b) {
cout << a << " " << b << endl;
}
この結果、メインから出力が得られます(コンパイラーを使用)
85899345930 48435561672736798
そして、あなたが16進に変換するならば:
140000000a effaf00000001e
14
は20
で、0A
は10
です。そして、私は1e
があなたの30
がスタックにプッシュされていると思います。そのため、引数はg
を介して呼び出されたときにスタックにプッシュされましたが、コンパイラ固有の方法で変更されました。 (未定義の動作再び、引数がプッシュされたことがわかります)。
printf
は、実際に渡された引数の数がわからないため、呼び出し元に依存してスタックを修正します。戻り値。したがって、g
を介して呼び出すと、コンパイラは3つの整数をスタックにプッシュするコードを生成し、関数を呼び出してから、同じ値をポップするコードを生成します。その瞬間、コンパイラオプションを変更して、呼び出し先にスタックをクリーンアップさせます(Visual Studioでは__stdcall
)。 void __stdcall f(int32_t a, int32_t b) {
cout << a << " " << b << endl;
}
今、あなたは明らかに未定義の行動領域にいます。 g
を介して呼び出すと、3つのint引数がスタックにプッシュされましたが、コンパイラーはf
のコードを生成するだけで、戻り時にスタックから2つのint引数をポップしました。スタックポインターは、戻り時に破損しています。
他の人が指摘したように、それは完全に未定義の動作であり、あなたが得るものはコンパイラに依存します。スタックを使用せず、パラメーターを渡すためにレジスターを使用する特定の呼び出し規約がある場合にのみ機能します。
Godboltを使用して生成されたアセンブリを確認しました。これは完全にチェックできます ここ
関連する関数呼び出しは次のとおりです。
mov edi, 10
mov esi, 20
mov edx, 30
call f(int, int) #clang totally knows you're calling f by the way
スタックにパラメーターをプッシュするのではなく、単にパラメーターをレジスターに入れます。最も興味深いのは、mov
命令がレジスタの下位8ビットだけを変更するのではなく、32ビットの移動であるため、それらすべてを変更することです。これは、以前のレジスタの内容に関係なく、fのように32ビットを読み取るときに常に正しい値を取得することも意味します。
32ビットの移動の理由がわからない場合、x86またはAMD64アーキテクチャでは、ほとんどすべての場合、コンパイラーは常に32ビットのリテラル移動または64ビットのリテラル移動のいずれかを使用します(値が大きすぎる場合のみ) 32ビット用)。 8ビット値を移動しても、レジスタの上位ビット(8〜31)はゼロになりません。そのため、値が昇格してしまうと問題が発生する可能性があります。 32ビットのリテラル命令を使用する方が、最初にレジスタをゼロにする1つの追加命令よりも簡単です。
ただし、8ビットのパラメーターがあるかのように実際にf
を呼び出そうとしているため、大きな値を指定すると、リテラルが切り捨てられます。たとえば、1000
の下位ビットは-24
であるため、1000
はE8
になり、符号付き整数を使用する場合は-24
になります。また、警告が表示されます
<source>:13:7: warning: implicit conversion from 'int' to 'signed char' changes value from 1000 to -24 [-Wconstant-conversion]
最初のCコンパイラは、C標準の発行に先立つほとんどのコンパイラと同様に、引数を右から左にプッシュして関数呼び出しを処理し、プラットフォームの「call subroutine」命令を使用して関数を呼び出します。次に、関数が返された後、プッシュされた引数をポップします。関数は、 "call"命令によってプッシュされた情報の直後から順番に引数にアドレスを割り当てます。
引数をポップする責任が通常呼び出される関数にある(そして、正しい数の引数をプッシュしないとスタックが破損することが多い)Classic Macintoshなどのプラットフォームでも、Cコンパイラは通常、最初のように動作する呼び出し規約を使用しましたCコンパイラ。他の言語(Pascalなど)で記述されたコードを呼び出すとき、または呼び出される関数で、「Pascal」修飾子が必要でした。
標準以前に存在していた言語のほとんどの実装では、関数を書くことができます:
_int foo(x,y) int x,y
{
printf("Hey\n");
if (x)
{ y+=x; printf("y=%d\n", y); }
}
_
そしてそれを例えばfoo(0)
またはfoo(0,0)
。前者は少し高速です。たとえば、次のように呼びます。 foo(1);
はスタックを破壊する可能性がありますが、関数がオブジェクトy
を使用したことがない場合は、渡す必要はありませんでした。ただし、そのようなセマンティクスのサポートはすべてのプラットフォームで実用的であるとは限らず、ほとんどの場合、引数検証の利点がコストを上回るため、標準では実装がそのパターンをサポートできる必要はありませんが、パターンをサポートできるものは許可されますそうすることで言語を拡張するのに便利です。