関数ポインターをキャストし、パラメーターの数を変更するとどうなりますか
-
22-09-2019 - |
質問
私はCの関数ポインターに頭を包み始めたばかりです。関数ポインターのキャストがどのように機能するかを理解するために、次のプログラムを書きました。基本的に、1つのパラメーターを取得する関数への関数ポインターを作成し、3つのパラメーターを持つ関数ポインターにキャストし、関数を呼び出し、3つのパラメーターを提供します。何が起こるか興味がありました:
#include <stdio.h>
int square(int val){
return val*val;
}
void printit(void* ptr){
int (*fptr)(int,int,int) = (int (*)(int,int,int)) (ptr);
printf("Call function with parameters 2,4,8.\n");
printf("Result: %d\n", fptr(2,4,8));
}
int main(void)
{
printit(square);
return 0;
}
これは、エラーや警告なしでコンパイルおよび実行されます(Linux / x86のGCC -wall)。私のシステムの出力は次のとおりです。
Call function with parameters 2,4,8.
Result: 4
したがって、どうやら余分な議論は静かに捨てられています。
今、私はここで本当に何が起こっているのかを理解したいと思います。
- 合法性について:私が答えを理解しているなら 別のタイプへの関数ポインターをキャストします 正しく、これは単に未定義の動作です。それで、これが実行され、合理的な結果を生み出すという事実は、純粋な運です。 (またはコンパイラライターの側の素晴らしさ)
- なぜGCCは壁でさえ、これを私に警告しないのですか?これはコンパイラが検出できないものですか?なんで?
私はJavaから来ています。ここでは、タイプチェックが非常に厳しいので、この振る舞いは私を少し混乱させました。多分私は文化的なショックを経験している:-)。
解決
追加のパラメーターは破棄されません。それらは、3つのパラメーターを期待する関数に呼び出しが行われるかのように、スタックに適切に配置されます。ただし、関数は1つのパラメーターのみを重視しているため、スタックの上部のみを確認し、他のパラメーターに触れません。
この呼び出しが機能したという事実は、2つの事実に基づいて、純粋な運です。
- 最初のパラメーターのタイプは、関数とキャストポインターで同じです。関数を変更して文字列へのポインターを取り、その文字列を印刷しようとすると、コードがメモリ2に対処するためにポインターを繰り返しようとするため、素晴らしいクラッシュが得られます。
- デフォルトで使用される通話規則は、発信者がスタックをクリーンアップすることです。 Calleeがスタックをクリーンアップするように呼び出し規則を変更すると、発信者がスタック上の3つのパラメーターを押してから、Calleeが1つのパラメーターをクリーンアップ(またはむしろ試みる)になります。これは、おそらく腐敗を積み重ねることにつながるでしょう。
コンパイラがこのような潜在的な問題について1つの単純な理由で警告する方法はありません。一般的なケースでは、コンパイル時にポインターの価値がわからないため、指し示す内容を評価できません。関数ポインターが、実行時に作成されたクラス仮想テーブルのメソッドを指していると想像してください。したがって、コンパイラに、3つのパラメーターを持つ関数へのポインターであると伝えます。コンパイラはあなたを信じるでしょう。
他のヒント
車を持ってハンマーとしてキャストする場合、コンパイラは車がハンマーであるが、これは車をハンマーに変えることはないという言葉であなたを連れて行きます。コンパイラは、車を使用して爪を運転することに成功するかもしれませんが、それは実装に依存する幸運です。それはまだ賢明なことです。
はい、それは未定義の動作です - それが「働く」ように見えるなど、何でも起こる可能性があります。
キャストは、コンパイラが警告を発することを防ぎます。また、コンパイラは、未定義の動作を診断するための要件はありません。この理由は、そうすることは不可能であるか、そうすることが難しすぎるか、または多くのオーバーヘッドを引き起こすだろうということです。
キャストの最悪の攻撃は、関数ポインターに対するデータポインターをキャストすることです。関数ポインターとデータポインターのサイズが等しいという保証がないため、署名の変更よりも悪いです。そして、多くに反して 理論的 未定義の動作、これは、高度なマシン(組み込みシステムだけでなく)でも、野生で遭遇する可能性があります。
組み込みプラットフォームでは、さまざまなサイズのポインターに簡単に遭遇する可能性があります。データポインターと機能ポインターがさまざまなもの(1つはRAM、もう1つはROM)、いわゆるハーバードアーキテクチャに対処するプロセッサさえあります。実際のモードのX86では、16ビットと32ビットを混ぜることができます。 WATCOM-Cには、データポインターが幅48ビットのDOSエクステンダーに特別なモードがありました。特にCでは、すべてがPOSIXではないことを知っておく必要があります。これは、Cがエキゾチックなハードウェアで利用可能な唯一の言語である可能性があるためです。
一部のコンパイラは、コードが32ビット以内に保証されている混合メモリモデルを許可し、データは64ビットポインターまたはコンバースでアドレス指定できます。
編集: 結論、関数ポインターに対するデータポインターをキャストしないでください。
動作は、呼び出し条約によって定義されます。発信者がスタックを押してポップする通話コンベンションを使用する場合、この場合はコール中にスタックにさらに数バイトがあることを意味するため、この場合は正常に動作します。現時点ではGCCハンディはありませんが、Microsoft Compiler、このコードを使用して:
int ( __cdecl * fptr)(int,int,int) = (int (__cdecl * ) (int,int,int)) (ptr);
次のアセンブリがコール用に生成されます。
push 8
push 4
push 2
call dword ptr [ebp-4]
add esp,0Ch
通話後にスタックに追加された12バイト(0CH)に注意してください。この後、スタックは問題ありません(この場合、Calleeが__cdeclであると仮定して、スタックもクリーンアップしようとしません)。しかし、次のコードでは:
int ( __stdcall * fptr)(int,int,int) = (int (__stdcall * ) (int,int,int)) (ptr);
add esp,0Ch
アセンブリで生成されません。この場合、Calleeが__cdeclの場合、スタックは破損します。
確かに私は確かにわかりませんが、運が良ければ、あなたは間違いなく行動を利用したくありません また コンパイラ固有の場合。
キャストは明示的であるため、警告に値しません。キャストすることで、コンパイラによく知っていることを通知しています。特に、あなたはaをキャストしています
void*
, 、そしてそのように、あなたは「このポインターで表されたアドレスを取り、この他のポインターと同じようにする」と言っています。同じ。ここでは、それが間違っていることはわかっています。
ある時点で、C呼び出し条約のバイナリレイアウトの記憶をリフレッシュする必要がありますが、これが起こっていることだと確信しています。
- 1:それは純粋な運ではありません。 C呼び出し規則は明確に定義されており、スタック上の追加データはコールサイトの要因ではありませんが、Calleeはそれについて知らないため、Calleeによって上書きされる可能性があります。
- 2:括弧を使用して、「ハード」キャストは、コンパイラに自分が何をしているのか知っていることを伝えています。必要なデータはすべて1つのコンピレーションユニットにあるため、コンパイラはこれが明らかに違法であることを理解するのに十分賢いかもしれませんが、Cの設計者はコーナーケースの検証可能な不正確さをキャッチすることに集中していませんでした。簡単に言えば、コンパイラはあなたが何をしているのかを知っていると信頼しています(おそらく多くのC/C ++プログラマーの場合は不当です!)
あなたの質問に答えるために:
純粋な運 - スタックを簡単に踏みにじり、次の実行コードへの返品ポインターを上書きすることができます。 3つのパラメーターで関数ポインターを指定し、関数ポインターを呼び出したため、残りの2つのパラメーターは「破棄」されたため、動作は未定義です。その2番目または3番目のパラメーターにバイナリ命令が含まれていて、コールプロシージャスタックから飛び出したと想像してください。
あなたが使用していたので警告はありません
void *
ポインターとキャスト。それは、明示的に指定されていても、コンパイラの目には非常に正当なコードです-Wall
スイッチ。 コンパイラは、あなたが何をしているのか知っていると思います! それが秘密です。
これがトムに最も役立つことを願っています。