C# Native AOT DLLをC/C++から呼び出す方法

· 更新日: · · C#, .NET, Native AOT, C++, Windows開発, ネイティブ連携

更新履歴(5件・最終更新 2026年08月02日)

この記事に加えた変更の記録です。アーカイブした更新前のバージョンは、DOI付きの固定URLから読めます。

記事の冒頭に「この記事の知識マップ」節を追加しました。本文で扱っている概念とその関係を、要約・図・詳細ページへのリンクにまとめたものです。本文の主張は変えていません。
外部レビュー(1283件)への対応として本文を更新しました。個々の変更内容は、この下の履歴を参照してください。
ハンドルの採番が一周したときの説明を直しました。辞書の重複キー例外が助けになるのは同じ値が「いま生きている」ときだけで、破棄済みなら`Add`は成功します。その結果、古いハンドルが無関係な新しいインスタンスを指すようになり、例外もエラーコードも出ません。`2^32`ちょうどでは、予約していたはずの0が払い出される点も追記し、世代番号を埋める方法と使い切ったら永続的に失敗させる方法を示しました。
前提環境の表を追加し、C#のコンパイルではなく最後のネイティブリンクで落ちるという躓き方を明記しました。エクスポートされているかを`dumpbin /exports`で確かめる節、インポートライブラリで静的にリンクする場合の手順、ABIの説明とC#とCの型対応表、ハンドル採番の注意を追加しました。
本文中の関連記事へのリンクの文言が、リンク先の現在のタイトルと食い違っていたのを、実際のタイトルに揃えました。本文の内容は変えていません。
初版公開
この記事を引用する(DOI: 10.5281/zenodo.21589625)

この記事はZenodoにアーカイブされています。常に最新版へ解決されるDOIと、いま表示している版に固定されたDOIの両方を下に示します。

小村 豪(2026)「C# Native AOT DLLをC/C++から呼び出す方法」合同会社小村ソフト. https://doi.org/10.5281/zenodo.21589625 https://staging.comcomponent.com/blog/2026/03/12/003-csharp-native-aot-native-dll-from-c-cpp/

DOI(最新版)
10.5281/zenodo.21589625
DOI(この版)
10.5281/zenodo.21732653

前回の C# からネイティブ DLL を使うなら C++/CLI ラッパーが有力な理由 では、C# から C++ を呼ぶときの境界面を整理しました。今回は向きを逆にして、C/C++ から C# を呼ぶ話です。

C# で書いた処理を既存の C/C++ アプリから呼びたい、でも P/Invoke の向きは逆だし、C++/CLI や COM まで持ち込むほどでもない、という場面があります。特に、ネイティブアプリ本体はそのまま残しつつ、判定ロジック、文字列処理、設定解釈、計算ルールのような部分だけを C# に寄せたいときです。

COM でも橋は架けられますが、今回はもっと in-process で、もっと DLL らしいやり方です。.NET の Native AOT では、クラスライブラリをネイティブ共有ライブラリとして発行でき、UnmanagedCallersOnly を付けたメソッドを C のエントリポイントとして公開できます。つまり、C# を「呼ばれる側のネイティブ DLL」として使えます。

ただし、何でもそのまま越境できるわけではありません。stringList<T>、例外、所有権を境界に漏らすと、急に空気が悪くなります。この記事では、Windows + C++ の最小例で、どんなときにこの構成が刺さるのか、どういう API 形状にすると壊れにくいのかを整理します。Linux / macOS でも考え方はほぼ同じですが、コード例は Windows の DLL を前提にします。

なお、この記事に登場するコードは、ビルド・実行できるサンプル一式(Native AOT で発行する C# ライブラリ、C++ の呼び出し例、ユニットテスト)として GitHub で公開しています。

csharp-native-aot-native-dll-from-c-cpp - komurasoft-blog-samples (GitHub)

目次

  1. まず結論(ひとことで)
  2. まず見る使い分け
  3. 構成図
  4. 最小構成
    • 4.1. C# プロジェクト
    • 4.2. エクスポートする C# コード
    • 4.3. 発行コマンド
    • 4.4. C++ 側の呼び出し例
    • 4.5. export されているかを確かめる
    • 4.6. import lib で静的にリンクしたい場合
  5. 壊れにくい API 形状
    • 5.1. C ABI に寄せる
    • 5.2. 文字列はポインタ + 長さ + バッファ容量で扱う
    • 5.3. 例外を越境させない
    • 5.4. 呼び出し規約を固定する
    • 5.5. Export メソッドは薄くして本体を別に置く
  6. 向いているケース
  7. それでも向かないケース
  8. はまりどころ
  9. まとめ
  10. 参考資料

この記事の知識マップ

C#のNative AOTはUnmanagedCallersOnly属性を付けたメソッドをCのエントリポイントとして公開でき、既存のC/C++アプリからin-processでC#のロジックだけを呼び出せる構成を実現する。ただし境界に出せるのはblittableな型に限られるため、実務ではstringやListを直接見せずcreate/destroy/operateのようなハンドルベースのC APIに落とし、呼び出し規約を明示し、例外の代わりにstatus codeでエラーを表現する設計が推奨される。この構成はC/C++がC#ロジックを呼ぶ向きに適する一方、C#からCを呼ぶならP/Invoke、C#からC++を自然に扱うならC++/CLI、32bit/64bitやプロセス境界を越えたい場合やアンロードしたい場合はCOM/IPCの方が向く。ハンドルの採番を32bit幅で使い回すと一周時に無関係なインスタンスを指す危険があり、世代番号の埋め込みが対策になる。

Native AOTでC#をネイティブDLLとしてC/C++から呼ぶ知識マップNative AOTとUnmanagedCallersOnlyでC#をC/C++から呼ばれるネイティブDLLとして発行できること、境界面をC ABIに落とすための呼び出し規約・blittable型・ハンドル設計・エラー表現の要点、P/Invoke・C++/CLI・COMとの使い分けを示す図利用する前提とするで構成できる利用する前提とする前提とする前提とする推奨される対応推奨される対応推奨される対応推奨される対応用いるのは非推奨用いるのは非推奨推奨される対応推奨される対応原因になり得る防止するで確認できる前提とする用いるのは非推奨用いるのは非推奨原因になり得るNative AOTUnmanagedCallersOnly属性blittable型呼び出し規約(calling convention)C ABI(Application Binary Interface)Native AOT publish用のネイティブツールチェーン.NET(Core以降)C/C++からC#ロジックをin-processで呼ぶ要件P/InvokeC#からCの関数群を呼ぶ要件C++/CLIC#からC++ライブラリを自然に扱う要件COM(Component Object Model)32bit/64bitまたはプロセス境界を越える要件プラグインを後でアンロードしたい要件エラーをstatus codeで返す設計ハンドルベースのC API設計(create/destroy/operate)ハンドル採番カウンターのオーバーフローハンドル一周による衝突世代番号を埋め込んだハンドル設計dumpbinインポートライブラリ(.lib)

図の実線は常に成り立つ関係、破線は条件付きの関係です(成立条件は詳細ページの各関係の説明に記載)。関係すべての一覧(全22件、根拠・確度つき)と主要概念の定義は知識マップ詳細ページにまとめています。データ: JSON-LD / Turtle

1. まず結論(ひとことで)

  • C/C++ から C# の処理を in-process で呼びたいなら、Native AOT + UnmanagedCallersOnly はかなり有力です。
  • ただし、export されるのは あくまで C の関数入口 です。stringList<T> をそのまま見せる世界ではありません。
  • 実務では、create / destroy / operate のようなフラットな C API に落として、寿命管理とエラーコードを明示したほうが安定します。
  • C++ のクラスや STL を自然に扱いたいなら C++/CLI、登録や自動化やプロセス越しが欲しいなら COM のほうが向いています。

要するに、C# をネイティブ DLL の中身として使うことはできるが、境界面は .NET ではなく C ABI として設計する、ということです。ここを割り切れるなら、かなり面白い武器になります。

2. まず見る使い分け

やりたいこと 有力候補 理由
C# から C の関数群を呼ぶ P/Invoke 向きが素直で、いちばん自然です
C# から C++ ライブラリを自然に扱う C++/CLI C++ 型、所有権、例外、std::wstring などを C++ 側で吸収しやすいです
32bit / 64bit やプロセス境界を越える COM / IPC in-process DLL だけでは越えられません
C/C++ から C# ロジックをネイティブ DLL として呼ぶ Native AOT + UnmanagedCallersOnly C の entry point を自前で export できます

この構成が刺さるのは、「ネイティブ側が主役で、C# は部品として呼ばれる」 場面です。ここは P/Invoke や C++/CLI とちょうど向きが違います。

3. 構成図

cdecl の関数呼び出しC / C++ アプリNative AOT で発行した C# DLLUnmanagedCallersOnly 付き exportC# の業務ロジックハンドル表 / 状態管理

見た目はシンプルです。大事なのは、境界面を C の関数に揃える ことです。C# 側の内部実装がクラスでもコレクションでも LINQ でも構いませんが、外に見せる面は flat にします。

4. 最小構成

ここでは、C++ 側から「加算器」を作って値を足し込み、最後に合計を取得する最小例にします。実務では判定エンジンでも、設定解釈でも、簡単な解析器でも構いません。ネイティブ側が handle を持ち、操作関数を順番に呼ぶ 形だと思ってください。

4.1. C# プロジェクト

まずはクラスライブラリを用意します。

<!-- NativeAotSample.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <PublishAot>true</PublishAot>
    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
  </PropertyGroup>
</Project>

ポイントは 2 つです。

  • Native AOT publish を有効にすること
  • ポインタ引数を使うので unsafe を許可すること

この記事のサンプルは net8.0 を前提にしていますが、考え方自体は .NET 9 / 10 でも同じです。

4.2. エクスポートする C# コード

UnmanagedCallersOnly を付けたメソッドが、ネイティブ側から見える入口になります。ここでは handle を整数で払い出して、内部の状態は C# 側の dictionary で管理します。

// NativeExports.cs
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;

namespace KomuraSoft.NativeAotSample;

internal static class NativeStatus
{
    public const int Ok = 0;
    public const int InvalidArgument = -1;
    public const int InvalidHandle = -2;
    public const int UnexpectedError = -3;
}

internal sealed class Accumulator
{
    public long Total { get; private set; }

    public void Add(int value)
    {
        Total += value;
    }
}

internal static class AccumulatorStore
{
    private static readonly object s_gate = new();
    private static readonly Dictionary<nint, Accumulator> s_instances = new();
    private static long s_nextHandle = 0;

    public static int Create(out nint handle)
    {
        try
        {
            var instance = new Accumulator();
            handle = (nint)System.Threading.Interlocked.Increment(ref s_nextHandle);

            lock (s_gate)
            {
                s_instances.Add(handle, instance);
            }

            return NativeStatus.Ok;
        }
        catch
        {
            handle = 0;
            return NativeStatus.UnexpectedError;
        }
    }

    public static int Add(nint handle, int value)
    {
        try
        {
            lock (s_gate)
            {
                if (!s_instances.TryGetValue(handle, out var instance))
                {
                    return NativeStatus.InvalidHandle;
                }

                instance.Add(value);
                return NativeStatus.Ok;
            }
        }
        catch
        {
            return NativeStatus.UnexpectedError;
        }
    }

    public static int GetTotal(nint handle, out long total)
    {
        try
        {
            lock (s_gate)
            {
                if (!s_instances.TryGetValue(handle, out var instance))
                {
                    total = 0;
                    return NativeStatus.InvalidHandle;
                }

                total = instance.Total;
                return NativeStatus.Ok;
            }
        }
        catch
        {
            total = 0;
            return NativeStatus.UnexpectedError;
        }
    }

    public static int Destroy(nint handle)
    {
        try
        {
            lock (s_gate)
            {
                return s_instances.Remove(handle)
                    ? NativeStatus.Ok
                    : NativeStatus.InvalidHandle;
            }
        }
        catch
        {
            return NativeStatus.UnexpectedError;
        }
    }
}

public static unsafe class NativeExports
{
    [UnmanagedCallersOnly(
        EntryPoint = "km_accumulator_create",
        CallConvs = new[] { typeof(CallConvCdecl) })]
    public static int AccumulatorCreate(nint* outHandle)
    {
        if (outHandle == null)
        {
            return NativeStatus.InvalidArgument;
        }

        var status = AccumulatorStore.Create(out var handle);
        *outHandle = handle;
        return status;
    }

    [UnmanagedCallersOnly(
        EntryPoint = "km_accumulator_add",
        CallConvs = new[] { typeof(CallConvCdecl) })]
    public static int AccumulatorAdd(nint handle, int value)
    {
        return AccumulatorStore.Add(handle, value);
    }

    [UnmanagedCallersOnly(
        EntryPoint = "km_accumulator_get_total",
        CallConvs = new[] { typeof(CallConvCdecl) })]
    public static int AccumulatorGetTotal(nint handle, long* outTotal)
    {
        if (outTotal == null)
        {
            return NativeStatus.InvalidArgument;
        }

        var status = AccumulatorStore.GetTotal(handle, out var total);
        *outTotal = total;
        return status;
    }

    [UnmanagedCallersOnly(
        EntryPoint = "km_accumulator_destroy",
        CallConvs = new[] { typeof(CallConvCdecl) })]
    public static int AccumulatorDestroy(nint handle)
    {
        return AccumulatorStore.Destroy(handle);
    }
}

やっていることはかなり素朴です。

  • ネイティブ側に見せるのは intptr_t の handle だけ
  • 状態本体は C# 側で持つ
  • create / add / get / destroy を flat な関数に分解する
  • 返り値はエラーコード、出力値はポインタ引数で返す

この形にしておくと、C# 側の内部実装をあとで差し替えても、C 側の ABI はかなり安定します。

handle の採番について、1 つだけ補足しておきます。サンプルでは採番用のカウンターを long で持ち、Interlocked.Increment の結果を nint にキャストしています。ここには、知っておいたほうがよい性質が 2 つあります。

  • 0 は払い出されません。 カウンターは 0 から始まり、Increment は加算後の値を返すので、最初の handle は 1 です。C++ 側が intptr_t handle = 0; を「まだ持っていない」の印に使えるのは、このためです。
  • 32bit では切り捨てが起きます。 nint はポインタ幅なので、64bit なら 64bit ですが、32bit ビルドでは 32bit です。long から nint へのキャストは上位ビットを黙って捨てるため、採番が 2^32 を超えると値が一周します。24 時間連続で create / destroy を繰り返すような使い方だと、理屈の上では届きます。

一周すると何が起きるか、ここは正確に押さえてください。s_instances.Add(handle, instance) の重複キー例外は、同じ値の handle が「いま生きている」ときしか助けになりません。この API の普通の使われ方は create と destroy の繰り返しで、destroy 済みの handle は辞書から消えています。つまり一周して同じ値に戻ってきたとき、辞書にキーは無いので Add は成功します。その結果、C 側が持ったままの古い handle が、無関係な新しいインスタンスを指すようになります。例外も出ず、エラーコードも返らないので、値だけが静かに壊れます。

もう1つ、2^32 ちょうどの値は下位32ビットが全部 0 なので、「まだ持っていない」の印にしていたはずの 0 が払い出されます。

したがって、重複キーの検査を安全装置として当てにしないでください。32bit を相手にする可能性があるなら、次のどちらかにします。

  • 世代番号を handle に埋める。 下位を連番、上位を世代にして、destroy のたびに世代を進める。同じ連番が回ってきても値が一致しません
  • 使い切ったら永続的に失敗させる。 採番が上限に達したら以後の create をエラーにする。稼働し続ける装置では再起動が必要になりますが、静かに壊れるよりは扱えます

いずれの場合も、nint 幅を超えないよう採番カウンター自体を nint で持ち、0 を払い出さないことを合わせて守ります。

4.3. 発行コマンド

先に前提を 1 つ。Native AOT の publish には、ネイティブのツールチェーンが別途必要です。 PublishAot を付けただけで dotnet publish を打つと、C# のコンパイルではなく最後のネイティブリンクの段階で落ちます。ここが最初の関門です。

環境 必要なもの
Windows Visual Studio 2022 以降。「C++ によるデスクトップ開発」ワークロードを、既定のコンポーネントを全部入れた状態で入れる
Ubuntu 18.04 以降 sudo apt-get install clang zlib1g-dev
Alpine 3.15 以降 sudo apk add clang build-base zlib-dev
Fedora 39 以降 / RHEL 8 以降 sudo dnf install clang zlib-ng-devel zlib-ng-compat-devel zlib-devel
macOS Xcode の Command Line Tools(.NET 8 以降で対応)

この記事は Windows + C++ を前提にしているので、実質「Visual Studio の C++ ワークロードが入っているか」を先に確認してください、という話です。 リンカが見つからない、link.exe 周りで失敗する、といったエラーはたいていここです。

そのうえで、共有ライブラリとして publish します。

dotnet publish -r win-x64 -c Release /p:NativeLib=Shared

これで、bin/Release/net8.0/win-x64/publish/ 配下にネイティブ DLL が出ます。Windows の例なら .dll、Linux なら .so、macOS なら .dylib です。

大事なのは、RID ごとに publish する ことです。win-x64 で作ったものを win-arm64 の前提で使うことはできませんし、呼び出し側と DLL の bitness も揃える必要があります。

4.4. C++ 側の呼び出し例

今回は import lib の話をいったん外して、LoadLibrary / GetProcAddress で素直に呼びます。この形だと、何が export されていて、どういうシグネチャで受けるべきかが見えやすいです。

/* native_api.h */
#pragma once
#include <stdint.h>

enum km_status
{
    KM_STATUS_OK = 0,
    KM_STATUS_INVALID_ARGUMENT = -1,
    KM_STATUS_INVALID_HANDLE = -2,
    KM_STATUS_UNEXPECTED_ERROR = -3
};

typedef int (__cdecl *km_accumulator_create_fn)(intptr_t* out_handle);
typedef int (__cdecl *km_accumulator_add_fn)(intptr_t handle, int value);
typedef int (__cdecl *km_accumulator_get_total_fn)(intptr_t handle, int64_t* out_total);
typedef int (__cdecl *km_accumulator_destroy_fn)(intptr_t handle);
// main.cpp
#include <cstdint>
#include <cstdlib>
#include <iostream>
#include <windows.h>

#include "native_api.h"

template <typename T>
T LoadSymbol(HMODULE module, const char* name)
{
    FARPROC proc = ::GetProcAddress(module, name);
    if (proc == nullptr)
    {
        std::cerr << "GetProcAddress failed: " << name << '\n';
        std::exit(EXIT_FAILURE);
    }

    return reinterpret_cast<T>(proc);
}

int main()
{
    HMODULE module = ::LoadLibraryW(L"NativeAotSample.dll");
    if (module == nullptr)
    {
        std::cerr << "LoadLibraryW failed" << '\n';
        return EXIT_FAILURE;
    }

    auto create = LoadSymbol<km_accumulator_create_fn>(module, "km_accumulator_create");
    auto add = LoadSymbol<km_accumulator_add_fn>(module, "km_accumulator_add");
    auto getTotal = LoadSymbol<km_accumulator_get_total_fn>(module, "km_accumulator_get_total");
    auto destroy = LoadSymbol<km_accumulator_destroy_fn>(module, "km_accumulator_destroy");

    intptr_t handle = 0;
    if (create(&handle) != KM_STATUS_OK)
    {
        std::cerr << "create failed" << '\n';
        return EXIT_FAILURE;
    }

    if (add(handle, 10) != KM_STATUS_OK)
    {
        std::cerr << "add(10) failed" << '\n';
        return EXIT_FAILURE;
    }

    if (add(handle, 20) != KM_STATUS_OK)
    {
        std::cerr << "add(20) failed" << '\n';
        return EXIT_FAILURE;
    }

    std::int64_t total = 0;
    if (getTotal(handle, &total) != KM_STATUS_OK)
    {
        std::cerr << "get_total failed" << '\n';
        return EXIT_FAILURE;
    }

    std::cout << "total = " << total << '\n';

    if (destroy(handle) != KM_STATUS_OK)
    {
        std::cerr << "destroy failed" << '\n';
        return EXIT_FAILURE;
    }

    handle = 0;

    // Native AOT の共有ライブラリはアンロード前提では使わない。
    // FreeLibrary(module);

    return EXIT_SUCCESS;
}

この例だと、C++ 側から見えるのは「関数ポインタで呼べる C API」だけです。中が C# で書かれていることは、ほとんど意識しなくて済みます。

publish した DLL を main.exe と同じフォルダーに置いて実行すると、10 と 20 を足しているので、標準出力はこれだけです。

total = 30

途中で失敗した場合は、std::cerr 側にどの段階で落ちたかが出ます。LoadLibraryW failed なら DLL がそもそも見つかっていない、GetProcAddress failed: km_accumulator_add なら DLL は読めているが export が見つかっていない、という切り分けです。

4.5. export されているかを確かめる

「呼べない」ときは、まず DLL 側に本当に名前が出ているか を見ます。Visual Studio の Developer Command Prompt で dumpbin を使うのが手早いです。

dumpbin /exports NativeAotSample.dll

km_accumulator_create / km_accumulator_add / km_accumulator_get_total / km_accumulator_destroy の 4 つが name の一覧に並んでいれば、C# 側の発行は成功しています。名前で絞るなら、こうです。

dumpbin /exports NativeAotSample.dll | findstr km_

ここに名前が出ていないなら C# 側の問題、出ているのに GetProcAddress が失敗するなら呼び出し側の問題、と切り分けられます。

GetProcAddressNULL を返すときに見る場所は、だいたい次の順です。

  1. dumpbin /exports に名前が出ているか(出ていなければ C# 側の話)
  2. EntryPoint に書いた文字列と、GetProcAddress に渡した文字列が完全に一致しているか(大文字小文字も区別されます)
  3. 呼び出し側 EXE と DLL の bitness が揃っているか
  4. UnmanagedCallersOnly を付けたメソッドが static で、generic の中に入っていないか
  5. その属性を publish 対象のアセンブリ側 に書いているか(参照先ライブラリに書いても表には出ません)

なお、LoadLibraryW 自体が失敗する場合は export の話ではありません。DLL のパス、bitness、依存 DLL の不足を先に疑ってください。

4.6. import lib で静的にリンクしたい場合

ここまでは LoadLibrary / GetProcAddress 方式で書きました。何が export されていて、どのシグネチャで受けるべきかが見えやすいからです。

一方、実務では「ヘッダーを include して、そのまま関数を呼びたい」ことも多いはずです。その場合は import library を使った静的ロードになります。手順としては、

  1. publish 出力に import library(.lib)が出ていれば、それをリンクします
  2. 出ていなければ、export 名を並べた .def ファイルを用意して、lib.exe /def:NativeAotSample.def /out:NativeAotSample.lib /machine:x64 で import library を作ります
  3. ヘッダー側は関数ポインタ型ではなく、通常の関数宣言にします
/* native_api_static.h */
#pragma once
#include <stdint.h>

#ifdef __cplusplus
extern "C" {
#endif

int __cdecl km_accumulator_create(intptr_t* out_handle);
int __cdecl km_accumulator_add(intptr_t handle, int value);
int __cdecl km_accumulator_get_total(intptr_t handle, int64_t* out_total);
int __cdecl km_accumulator_destroy(intptr_t handle);

#ifdef __cplusplus
}
#endif

この形にすると、呼び出し側のコードはかなり素直になります。その代わり、DLL が見つからないと プロセス起動時点で 落ちるので、「本体は動くが、この機能だけ使えない」という運用にはしづらくなります。プラグイン的に差し込みたいなら LoadLibrary 方式のままのほうが扱いやすいです。

なお、静的ライブラリ(NativeLib=Static)としての発行は公式にはサポートされていないので、そちらは当てにしないほうが安全です。

5. 壊れにくい API 形状

Native AOT で export できるのは面白いのですが、実務では 何を export しないか のほうが大事です。

5.1. C ABI に寄せる

先に言葉の整理をしておきます。この記事の核心は「境界面を .NET ではなく C ABI として設計する」ですが、その ABI は Application Binary Interface の略で、コンパイル済みのバイナリ同士が、実行時にどう噛み合うかの約束事 です。ソースコードのレベルではなく、機械語のレベルの約束、と思ってください。中身は主に 3 つあります。

約束 何を決めるか ここで守らないとどうなるか
呼び出し規約(calling convention) 引数をレジスタとスタックのどちらでどう渡すか、戻り値をどこに置くか、呼び出し後にスタックを戻すのは呼ぶ側か呼ばれる側か 引数がずれる、戻った直後にスタックが壊れる
型のレイアウト 各型が何バイトか、struct のメンバーがどの位置に並ぶか(パディングとアラインメント) 構造体の途中から値が読めなくなる
名前とリンク export される関数名の綴り、装飾(デコレーション)の有無 GetProcAddress が名前を見つけられない

cdeclstdcall は、このうち 1 つ目「呼び出し規約」の名前です。C++ のクラスや例外は、この 3 つの約束がコンパイラごとに違うため、そのまま境界に出すと噛み合いません。逆に言えば、C の関数と基本型だけに絞れば、約束が単純なので噛み合わせやすい ということです。「C ABI に寄せる」は、この単純な約束の範囲まで境界面を落とす、という意味になります。

そのうえで、境界に出す型は、最初から次のあたりに寄せたほうが穏やかです。

  • int32_t / int64_t / double のような基本型
  • 固定レイアウトの struct
  • intptr_t / void* 相当の handle
  • uint8_t* と長さ

逆に、最初から外に漏らしたくないのはこのあたりです。

  • string
  • object
  • List<T>
  • Task
  • Span<T>
  • C++ のクラスや std::vectorstd::wstring

このへんをそのまま越境させようとすると、境界面がすぐ濁ります。C# の都合を C++ に漏らさず、C++ の都合も C# に漏らしすぎない というのが大事です。

写経ミスを減らすために、対応表も置いておきます。UnmanagedCallersOnly を付けたメソッドのシグネチャには blittable な型しか使えないので、実質この範囲に収まります。

C# 側 C / C++ 側 補足
byte / sbyte uint8_t / int8_t  
short / ushort int16_t / uint16_t  
int / uint int32_t / uint32_t  
long / ulong int64_t / uint64_t C++ の long は Windows で 32bit、Linux の LP64 で 64bit なので、long とは書かず int64_t を使うのが安全です
nint / nuint intptr_t / uintptr_t ポインタ幅。32bit ビルドでは 32bit になります
float / double float / double  
bool 使わない blittable ではありません。int32_t で 0 / 1 を渡します
char / string 使わない 文字列は 5.2 のとおり、ポインタ + 長さで扱います
T*(unsafe ポインタ) T* 出力値はこれで返します
固定レイアウトの struct 同じレイアウトの struct メンバーの順序、型、パディングを両側で必ず一致させます

5.2. 文字列はポインタ + 長さ + バッファ容量で扱う

文字列をやり取りしたくなったら、いきなり string を出したくなりますが、ここはぐっとこらえたほうがよいです。ライブラリ境界では、たとえば次のような形に落とすのが分かりやすいです。

int km_parse_utf8(const uint8_t* text, int32_t text_len, int32_t* out_value);
int km_format_utf8(int32_t value, uint8_t* buffer, int32_t buffer_len, int32_t* out_written);

文字コード、長さ、誰がバッファを確保するか を先に決めておく、ということです。Windows だから UTF-16 に寄せる選択肢もありますが、他言語まで見据えるなら UTF-8 のほうが扱いやすいことが多いです。

5.3. 例外を越境させない

ネイティブの関数境界は、例外の表現としてはあまり親切ではありません。少なくとも、managed 例外をそのまま呼び出し元へ漏らす設計にはしない ほうが安全です。

実務では、

  • 戻り値は status code
  • 実データは out バッファやポインタ引数
  • 必要なら get_last_error 形式で追加情報を取得

のようにしておくと扱いやすいです。

派手ではありませんが、こういう地味な設計が後で効きます。境界面で急に格闘技を始めない、ということです。

5.4. 呼び出し規約を固定する

サンプルでは CallConvCdecl を明示しました。省略するとプラットフォーム既定の呼び出し規約になりますが、ヘッダや関数ポインタ型を固定したいなら、こちらで明示してしまったほうが事故りにくい です。

特に x86 を相手にする可能性があると、ここを曖昧にすると後でつらくなります。x64 では表面化しにくくても、ルールを最初に決めておくほうがよいです。

5.5. Export メソッドは薄くして、本体を別に置く

UnmanagedCallersOnly を付けたメソッドは、通常の managed コードからそのまま呼ぶ前提ではありません。なので、そこに業務ロジックを全部書き始めると、テストもしづらくなります。

サンプルでも、実体の管理は AccumulatorStore に置き、export される NativeExports は薄い入口だけにしています。これはかなり大事です。

  • export メソッド: ABI の窓口
  • 内部クラス: ふつうの C# ロジック

この分業にしておくと、C++ との境界と、C# の本体コードを分けて考えられます。

6. 向いているケース

この構成がかなり気持ちよくハマるのは、こんな場面です。

  • 既存の C/C++ アプリは残したまま、一部の業務ロジックだけ C# に寄せたい
  • .NET ランタイムの事前インストールを配布前提にしたくない
  • export する関数面を小さく保てる
  • 将来的に Rust や Go など、他言語からも同じ C API で呼びたくなるかもしれない

特に、ネイティブアプリはそのままで、差し替えやすいロジック層だけを C# で書く という構成には相性がよいです。UI や装置制御は C++ のまま、判定や計算や設定ルールは C#、という切り分けです。

7. それでも向かないケース

もちろん、これは万能ではありません。向かない場面もはっきりあります。

  • C++ のクラスや std::vector や例外をそのまま扱いたい
    • こういうときは C++/CLI かネイティブ側ラッパーのほうが自然です。
  • COM 登録、VBA / Office 自動化、Explorer 拡張のような世界に入りたい
    • ここは COM の文脈で考えたほうがよいです。
  • 32bit / 64bit を橋渡ししたい、またはプロセス境界を越えたい
    • in-process DLL ではなく、COM / IPC / 別プロセス構成のほうが筋がよいです。
  • プラグインをあとでアンロードしたい
    • Native AOT の共有ライブラリはアンロード前提では使わないほうがよいです。
  • 依存ライブラリが強く reflection や動的コード生成に依存している
    • AOT publish の warning が出るなら、その warning を雑に無視しないほうが安全です。

結局は、C ABI で割り切れるかどうか が分水嶺です。割り切れないなら、別の橋のほうがきれいです。

8. はまりどころ

最後に、Native AOT export で地味にはまりやすい点をまとめます。

  • UnmanagedCallersOnly を付けるメソッドは static である必要があります。
  • generic メソッドや generic class の中には置けません。
  • named export にしたいなら EntryPoint を付けます。
  • ref / in / out は使わず、ポインタ引数で返す形にしたほうがよいです。
  • export されるのは publish 対象アセンブリ側のメソッドです。参照先ライブラリのメソッドに属性を付けても、そのままでは表に出ません。
  • 呼び出し側と DLL の bitness は揃える必要があります。
  • publish warning はかなり重要です。AOT / trimming の warning が出ているなら、先にそこを片付けたほうが安全です。

このあたりは、どれも「知ってしまえばそうですよね」という話です。ですが、知らない状態で一度踏むと、かなり渋い時間が流れます。

9. まとめ

C/C++ から C# を呼びたいとき、まず思いつくのは COM だったり、C++/CLI だったり、別プロセスだったりします。どれも正しい選択肢です。

ただ、in-process のネイティブ DLL として C# の処理を差し込みたい なら、Native AOT + UnmanagedCallersOnly はかなり面白い選択肢です。

ポイントをもう一度だけ挙げておきます。

  • C# をそのまま見せるのではなく、C ABI に flatten する
  • handle ベースで寿命管理を明示する
  • 例外ではなく error code で境界を越える
  • 呼び出し規約を固定する
  • export メソッドは薄くして、内部ロジックと分ける

やっていることは派手ではありません。ですが、こういう「境界をどう切るか」は、後の保守性にかなり効きます。ネイティブ資産を活かしつつ、ロジック層だけ C# の生産性を持ち込みたいとき、この構成は覚えておいて損がありません。

10. 参考資料

同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。

このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。

この記事は次のサービスページにつながります。近い入口からご覧ください。

よくある質問

この記事のテーマについて、相談時によくある質問をまとめています。

C++からC#のコードを呼び出せますか?
呼び出せます。.NETのNative AOTではC#のクラスライブラリをネイティブ共有ライブラリとして発行でき、UnmanagedCallersOnlyを付けたメソッドをCのエントリポイントとして公開できます。つまりC#を「呼ばれる側のネイティブDLL」としてC/C++からin-processで使えます。
この構成はどんな場面に向いていますか?
ネイティブアプリ本体はそのまま残しつつ、判定ロジック、文字列処理、設定解釈、計算ルールのような部分だけをC#に寄せたい場面です。「ネイティブ側が主役で、C#は部品として呼ばれる」向きが特徴です。逆にC#からCの関数群を呼ぶならP/Invoke、C++の型や所有権を自然に扱いたいならC++/CLI、32bit/64bitやプロセス境界を越えるならCOM/IPCが向いています。
APIを設計するときの注意点は何ですか?
exportされるのはあくまでCの関数入口なので、stringやList<T>や例外を境界にそのまま見せてはいけません。create / destroy / operateのようなフラットなC APIに落として寿命管理とエラーコードを明示し、文字列はポインタ+長さ+バッファ容量で扱い、例外を越境させず、呼び出し規約を固定します。境界面は.NETではなくC ABIとして設計するのが要点です。
動作するサンプルコードはありますか?
あります。GitHubのkomurasoft-blog-samplesリポジトリに、Native AOTで発行するC#ライブラリ、C++の呼び出し例、ユニットテストを含むビルド・実行できるサンプル一式が公開されています。コード例はWindowsのDLLを前提にしていますが、考え方はLinux / macOSでもほぼ同じです。

著者プロフィール

記事の著者プロフィールページです。

小村 豪

合同会社小村ソフト 代表

Windows ソフト開発、技術相談、不具合調査を中心に、既存資産が残る案件や原因が見えにくい障害調査に強みがあります。

ブログ一覧に戻る