天狗会議録 Posts Pages About

COMの構造とRust FFIで扱う手法

#rust #windowsapi
2023/3/22

背景

東方Project原作STGの大会にTouhou World Cupという大会がある。 これを存分に真似した身内大会が二回開催された。

配信では、「hh:mm:ss」形式でリアルタイムに更新されるテキストファイルを用いて、制限時間を表示する。 このテキストファイルを作るためにSnazというアプリケーションを使っていたが、主催者曰く、動作が悪いらしい。 そのため、同様のことができるタイマーを作ってほしいと依頼を受けた。

諸々理由あってWindows環境でのみ動作すればいいので、QueryPerformanceCounterで一秒を測って随時更新すれば良いだけである。 が、ビジーループは計算リソースが勿体ないので、DirectXで垂直同期を取ることにした。

その上で、せっかくならRustで記述することにした。 当記事に、RustのFFIでCOMを扱う手法を認める。

追記(2023/9/25): 所詮要求する精度は一秒程度なので、OSからだいたい一秒ごとに現在時刻を取得して更新すればいい。 当記事の手法は、ネットに接続されていない状況に対しては有効かもしれないが、マシンのスペックが悪いと処理落ちのために「永遠に時刻がずれた状態」になってしまう。

追記(2023/9/25): ついでに、RustのFFIシグネチャを書くのは本当に面倒なので、時々コンパイルエラーを引き起こすがbindgenを使うといい。

COM

Component Object Model(COM)とは、Windowsを形成する開発体系である。

COMはC++ファーストで記述されている。 このことは、非常に厄介である。 例として、VulkanとDirect3D12のFenceの作成を比較する。

VulkanのAPIは主に、オブジェクトのハンドルと関数だけである。 Fenceを作成するには、vkCreateFence関数の第一引数に論理デバイスVkDeviceを渡す。

vkCreateFence(device, &fence_create_info, NULL, &fence);

一方で、COMのAPIは、オブジェクト指向なインターフェースである。 Fenceを作成するには、ID3D12Device::CreateFenceを呼び出す必要がある。 関数ではなく、メソッドなのである。

しかし、Windows APIはC言語でも扱えるようにされている。 というか、恐らくC++の登場より、COMの登場の方が早いので当然C言語で扱える。 当然ながらC言語はオブジェクト指向をサポートしていない。 そこでCOMは、関数テーブルによってメソッドを実現している。 関数ポインタを大量に持った構造体を持った構造体をインターフェースとしているのである。

このことは、とても有名なので、COMについて少し調べたり、C言語でCOMを扱ったことがあれば、知っているはずである。 C言語でID3D12Device::CreateFenceを呼び出すには以下のように書く。

device->lpVtbl->CreateFence(
    device,
    0,
    D3D12_FENCE_FLAG_NONE,
    __uuidof(ID3D12Fence),
    (void **)&fence
);

で、問題はこれをどうやってRustで実装するのか、ということである。 Rustはオブジェクト指向をサポートしていないため、C言語でのCOMを愚直にFFIしなければならない。 このためには、ヘッダーを読むのが手っ取り早い。

COMインターフェースの定義

例としてID3D12Deviceを取り上げる。 C言語向けのID3D12Deviceは、d3d12.hに以下のように定義されている。

typedef struct ID3D12DeviceVtbl
{
    BEGIN_INTERFACE
    
    DECLSPEC_XFGVIRT(IUnknown, QueryInterface)
    HRESULT ( STDMETHODCALLTYPE *QueryInterface )( 
        ID3D12Device *This,
        REFIID riid,
        _COM_Outptr_  void **ppvObject);
    
    DECLSPEC_XFGVIRT(IUnknown, AddRef)
    ULONG ( STDMETHODCALLTYPE *AddRef )( 
        ID3D12Device *This);
    
    DECLSPEC_XFGVIRT(IUnknown, Release)
    ULONG ( STDMETHODCALLTYPE *Release )( 
        ID3D12Device *This);
    
    DECLSPEC_XFGVIRT(ID3D12Object, GetPrivateData)
    HRESULT ( STDMETHODCALLTYPE *GetPrivateData )( 
        ID3D12Device *This,
        _In_  REFGUID guid,
        _Inout_  UINT *pDataSize,
        _Out_writes_bytes_opt_( *pDataSize )  void *pData);
    
/* 中略 */
        
    DECLSPEC_XFGVIRT(ID3D12Device, GetAdapterLuid)
    LUID *( STDMETHODCALLTYPE *GetAdapterLuid )( 
        ID3D12Device *This,
        LUID *RetVal);
    
    END_INTERFACE
} ID3D12DeviceVtbl;

interface ID3D12Device
{
    CONST_VTBL struct ID3D12DeviceVtbl *lpVtbl;
};

非常に目障りなコードをしている。 およそ、人間向けには書かれていない。 これを、C言語向けにマクロ展開すると以下のようになる。

typedef struct ID3D12DeviceVtbl
{
    HRESULT (__stdcall *QueryInterface)(
        ID3D12Device *This,
        const IID *const riid,
        void **ppvObject);

    ULONG (__stdcall *AddRef)(ID3D12Device *This);

    ULONG (__stdcall *Release)(ID3D12Device *This);
    
    HRESULT (__stdcall *GetPrivateData)(
        ID3D12Device *This,
        const GUID *const guid,
        UINT **pDataSize,
        void *pData);

/* 中略 */

    LUID *(__stdcall *GetAdapterLuid)(ID3D12Device *This, LUID *RetVal);

} ID3D12DeviceVtbl;

struct ID3D12Device
{
    struct ID3D12DeviceVtbl *lpVtbl;
};

関数テーブルを持っているだけである。 インスタンスを生成する際、関数がこのポインタに設定されるわけだから、確かに仮想関数的と言える。 そして、呼び出し規約がstdcallである関数ポインタを構造体に詰められれば、FFIできそうだ。

ちなみに、Direct3D9のCOMの定義はもっといかつい見た目をしているが、流石に今更DX9を使うのはナンセンスなので、書かなくて良いかなあと。

Rustでの表現

愚直にすべてのメソッドを定義するのは億劫であるため、プログラムに使わないメソッドにはダミー型を割り当てることにする。 例えば、絶対にありえないことではあるが、ID3D12Device::CreateFenceだけを用いるプログラムの場合、以下のようにID3D12Deviceを定義すればいい。

type DummyFunction = *const c_void;

#[repr(C)]
struct ID3D12DeviceVtbl {
    QueryInterface: DummyFunction,
    AddRef: DummyFunction,
    Release: DummyFunction,
    GetPrivateData: DummyFunction,
/* 中略 */
    CreateFence: extern "stdcall" fn(
        This: *const ID3D12Device,
        InitialValue: UINT64,
        Flags: D3D12_FENCE_FLAGS,
        riid: *const IID,
        ppFence: *mut *const c_void,
    ) -> HRESULT,
/* 中略 */
    GetAdapterLuid: DummyFunction,
}

#[repr(C)]
struct ID3D12Device {
    lpVtbl: *const ID3D12DeviceVtbl,
}

以下のように呼び出す。

let mut fence = std::ptr::null();
((*(*device).lpVtbl).CreateFence)(device, 0, D3D12_FENCE_FLAG_NONE, &iid, &mut fence);