C#のインスタンスメソッドで関数ポインタを使えない理由

Higtyのシステムの作り方

この記事は以下の記事の日本語訳です。

https://minidump.net/c-why-function-pointers-cant-be-used-on-instance-methods-8a99fc99b040/

by Kevin Gosse

https://x.com/KooKiz

※作者に許可取得済み



なぜ C# でインスタンスメソッドに関数ポインタを使えないのか

数日前、.NET ランタイムの dotnet/runtime リポジトリのイシュー で興味深い質問がありました。

https://github.com/dotnet/runtime/issues/72781


要約すると、イシューの投稿者が「なぜ自分のコードが期待通りに動作しないのか?」と疑問を呈していました。以下に簡略化したコード例を示します。


public unsafe class Getter
{
    private delegate*<Obj, SomeStruct> _functionPointer;

    public Getter(string propName)
    {
        var methodInfo = typeof(Obj).GetProperty(propName).GetGetMethod();
        _functionPointer = (delegate*<Obj, SomeStruct>)methodInfo.MethodHandle.GetFunctionPointer();
    }

    public SomeStruct GetFromFunctionPointer(Obj target)
    {
        var v = _functionPointer(target);
        return v;
    }
}

public struct SomeStruct
{
    public int Value1;
    public int Value2;
    public int Value3;
    public int Value4;
}

public class Obj
{
    public SomeStruct Property { get; set; }
}

class Program
{
    static void Main(string[] args)
    {
        var obj = new Obj { Property = new SomeStruct { Value1 = 42 } };

        var getter = new Getter("Property");

        Console.WriteLine($"Value: {getter.GetFromFunctionPointer(obj).Value1}");
    }
}


上記のコードを実行すると、本来 Value1 が 42 になるはずにもかかわらず、コンソールには Value: 0 が表示されます。さらに驚くべきことに、デバッガを使って実行すると、関数ポインタ呼び出し後に target の値を読めなくなり、返ってきた v が不正な値(いわゆるガーベッジ)で埋まっていることがわかります。


投稿者の観察によると、構造体 (SomeStruct) を参照型に変えたり、関数ポインタではなくデリゲート(methodInfo.CreateDelegate<Func<Obj, SomeStruct>>() のように)を使ったりすると正常に動作するとのこと。少なくとも、値型でかつ関数ポインタが原因のようです。さらに、呼び出しの後に target が不正になる点から、スタックが破壊されている(スタック・コラプション)可能性を示唆しています。


私が実際にこの現象を再現しようとしたところ、最初は再現しませんでした。というのも、最初に試した構造体が非常に小さかったからです。

public struct SomeStruct
{
    public int Value1;
}

この程度のサイズなら問題なく動作しました。しかしフィールド数を増やしてサイズが 8 バイトを超えた頃から再現するようになりました。実験の結果、構造体が 8 バイトより大きいとこの問題が起きるように見えます。先ほどの例でも Value3 が入る辺りから不正値が混ざっていました。64 ビットの環境では、8 バイトはポインタやレジスタの大きさに相当します。このあたりに重要なヒントがあるわけです。


早期診断

ここで、C# の関数ポインタ仕様を改めて確認してみます。

公式ドキュメントには以下のように書かれています(要約):

.NET のマネージド メソッドに対して関数ポインタ呼び出しを行う場合、そのメソッドは static でなければならない。

つまり、インスタンスメソッドには使えないことが仕様として明記されています。実際、プロパティを static に変え、シグネチャを delegate*<Obj, SomeStruct> から delegate*<SomeStruct> のように書き換えると正常に動きます。つまり、このコードは仕様上失敗するのが当たり前なのです。


しかし、それだけでは「なぜ動かないのか?」の根本原因の説明にはなりません。もう少し踏み込んで調べてみましょう。


すべてを繋ぎ合わせる

問題を理解するためには、呼び出し規約 (calling convention) と CLR ABI について少し知る必要があります。

注意: 以下の説明は x64 アーキテクチャを前提としています。x86 や ARM では呼び出し規約が大きく異なります。

メソッドに引数や戻り値がある場合、呼び出し元(caller)と呼び出されるメソッド(callee)の間で「どうやって値を受け渡すか?」を決める必要があります。これを「呼び出し規約 (calling convention)」と呼びます。多くの場合、パフォーマンスを考慮して引数は可能な限りレジスタを使って渡されます。


.NET のマネージド メソッドも同様ですが、引数に構造体が来る場合はサイズ次第で扱い方が変わります。具体的には、8 バイト以下の構造体であればレジスタに収まるので、レジスタを通して値が渡されます。しかし 8 バイトを超えると、引数としてレジスタに収まらないため、呼び出し元は構造体のコピーをメモリ上に作り、そのポインタをレジスタに載せて渡します。実際には「値ではなく“コピーされたメモリ領域への参照”を渡す」という形になります(C# 的には値渡しのように見えますが、ABI レベルではこう実装されているわけです)。


戻り値の構造体が大きい場合も同様に問題が生じます。8 バイトを超える構造体をレジスタで返すことはできないため、メソッド側で返す前にどこかにコピーして、そのアドレスを返す必要があります。ここで採用されているのが「リターンバッファ (return buffer)」という仕組みです。呼び出し元があらかじめスタック上に構造体用の領域を確保し、そのアドレスを「隠し引数 (hidden argument)」としてメソッドに渡します。メソッドはその領域に構造体の内容をコピーしてから戻り先に返す、という流れです。


さらに、.NET のインスタンスメソッドにはもう一つの「隠し引数」が存在します。それは this 引数です。インスタンスメソッドは、静的メソッドには存在しない「自分自身のインスタンス (this)」を受け取るための引数を持っています。


結果として、メソッドによって期待される引数の順番や内容が変わります。たとえば

public class MyClass
{
    public static LargeStruct StaticMethod(MyClass obj)
    {
        // ...
    }

    public LargeStruct InstanceMethod()
    {
        // ...
    }
}

を考えてみましょう。戻り値が 8 バイトを超える LargeStruct だとすると、呼び出しのときはリターンバッファ用のポインタを隠し引数として渡す必要があります。

  1. StaticMethod(MyClass obj) の実際の呼び出しシグネチャ(x64, CLR ABI 下)
  2. リターンバッファ(構造体を格納するメモリ領域)へのポインタ
  3. obj
  4. InstanceMethod() の実際の呼び出しシグネチャ(x64, CLR ABI 下)
  5. this(インスタンスへのポインタ)
  6. リターンバッファ(構造体を格納するメモリ領域)へのポインタ

つまり、インスタンスメソッドか静的メソッドかによって「隠し引数」の並び順が変わるわけです。関数ポインタを使って「引数はこういう並びで呼び出される」と決め打ちしてしまうと、それがインスタンスメソッド用か静的メソッド用かでズレが生じ、結果としてスタック破壊が起こるのです。

delegate*<Obj, SomeStruct> は「最初の引数にリターンバッファ、次の引数に Obj」が来る静的メソッド用の呼び出しを想定しています。しかし実際は「最初の引数が this、次の引数がリターンバッファ」を期待するインスタンスメソッドを呼ぼうとしているため、引数の位置がずれ、メモリが破壊されてしまうというのが問題の本質です。



さらに踏み込んでみる

せっかくなので、理論が正しいことを証明するために、インスタンスメソッドを関数ポインタで呼び出す場合の「隠し引数」を自前でセットして呼び出してみる例を示します。先ほどのプロパティ Property の getter は、C# 的には

public SomeStruct get_Property(Obj instance);

のように見えますが、CLR ABI レベルでは

public SomeStruct* get_Property(Obj instance, SomeStruct* returnBuffer);

が実際のシグネチャになります。そこで、自分で returnBuffer 用の領域を作り、それを引数として渡してあげれば呼び出しがうまくいくはずです。コードは以下のようになります。


public unsafe class Getter
{
    private delegate*<Obj, SomeStruct*, SomeStruct*> _functionPointer;

    public Getter(string propName)
    {
        var methodInfo = typeof(Obj).GetProperty(propName).GetGetMethod();
        _functionPointer = (delegate*<Obj, SomeStruct*, SomeStruct*>)methodInfo.MethodHandle.GetFunctionPointer();
    }

    public SomeStruct GetFromFunctionPointer(Obj target)
    {
        // 戻り値を格納する領域をスタック上に確保
        SomeStruct returnValue = default;

        // 実際は (Obj instance, SomeStruct* returnBuffer) の順で呼ぶ必要がある
        var v = _functionPointer(target, &returnValue);

        // v は戻り値を格納したアドレスを指しているので、参照を外して実際の値を返す
        return *v;
    }
}

もちろん、これはあくまで「こういう仕組みで動いているんだ」という学術的なデモです。実際の運用コードでこのような呼び出し方をすることは推奨されません。また、このコードは x64 前提で書かれているため、x86 や ARM の環境では動作しません(呼び出し規約が異なるため)。


以上をまとめると、

  1. C# の関数ポインタは仕様上、マネージドなインスタンスメソッドとは直接的に結びつけられない。
  2. その理由は、インスタンスメソッドと静的メソッドで 隠し引数の並びthis とリターンバッファの順)が異なるため、ABI レベルで齟齬が起きてしまうから。
  3. 小さい構造体(8 バイト以下)だとレジスタでやり取りされるため問題が表面化しないケースがあるが、より大きい構造体になるとリターンバッファを利用した呼び出しが発生し、スタック破壊のような問題が顕在化する。

これが「なぜ C# でインスタンスメソッドに関数ポインタを使えないのか」の根本的な理由です。



この記事が役に立ったと思ったら

https://buymeacoffee.com/kevingosse

こちらからコーヒー1杯分くらいの寄付を頂けると幸いです