Eine C# Native AOT DLL aus C/C++ aufrufen

· · C#, .NET, Native AOT, C++, Windows-Entwicklung, Native Interop

Im vorherigen Beitrag Warum ein C++/CLI-Wrapper eine starke Wahl ist, um native DLLs aus C# zu verwenden haben wir die Grenzfläche beim Aufruf von C++ aus C# geordnet. Diesmal drehen wir die Richtung um: Es geht um den Aufruf von C# aus C/C++.

Es gibt Situationen, in denen Sie in C# geschriebene Logik aus einer bestehenden C/C++-Anwendung aufrufen möchten – aber P/Invoke geht in die falsche Richtung, und C++/CLI oder COM ins Spiel zu bringen, wäre überzogen. Das kommt besonders vor, wenn die native Anwendung selbst unverändert bleiben soll und nur Teile wie Entscheidungslogik, Zeichenkettenverarbeitung, Konfigurationsauswertung oder Berechnungsregeln nach C# ausgelagert werden sollen.

COM kann diese Brücke ebenfalls schlagen, aber hier geht es um einen stärker in-process gehaltenen, DLL-artigeren Weg. Mit .NETs Native AOT lässt sich eine Klassenbibliothek als native gemeinsam genutzte Bibliothek veröffentlichen, und mit UnmanagedCallersOnly versehene Methoden lassen sich als C-Einstiegspunkte offenlegen. Mit anderen Worten: Sie können C# als „die aufgerufene native DLL“ verwenden.

Allerdings kann nicht alles einfach unverändert die Grenze überschreiten. Lassen Sie string, List<T>, Exceptions oder Eigentümerschaft an die Grenze durchsickern, verschlechtert sich die Stimmung schlagartig. In diesem Artikel ordnen wir anhand eines minimalen Beispiels mit Windows + C++, wann dieser Aufbau wirklich trifft und welche API-Formen sich als robust erweisen. Unter Linux / macOS gilt nahezu dasselbe Denken, die Codebeispiele setzen aber eine Windows-DLL voraus.

Der gesamte in diesem Artikel gezeigte Code ist außerdem als bau- und lauffähiges Beispielpaket (die mit Native AOT veröffentlichte C#-Bibliothek, ein C++-Aufrufbeispiel und Unit-Tests) auf GitHub veröffentlicht.

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

Inhaltsverzeichnis

  1. Das Fazit zuerst (in einem Satz)
  2. Die Entscheidungshilfe im Überblick
  3. Architekturdiagramm
  4. Minimalaufbau
    • 4.1. Das C#-Projekt
    • 4.2. Der exportierte C#-Code
    • 4.3. Der Publish-Befehl
    • 4.4. Aufruf aus C++
    • 4.5. Prüfen, ob der Export vorhanden ist
    • 4.6. Statisches Linken mit einer Importbibliothek
  5. API-Formen, die nicht so leicht brechen
    • 5.1. Sich am C-ABI orientieren
    • 5.2. Zeichenketten als Zeiger + Länge + Puffergröße behandeln
    • 5.3. Exceptions nicht über die Grenze lassen
    • 5.4. Die Aufrufkonvention festlegen
    • 5.5. Exportierte Methoden dünn halten und die Logik auslagern
  6. Fälle, für die es sich eignet
  7. Fälle, für die es sich trotzdem nicht eignet
  8. Fallstricke
  9. Zusammenfassung
  10. Quellen

1. Das Fazit zuerst (in einem Satz)

  • Wenn Sie C#-Logik in-process aus C/C++ aufrufen möchten, ist Native AOT + UnmanagedCallersOnly eine ziemlich starke Option.
  • Was exportiert wird, ist jedoch strikt ein C-Funktionseinstieg. Das ist keine Welt, in der string oder List<T> unverändert sichtbar werden.
  • In der Praxis ist es stabiler, alles auf eine flache C-API wie create / destroy / operate herunterzubrechen und Lebensdauerverwaltung sowie Fehlercodes explizit zu machen.
  • Wenn Sie C++-Klassen und die STL natürlich handhaben möchten, ist C++/CLI die bessere Wahl; wenn Sie Registrierung, Automatisierung oder Aufrufe über Prozessgrenzen hinweg brauchen, eignet sich COM besser.

Kurz gesagt: Sie können C# als das Innenleben einer nativen DLL verwenden, aber die Grenzfläche muss als C-ABI entworfen werden, nicht als .NET. Wenn Sie diesen Kompromiss akzeptieren können, wird daraus ein wirklich interessantes Werkzeug.

2. Die Entscheidungshilfe im Überblick

Was Sie erreichen wollen Starker Kandidat Grund
Aus C# eine Gruppe von C-Funktionen aufrufen P/Invoke Die Richtung ist unkompliziert und am natürlichsten
Aus C# eine C++-Bibliothek natürlich handhaben C++/CLI C++-Typen, Eigentümerschaft, Exceptions, std::wstring und Ähnliches lassen sich auf der C++-Seite leicht auffangen
32-Bit-/64-Bit- oder Prozessgrenzen überschreiten COM / IPC Eine reine in-process-DLL kann diese Grenzen nicht überschreiten
Aus C/C++ C#-Logik als native DLL aufrufen Native AOT + UnmanagedCallersOnly Sie können eigene C-Einstiegspunkte exportieren

Dieser Aufbau trifft genau dann, wenn „die native Seite die Hauptrolle spielt und C# als Baustein aufgerufen wird“. Das ist genau die entgegengesetzte Richtung zu P/Invoke und C++/CLI.

3. Architekturdiagramm

cdecl-FunktionsaufrufeC / C++ AnwendungMit Native AOT veröffentlichte C# DLLMit UnmanagedCallersOnly versehene ExportsC#-FachlogikHandle-Tabelle / Zustandsverwaltung

Das Bild ist einfach. Wichtig ist, die Grenzfläche auf C-Funktionen auszurichten. Ob die interne Implementierung auf der C#-Seite aus Klassen, Collections oder LINQ besteht, spielt keine Rolle – die nach außen gezeigte Fläche bleibt flach.

4. Minimalaufbau

Hier bauen wir ein minimales Beispiel, bei dem die C++-Seite einen „Akkumulator“ erzeugt, Werte hinzufügt und am Ende die Summe abruft. In der Praxis kann es genauso gut eine Entscheidungs-Engine, eine Konfigurationsauswertung oder ein einfacher Parser sein. Stellen Sie es sich als das Muster vor, bei dem die native Seite ein Handle hält und der Reihe nach Operationsfunktionen aufruft.

4.1. Das C#-Projekt

Zunächst richten wir eine Klassenbibliothek ein.

<!-- 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>

Es gibt zwei wichtige Punkte.

  • Native AOT Publish aktivieren
  • unsafe erlauben, da Zeigerargumente verwendet werden

Die Beispiele in diesem Artikel setzen net8.0 voraus, aber das Prinzip selbst gilt genauso für .NET 9 / 10.

4.2. Der exportierte C#-Code

Mit UnmanagedCallersOnly versehene Methoden werden zu den von der nativen Seite sichtbaren Einstiegspunkten. Hier vergeben wir Handles als Ganzzahlen und verwalten den internen Zustand in einem Dictionary auf der C#-Seite.

// 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);
    }
}

Was hier passiert, ist recht schlicht.

  • Der nativen Seite wird nur ein intptr_t-Handle gezeigt
  • Der eigentliche Zustand liegt auf der C#-Seite
  • create / add / get / destroy werden in flache Funktionen zerlegt
  • Rückgabewerte sind Fehlercodes, Ausgabewerte kommen über Zeigerargumente zurück

Mit dieser Form bleibt die C-seitige ABI ziemlich stabil, selbst wenn Sie die interne Implementierung auf der C#-Seite später austauschen.

Zur Vergabe von Handle-Nummern noch eine Ergänzung. Im Beispiel wird der Zähler für die Vergabe als long gehalten, und das Ergebnis von Interlocked.Increment wird nach nint gecastet. Dabei gibt es zwei Eigenschaften, die man kennen sollte.

  • 0 wird nicht vergeben. Der Zähler beginnt bei 0, und Increment gibt den Wert nach der Erhöhung zurück, sodass das erste Handle 1 ist. Deshalb kann die C++-Seite intptr_t handle = 0; als Markierung für „noch keins vorhanden“ verwenden.
  • Bei 32 Bit kommt es zu Abschneidungen. nint hat Zeigerbreite, ist also bei 64-Bit-Builds 64 Bit breit, bei 32-Bit-Builds jedoch nur 32 Bit. Der Cast von long nach nint verwirft die oberen Bits stillschweigend, sodass der Wert bei einer Vergabe über 2^32 hinaus einen Wrap-around erfährt. Bei einer Nutzung mit durchgehendem create / destroy über 24 Stunden ist das theoretisch erreichbar.

Was beim Wrap-around genau passiert, sollten Sie präzise verstehen. Die Ausnahme bei doppelten Schlüsseln in s_instances.Add(handle, instance) hilft nur, solange ein Handle mit demselben Wert gerade „lebendig“ ist. Die übliche Nutzung dieser API besteht aus wiederholtem create und destroy, und ein bereits zerstörtes Handle ist aus dem Dictionary verschwunden. Kommt der Zähler nach dem Wrap-around also wieder auf denselben Wert, existiert im Dictionary kein Schlüssel mehr, und Add gelingt. Als Folge zeigt ein altes Handle, das die C-Seite noch hält, plötzlich auf eine völlig unabhängige neue Instanz. Weder wird eine Exception ausgelöst noch ein Fehlercode zurückgegeben – nur der Wert bricht still.

Ein weiterer Punkt: Da beim Wert genau 2^32 alle unteren 32 Bit 0 sind, wird ausgerechnet die 0 vergeben, die eigentlich als Markierung für „noch keins vorhanden“ gedacht war.

Verlassen Sie sich also nicht auf die Prüfung doppelter Schlüssel als Sicherheitsnetz. Wenn 32-Bit-Umgebungen infrage kommen, wählen Sie eine der folgenden Optionen:

  • Eine Generationsnummer in das Handle einbetten. Die unteren Bits als laufende Nummer, die oberen Bits als Generation, wobei die Generation bei jedem destroy weitergezählt wird. Auch wenn dieselbe laufende Nummer wiederkehrt, stimmt der Gesamtwert nicht mehr überein.
  • Nach Erschöpfung dauerhaft fehlschlagen lassen. Sobald die Vergabe das Maximum erreicht, werden nachfolgende create-Aufrufe als Fehler behandelt. Bei einem dauerhaft laufenden Gerät ist dann ein Neustart nötig, aber das ist besser handhabbar als ein stilles Kaputtgehen.

In beiden Fällen gilt zusätzlich: Halten Sie den Zähler selbst als nint, damit er die nint-Breite nicht überschreitet, und vergeben Sie die 0 weiterhin nicht.

4.3. Der Publish-Befehl

Vorab ein Hinweis. Für das Native-AOT-Publish wird zusätzlich eine native Toolchain benötigt. Führen Sie dotnet publish nur mit gesetztem PublishAot aus, scheitert es nicht bei der C#-Kompilierung, sondern erst in der abschließenden nativen Link-Phase. Das ist die erste Hürde.

Umgebung Erforderlich
Windows Visual Studio 2022 oder neuer. Die Workload „Desktopentwicklung mit C++“ mit allen Standardkomponenten installieren
Ubuntu 18.04 oder neuer sudo apt-get install clang zlib1g-dev
Alpine 3.15 oder neuer sudo apk add clang build-base zlib-dev
Fedora 39 oder neuer / RHEL 8 oder neuer sudo dnf install clang zlib-ng-devel zlib-ng-compat-devel zlib-devel
macOS Xcode Command Line Tools (unterstützt ab .NET 8)

Da dieser Artikel Windows + C++ voraussetzt, läuft es faktisch darauf hinaus, zuerst zu prüfen, ob die C++-Workload von Visual Studio installiert ist. Fehler wie „Linker nicht gefunden“ oder Probleme rund um link.exe haben meist genau hier ihre Ursache.

Anschließend veröffentlichen Sie als gemeinsam genutzte Bibliothek.

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

Damit entsteht unter bin/Release/net8.0/win-x64/publish/ die native DLL. Unter Windows ist es eine .dll, unter Linux eine .so, unter macOS eine .dylib.

Wichtig ist, pro RID zu veröffentlichen. Was für win-x64 erstellt wurde, lässt sich nicht als win-arm64 verwenden, und auch die Bitness von Aufrufer und DLL muss übereinstimmen.

4.4. Aufruf aus C++

Hier lassen wir das Thema Importbibliothek zunächst beiseite und rufen die Funktionen direkt mit LoadLibrary / GetProcAddress auf. In dieser Form ist gut sichtbar, was exportiert wird und mit welcher Signatur es entgegengenommen werden sollte.

/* 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;

    // Do not use a Native AOT shared library with unloading in mind.
    // FreeLibrary(module);

    return EXIT_SUCCESS;
}

In diesem Beispiel sieht die C++-Seite nur „eine über Funktionszeiger aufrufbare C-API“. Dass das Innere in C# geschrieben ist, muss dabei kaum eine Rolle spielen.

Legen Sie die veröffentlichte DLL in denselben Ordner wie main.exe und führen Sie es aus, ergibt sich auf der Standardausgabe – da 10 und 20 addiert werden – nur Folgendes:

total = 30

Schlägt es unterwegs fehl, zeigt std::cerr, in welcher Phase es gescheitert ist. LoadLibraryW failed bedeutet, dass die DLL gar nicht gefunden wurde; GetProcAddress failed: km_accumulator_add bedeutet, dass die DLL zwar gelesen werden konnte, der Export aber nicht gefunden wurde – so lässt sich das eingrenzen.

4.5. Prüfen, ob der Export vorhanden ist

Wenn „es sich nicht aufrufen lässt“, prüfen Sie zuerst, ob der Name auf der DLL-Seite tatsächlich auftaucht. Am schnellsten geht das mit dumpbin in der Visual Studio Developer Command Prompt.

dumpbin /exports NativeAotSample.dll

Stehen die vier Namen km_accumulator_create / km_accumulator_add / km_accumulator_get_total / km_accumulator_destroy in der Liste der Namen, war das Publish auf der C#-Seite erfolgreich. Um nach dem Namen zu filtern, geht es so:

dumpbin /exports NativeAotSample.dll | findstr km_

Taucht der Name hier nicht auf, liegt das Problem auf der C#-Seite; taucht er auf, GetProcAddress schlägt aber trotzdem fehl, liegt das Problem auf der Aufruferseite.

Wenn GetProcAddress NULL zurückgibt, prüfen Sie ungefähr in dieser Reihenfolge:

  1. Taucht der Name in dumpbin /exports auf? (Wenn nicht, liegt es an der C#-Seite.)
  2. Stimmt die in EntryPoint angegebene Zeichenkette exakt mit der an GetProcAddress übergebenen Zeichenkette überein? (Groß-/Kleinschreibung wird unterschieden.)
  3. Stimmt die Bitness von aufrufender EXE und DLL überein?
  4. Ist die mit UnmanagedCallersOnly versehene Methode static und liegt sie nicht in etwas Generischem?
  5. Steht das Attribut auf der für das Publish vorgesehenen Assembly selbst? (Steht es nur in einer referenzierten Bibliothek, taucht es dort nicht im Export auf.)

Schlägt LoadLibraryW selbst schon fehl, geht es dabei nicht um den Export. Prüfen Sie zuerst den Pfad der DLL, die Bitness und fehlende abhängige DLLs.

4.6. Statisches Linken mit einer Importbibliothek

Bis hierher haben wir mit dem Ansatz LoadLibrary / GetProcAddress gearbeitet, weil dabei gut sichtbar ist, was exportiert wird und mit welcher Signatur es entgegengenommen werden sollte.

In der Praxis möchten Sie aber oft einfach den Header includen und die Funktion direkt aufrufen. In diesem Fall handelt es sich um statisches Laden mit einer Importbibliothek. Das Vorgehen ist:

  1. Enthält die Publish-Ausgabe bereits eine Importbibliothek (.lib), linken Sie diese.
  2. Falls nicht, legen Sie eine .def-Datei mit den Exportnamen an und erzeugen die Importbibliothek mit lib.exe /def:NativeAotSample.def /out:NativeAotSample.lib /machine:x64.
  3. Der Header enthält dann keine Funktionszeiger-Typen mehr, sondern gewöhnliche Funktionsdeklarationen.
/* 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

Mit dieser Form wird der Code auf der Aufruferseite ziemlich unkompliziert. Dafür schlägt der Prozess beim Start fehl, wenn die DLL nicht gefunden wird, sodass sich der Betrieb „die Anwendung läuft, aber nur diese Funktion ist nicht verfügbar“ schwer umsetzen lässt. Wenn Sie es eher plugin-artig einklinken möchten, ist der LoadLibrary-Ansatz weiterhin die handlichere Wahl.

Die Veröffentlichung als statische Bibliothek (NativeLib=Static) wird offiziell nicht unterstützt, daher sollten Sie sich darauf sicherheitshalber nicht verlassen.

5. API-Formen, die nicht so leicht brechen

Dass sich mit Native AOT exportieren lässt, ist zwar interessant, in der Praxis ist aber das, was Sie nicht exportieren, wichtiger.

5.1. Sich am C-ABI orientieren

Klären wir zuerst die Begriffe. Der Kern dieses Artikels lautet „die Grenzfläche als C-ABI und nicht als .NET zu entwerfen“, aber dieses ABI steht für Application Binary Interface und bezeichnet die Vereinbarung, wie kompiliert vorliegende Binärdateien zur Laufzeit ineinandergreifen. Stellen Sie es sich nicht als Vereinbarung auf Quellcode-Ebene, sondern auf Maschinencode-Ebene vor. Im Kern geht es dabei hauptsächlich um drei Dinge.

Vereinbarung Was sie festlegt Was passiert, wenn sie hier nicht eingehalten wird
Aufrufkonvention (calling convention) Ob Argumente über Register oder den Stack und wie übergeben werden, wo der Rückgabewert abgelegt wird, ob der Aufrufer oder der Aufgerufene nach dem Aufruf den Stack zurücksetzt Argumente verschieben sich, der Stack ist direkt nach der Rückkehr beschädigt
Typ-Layout Wie viele Bytes jeder Typ hat, an welcher Position die Member eines struct liegen (Padding und Alignment) Ab einer bestimmten Stelle im struct lassen sich keine korrekten Werte mehr lesen
Namen und Linking Schreibweise der exportierten Funktionsnamen, ob eine Namensdekoration vorliegt GetProcAddress findet den Namen nicht

cdecl und stdcall sind Namen für die erste dieser drei Vereinbarungen, die Aufrufkonvention. Bei C++-Klassen und Exceptions unterscheiden sich diese drei Vereinbarungen von Compiler zu Compiler, sodass sie unverändert an der Grenze nicht ineinandergreifen. Umgekehrt bedeutet das: Beschränkt man sich auf C-Funktionen und Grundtypen, sind die Vereinbarungen einfach und greifen leicht ineinander. „Sich am C-ABI orientieren“ bedeutet, die Grenzfläche auf diesen einfachen Rahmen von Vereinbarungen herunterzubrechen.

Darauf aufbauend ist es ruhiger, die an der Grenze exponierten Typen von vornherein auf ungefähr Folgendes zu beschränken.

  • Grundtypen wie int32_t / int64_t / double
  • structs mit festem Layout
  • Handles, die intptr_t / void* entsprechen
  • uint8_t* zusammen mit einer Länge

Umgekehrt sind das die Dinge, die von Anfang an nicht nach außen durchsickern sollten:

  • string
  • object
  • List<T>
  • Task
  • Span<T>
  • C++-Klassen, std::vector, std::wstring

Versuchen Sie, diese Dinge unverändert über die Grenze zu schicken, trübt sich die Grenzfläche rasch ein. Wichtig ist, die Belange von C# nicht nach C++ durchsickern zu lassen und umgekehrt die Belange von C++ nicht zu stark nach C# durchsickern zu lassen.

Um Abschreibfehler zu vermeiden, hier noch eine Zuordnungstabelle. Da in der Signatur einer mit UnmanagedCallersOnly versehenen Methode nur blittable Typen verwendet werden können, bleibt man ohnehin in diesem Rahmen.

C#-Seite C-/C++-Seite Hinweis
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++s long ist unter Windows 32 Bit, unter Linux mit LP64 64 Bit breit; deshalb ist es sicherer, statt long int64_t zu schreiben
nint / nuint intptr_t / uintptr_t Zeigerbreite. Bei 32-Bit-Builds 32 Bit
float / double float / double  
bool nicht verwenden Nicht blittable. Übergeben Sie 0 / 1 als int32_t
char / string nicht verwenden Zeichenketten werden wie in 5.2 als Zeiger + Länge behandelt
T* (unsafe-Zeiger) T* Damit werden Ausgabewerte zurückgegeben
struct mit festem Layout struct mit demselben Layout Reihenfolge, Typ und Padding der Member müssen auf beiden Seiten unbedingt übereinstimmen

5.2. Zeichenketten als Zeiger + Länge + Puffergröße behandeln

Sobald Sie Zeichenketten austauschen möchten, ist die Versuchung groß, direkt string freizugeben – das sollten Sie sich aber verkneifen. An einer Bibliotheksgrenze ist zum Beispiel folgende Form klar verständlich:

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);

Es geht darum, vorab die Zeichenkodierung, die Länge und wer den Puffer alloziert festzulegen. Da es Windows ist, gibt es auch die Option, sich an UTF-16 zu orientieren, aber mit Blick auf andere Sprachen ist UTF-8 meist einfacher zu handhaben.

5.3. Exceptions nicht über die Grenze lassen

Eine native Funktionsgrenze ist als Mittel, um Exceptions auszudrücken, wenig geeignet. Zumindest ist es sicherer, das Design nicht so anzulegen, dass eine managed Exception unverändert zum Aufrufer durchsickert.

In der Praxis ist es handhabbar, wenn Sie es so anlegen:

  • Der Rückgabewert ist ein Status-Code
  • Die eigentlichen Daten kommen über einen Out-Puffer oder Zeigerargumente
  • Bei Bedarf holen Sie zusätzliche Informationen über eine get_last_error-artige Funktion

Das ist nicht spektakulär, aber genau dieses unauffällige Design zahlt sich später aus. Fangen Sie an der Grenzfläche nicht plötzlich einen Nahkampf an, mit anderen Worten.

5.4. Die Aufrufkonvention festlegen

Im Beispiel wurde CallConvCdecl explizit angegeben. Lässt man es weg, ergibt sich die plattformspezifische Standard-Aufrufkonvention; wenn Sie aber Header und Funktionszeiger-Typen festlegen möchten, ist es weniger fehleranfällig, sie selbst explizit anzugeben.

Besonders wenn x86 infrage kommt, wird es später unangenehm, wenn Sie das hier offenlassen. Auch wenn es sich unter x64 kaum bemerkbar macht, sollten Sie die Regel von Anfang an festlegen.

5.5. Exportierte Methoden dünn halten und die Logik auslagern

Mit UnmanagedCallersOnly versehene Methoden sind nicht dafür gedacht, direkt aus gewöhnlichem managed Code aufgerufen zu werden. Fangen Sie also an, dort die gesamte Fachlogik unterzubringen, wird auch das Testen schwierig.

Auch im Beispiel liegt die eigentliche Verwaltung in AccumulatorStore, während der exportierte NativeExports nur ein dünner Einstiegspunkt ist. Das ist ziemlich wichtig.

  • Export-Methoden: der ABI-Schalter
  • Interne Klassen: gewöhnliche C#-Logik

Mit dieser Arbeitsteilung lassen sich die Grenze zu C++ und der eigentliche C#-Code getrennt betrachten.

6. Fälle, für die es sich eignet

Dieser Aufbau passt richtig gut in Situationen wie diesen.

  • Die bestehende C/C++-Anwendung soll erhalten bleiben, nur ein Teil der Fachlogik soll nach C# ausgelagert werden
  • Eine vorab installierte .NET-Runtime soll keine Verteilungsvoraussetzung sein
  • Die exportierte Funktionsfläche lässt sich klein halten
  • Möglicherweise möchten Sie künftig dieselbe C-API auch aus anderen Sprachen wie Rust oder Go aufrufen

Besonders gut passt es zu dem Aufbau, bei dem die native Anwendung unverändert bleibt und nur die leicht austauschbare Logikschicht in C# geschrieben wird – UI und Gerätesteuerung bleiben in C++, Entscheidungen, Berechnungen und Konfigurationsregeln liegen in C#.

7. Fälle, für die es sich trotzdem nicht eignet

Natürlich ist das kein Allheilmittel. Es gibt klare Fälle, für die es sich nicht eignet.

  • Sie möchten C++-Klassen, std::vector oder Exceptions direkt handhaben
    • In diesem Fall sind C++/CLI oder ein nativer Wrapper natürlicher.
  • Sie möchten in die Welt von COM-Registrierung, VBA-/Office-Automatisierung oder Explorer-Erweiterungen einsteigen
    • Denken Sie das lieber im Rahmen von COM.
  • Sie möchten 32-Bit / 64-Bit überbrücken oder Prozessgrenzen überschreiten
    • Nicht eine in-process-DLL, sondern COM / IPC / ein separater Prozess ist hier die sinnvollere Wahl.
  • Sie möchten Plugins später entladen können
    • Eine Native-AOT-Shared-Library sollte nicht mit Entladen im Sinn verwendet werden.
  • Ihre Abhängigkeiten setzen stark auf Reflection oder dynamische Codeerzeugung
    • Erscheinen Warnungen beim AOT-Publish, ist es sicherer, diese nicht einfach zu ignorieren.

Letztlich ist die Wasserscheide, ob Sie sich mit dem C-ABI abfinden können. Wenn nicht, ist eine andere Brücke die sauberere Wahl.

8. Fallstricke

Zum Schluss eine Zusammenfassung der unauffälligen, aber leicht übersehbaren Fallstricke bei Native-AOT-Exporten.

  • Mit UnmanagedCallersOnly versehene Methoden müssen static sein.
  • Sie dürfen nicht in generischen Methoden oder generischen Klassen liegen.
  • Für einen benannten Export fügen Sie EntryPoint hinzu.
  • Verwenden Sie kein ref / in / out, sondern geben Sie Werte über Zeigerargumente zurück.
  • Exportiert werden nur Methoden der für das Publish vorgesehenen Assembly. Ein Attribut auf einer Methode in einer referenzierten Bibliothek taucht so nicht im Export auf.
  • Die Bitness von Aufrufer und DLL muss übereinstimmen.
  • Publish-Warnungen sind ziemlich wichtig. Erscheinen AOT-/Trimming-Warnungen, ist es sicherer, diese zuerst zu beseitigen.

Jeder dieser Punkte klingt nach „klar, wenn man es einmal weiß“. Aber stolpert man unwissend einmal darüber, vergehen ziemlich zähe Stunden.

9. Zusammenfassung

Wenn Sie C# aus C/C++ aufrufen möchten, kommen einem zuerst COM, C++/CLI oder ein separater Prozess in den Sinn. All das sind valide Optionen.

Wenn Sie aber C#-Verarbeitung als in-process native DLL einklinken möchten, ist Native AOT + UnmanagedCallersOnly eine ziemlich interessante Wahl.

Halten wir die zentralen Punkte noch einmal fest.

  • C# nicht unverändert zeigen, sondern zu einer C-ABI flatten
  • Lebensdauerverwaltung handle-basiert explizit machen
  • Die Grenze mit Fehlercodes statt Exceptions überschreiten
  • Die Aufrufkonvention festlegen
  • Export-Methoden dünn halten und von der internen Logik trennen

Was hier passiert, ist nicht spektakulär. Aber wie Sie diese Grenze ziehen, wirkt sich später erheblich auf die Wartbarkeit aus. Wenn Sie native Ressourcen erhalten und trotzdem die Produktivität von C# nur in die Logikschicht bringen möchten, lohnt es sich, diesen Aufbau im Kopf zu behalten.

10. Quellen

Aktuelle Artikel mit denselben Schlagwörtern führen zu verwandten Themen weiter.

Diese Seiten ordnen den Artikel in einen größeren Leistungs- und Entscheidungskontext ein.

Dieser Artikel ist direkt mit den folgenden Leistungen verbunden.

Windows-App-Entwicklung

Da es um die Implementierung der Grenze zwischen C# und C/C++ geht, ist das Thema direkt mit Design- und Implementierungsberatung im Bereich Windows-Anwendungsentwicklung verknüpft.

Häufige Fragen

Fragen, die in Beratungen zu diesem Artikelthema häufig gestellt werden.

Kann ich C#-Code aus C++ aufrufen?
Ja. Mit .NETs Native AOT lässt sich eine C#-Klassenbibliothek als native gemeinsam genutzte Bibliothek veröffentlichen, und mit UnmanagedCallersOnly versehene Methoden lassen sich als C-Einstiegspunkte offenlegen. Das heißt, Sie können C# als „aufgerufene native DLL“ in-process aus C/C++ verwenden.
Für welche Szenarien eignet sich dieser Aufbau?
Für Szenarien, in denen die native Anwendung selbst unverändert bleiben soll, aber Teile wie Entscheidungslogik, Zeichenkettenverarbeitung, Konfigurationsauswertung oder Berechnungsregeln nach C# ausgelagert werden sollen. Charakteristisch ist die Richtung „die native Seite spielt die Hauptrolle, C# wird als Baustein aufgerufen“. Umgekehrt eignet sich P/Invoke, um aus C# eine Gruppe von C-Funktionen aufzurufen, C++/CLI, um C++-Typen und -Eigentümerschaft natürlich zu handhaben, und COM/IPC, um 32-Bit-/64-Bit- oder Prozessgrenzen zu überschreiten.
Worauf muss ich beim Entwurf der API achten?
Was exportiert wird, ist letztlich nur ein C-Funktionseinstieg, daher dürfen string, List<T> oder Exceptions nicht unverändert an der Grenze sichtbar werden. Es empfiehlt sich, alles auf eine flache C-API wie create / destroy / operate herunterzubrechen, Lebensdauerverwaltung und Fehlercodes explizit zu machen, Zeichenketten als Zeiger + Länge + Puffergröße zu behandeln, Exceptions nicht über die Grenze zu lassen und die Aufrufkonvention festzulegen. Entscheidend ist, die Grenzfläche als C-ABI und nicht als .NET zu entwerfen.
Gibt es lauffähigen Beispielcode?
Ja. Im GitHub-Repository komurasoft-blog-samples ist ein vollständiges, bau- und lauffähiges Beispielpaket veröffentlicht, das die mit Native AOT veröffentlichte C#-Bibliothek, ein C++-Aufrufbeispiel und Unit-Tests umfasst. Die Codebeispiele setzen eine Windows-DLL voraus, das Vorgehen ist unter Linux / macOS aber nahezu identisch.

Autorenprofil

Profilseite des Artikelautors.

Go Komura

Geschäftsführer von KomuraSoft LLC

Spezialisiert auf Windows-Softwareentwicklung, technische Beratung und Fehleranalyse, insbesondere bei bestehenden Systemen und schwer reproduzierbaren Störungen.

Zurück zum Blog