Native DLLs aus C# aufrufen: C++/CLI-Wrapper vs. P/Invoke
· Go Komura · C++/CLI, C#, Windows-Entwicklung, Native Interoperabilität
Die Anforderung, vorhandene Windows-Bestände oder bestehende DLLs aus C# heraus zu nutzen, kommt recht häufig vor. Ist die Gegenseite eine unkomplizierte C-Schnittstelle wie die Win32-API, reicht P/Invoke völlig aus.
In der Praxis begegnet man aber DLLs mit deutlich mehr Eigenarten.
Es gibt C++-Klassen, eigene Gepflogenheiten bei Besitzverhältnissen, Ausnahmen fliegen umher, und std::wstring sowie std::vector tauchen ganz selbstverständlich auf.
Versucht man hier, allein mit P/Invoke durchzukommen, wird die Grenzfläche meist zunehmend schmerzhaft.
In diesem Artikel geht es darum, was in solchen Situationen einfacher wird, wenn Sie einen dünnen C++/CLI-Wrapper dazwischenlegen. Es geht nicht darum, dass P/Invoke schlecht wäre – der Kernpunkt ist, dass die Fälle, in denen P/Invoke ausreicht, und die Fälle, in denen C++/CLI sich lohnt, unterschiedlich sind.
Die in diesem Artikel gezeigten Code-Ausschnitte sind als vollständiges, kompilierbares Beispielset (native C++-Bibliothek, C-API-Bridge, C++/CLI-Wrapper sowie C#-Konsumentencode in einer P/Invoke- und einer C++/CLI-Version) auf GitHub veröffentlicht.
cpp-cli-wrapper-for-native-dlls - komurasoft-blog-samples (GitHub)
Zielgruppe und Voraussetzungen
Dieser Artikel richtet sich an Entwickler, die schon einmal native DLLs aus C# aufgerufen haben und DllImport-Deklarationen schreiben können, aber ins Stocken geraten, sobald die Gegenseite eine C++-Klassenbibliothek ist. Erfahrung mit C++/CLI selbst ist nicht nötig. Wer umgekehrt noch nie P/Invoke geschrieben hat, kommt schneller voran, wenn er zuerst „Win32-APIs sicher aus C# aufrufen – ein praxistauglicher P/Invoke-Leitfaden“ liest.
Als Umgebung wird Windows mit Visual Studio 2022 vorausgesetzt, in einer Konfiguration, bei der sich der C++/CLI-Wrapper (.vcxproj) und das C#-Projekt in derselben Solution ablegen lassen. Als Ziel kommen sowohl .NET Framework als auch etwa .NET 8 infrage, doch die .NET-Seite hat eigene Einschränkungen, die in Kapitel 7 zusammengefasst sind.
Begriffe, die Sie vorher kennen sollten
| Begriff | Bedeutung |
|---|---|
| P/Invoke (Platform Invoke) | Der Mechanismus, mit dem Sie über die C#-Attribute DllImport / LibraryImport exportierte Funktionen einer nativen DLL deklarieren und direkt aufrufen |
| Marshalling | Die wechselseitige Umwandlung zwischen .NET-Typen (string, Arrays usw.) und ihrer nativen Darstellung (wchar_t*, rohe Zeiger usw.) an der Grenze |
| ABI (Application Binary Interface) | Die Vereinbarungen – Aufrufkonvention, Art der Parameterübergabe, Speicherlayout von Strukturen, Namensverstümmelung usw. –, damit kompilierte Binärdateien zueinander passen. Bei C-Funktionen sind diese Vereinbarungen einfach und stabil, bei C++-Klassen hängen Namensverstümmelung und vtable-Layout jedoch vom Compiler ab und lassen sich von C# aus nicht direkt voraussetzen (5.4) |
SafeHandle |
Eine abstrakte .NET-Klasse, die einen nativen Handle umschließt. Sie wird anstelle eines nackten IntPtr verwendet, um vergessene Freigaben und den Unfall „Freigabe während der Nutzung“ zu verhindern (6.2) |
StructLayout |
Ein Attribut, mit dem das Speicherlayout einer C#-Struktur an die native Seite angeglichen wird. Es dient etwa dazu, mit LayoutKind.Sequential die Felder in Deklarationsreihenfolge anzuordnen oder mit CharSet den Umgang mit Zeichenketten festzulegen (6.2) |
marshal_as |
Ein von C++/CLI bereitgestellter Konvertierungshelfer. Er wandelt .NET-Typen und native Typen wechselseitig um, etwa mit marshal_as<std::wstring>(managedString). Verwendet wird er, indem Header wie msclr/marshal_cppstd.h eingebunden werden (6.3) |
| Gemischte Assembly | Eine DLL, die sowohl native Maschinenbefehle als auch MSIL enthält. Ein C++/CLI-Wrapper ist genau das (Kapitel 7) |
Inhaltsverzeichnis
- Das Fazit zuerst (in einem Satz)
- Fälle, in denen P/Invoke ausreicht
- Die Grenze, an der P/Invoke plötzlich mühsam wird
- Die Architektur mit einem C++/CLI-Wrapper
- Was C++/CLI einfacher macht
- Code-Ausschnitte
- Fälle, in denen Sie C++/CLI trotzdem nicht wählen sollten
- Zusammenfassung
- Quellen
1. Das Fazit zuerst (in einem Satz)
- Ist die Gegenseite eine Gruppe von C-Funktionen, ist P/Invoke naheliegend
- Ist die Gegenseite eine C++-Bibliothek, lässt sich das Ganze mit einem dazwischengelegten C++/CLI-Wrapper leichter warten
- Vor allem wenn Klassen, Besitzverhältnisse, Zeichenketten, Arrays, Ausnahmen und Callbacks eine Rolle spielen, sollte man die C#-Seite nicht überfordern
Kurz gesagt: Man bringt die Belange der nativen DLL nicht direkt in C# hinein. Die nativen Belange werden auf der C++-Seite abgefangen, und nur die Oberfläche, die .NET zu sehen bekommt, wird herausgeputzt. Wenn diese Arbeitsteilung funktioniert, werden sowohl der Code als auch das Debugging spürbar entspannter.
2. Fälle, in denen P/Invoke ausreicht
Wenn P/Invoke die Sache erledigt, ist das die einfachste Lösung. Es besteht keine Notwendigkeit, C++/CLI mit Gewalt hineinzubringen.
Für P/Invoke eignen sich zum Beispiel folgende Fälle:
- Die API ist eine flache Menge von über
extern "C"veröffentlichten Funktionen - Argumente und Rückgabewerte sind Ganzzahlen, Zeiger, einfache Strukturen und Ähnliches
- Die Zeichenketten-Konventionen sind klar, und die Zuständigkeit für Puffer ist einfach
- Die Ressourcenverwaltung folgt einem verständlichen Muster wie
Create/Destroy - Sie können auf der C#-Seite
SafeHandleundStructLayoutunkompliziert schreiben
Ist es so weit aufgeräumt, genügt es, auf der C#-Seite zu deklarieren und aufzurufen. Da sich das nah am Gefühl des Aufrufens der Windows-API anfühlt, bleibt auch die Implementierung gut lesbar.
3. Die Grenze, an der P/Invoke plötzlich mühsam wird
Das Problem entsteht, wenn die Gegenseite eben keine „reine C-API“ ist. Ab hier ändert sich die Stimmung schlagartig.
3.1. Sobald Sie es mit C++-Klassen zu tun bekommen
Ist die native DLL klassenzentriert um C++ herum entworfen, möchten Sie die Methoden der Klassen eigentlich direkt aufrufen. Doch was P/Invoke direkt ansprechen kann, sind nur die exportierten Funktionen der DLL. Das bedeutet, irgendwo wird zwangsläufig eine Schicht nötig, die alles auf C-förmige Funktionen herunterbricht.
An diesem Punkt tun Sie im Grunde nichts anderes als „einen Wrapper zu schreiben“.
Wenn das so ist, ist es natürlicher, den Wrapper auf die C++-Seite zu verlagern, statt auf der C#-Seite massenhaft IntPtr und Freigabefunktionen sprießen zu lassen.
3.2. Wenn Besitzverhältnisse und Lebensdauer-Verwaltung schwer zu durchschauen sind
In C++ sind Fragen wie diese ganz normal:
- Gibt die aufrufende Seite frei?
- Ist der zurückgegebene Zeiger nur geliehen?
- Handelt es sich um
const&oder um eine Eigentumsübertragung? - Wird intern zwischengespeichert, mit Annahmen über die Lebensdauer?
Drückt man das auf der C#-Seite mit IntPtr aus, funktioniert es zunächst vielleicht, ist aber beim späteren Wiederlesen ziemlich mühsam.
Sobald das Problem „Wer löscht diesen Zeiger eigentlich wann?“ auftaucht, wird die Grenzfläche schnell trüb.
3.3. Wenn std::wstring, std::vector, Callbacks und Ausnahmen ins Spiel kommen
Ab hier betritt P/Invoke das Terrain von „lässt sich schreiben, macht aber keinen Spaß“.
- Sie wollen
std::wstringdirekt aus C# heraus abbilden - Sie wollen ein
std::vector<T>zurückgeben - Sie wollen den Fortschritt nativer Verarbeitung per Callback empfangen
- Bei einem Fehlschlag fliegt eine C++-Ausnahme
Häufen sich solche Elemente, wachsen auf der C#-Seite MarshalAs, manuelle Puffer, Arrays fester Länge, die Verwaltung der Delegate-Lebensdauer und die Interpretation von Fehlercodes.
Natürlich lässt sich das alles schreiben, wenn man sich anstrengt. Das Mühsame daran ist nur, dass die Stelle, an der man sich anstrengt, nicht der eigentliche Kern der Sache ist. Eigentlich möchte man Fachlogik oder die Benutzeroberfläche bauen, nicht Kampfsport an der Grenzfläche betreiben.
3.4. Wenn Sie die Belange von C++ nicht nach C# durchsickern lassen wollen
Die API der nativen DLL ist nicht zwangsläufig so gestaltet, dass sie unverändert zu C# passt.
Selbst wenn die native Seite zum Beispiel so entworfen ist, dass
- mehrere Methodenaufrufe zu einem einzigen logischen Vorgang kombiniert werden,
- Fehler über Rückgabewerte und out-Parameter zurückgegeben werden,
- es Annahmen über die Initialisierungsreihenfolge gibt,
- Einschränkungen bei der Thread-Sicherheit bestehen,
möchte man der C#-Seite meist eine deutlich unkompliziertere API zeigen. Als Schicht, die diese Umwandlung übernimmt, ist C++/CLI recht praktisch.
4. Die Architektur mit einem C++/CLI-Wrapper
Die Architektur ist einfach.
flowchart LR
Cs[C#-App] -->|API für .NET| Wrapper[C++/CLI-Wrapper-DLL]
Wrapper -->|Arbeitet direkt mit nativen Headern und Typen| Native[Native C++-DLL]
Sorgen Sie dafür, dass C# nur eine .NET-typische API zu sehen bekommt, und schließen Sie Folgendes auf der C++/CLI-Seite ein:
- Umwandlung von Zeichenketten
- Umwandlung von Arrays und Vektoren
- Umwandlung von Ausnahmen
- Aufräumen der Besitzverhältnisse
- Interpretation von Fehlercodes
- bei Bedarf das Abfangen von Thread-Grenzen und Callbacks
Wichtig ist, das C++/CLI-Projekt selbst nicht zu groß werden zu lassen. Seine Rolle ist ausschließlich „Übersetzung“ und „Formgebung“. Fängt man an, auch Fachlogik hineinzupacken, wird diese Schicht plötzlich zur Hauptrolle.
5. Was C++/CLI einfacher macht
5.1. C++-Typen bleiben C++-Typen
Das ist ein ziemlich großer Vorteil. Auf der C++/CLI-Seite können Sie die nativen Header einbinden und die C++-Typen unverändert verwenden.
Das heißt, die C#-Seite muss nicht mehr mühsam „die C++-Welt nachbilden“.
Sowohl std::wstring als auch std::vector lassen sich zunächst als C++-Typen entgegennehmen und dann in der benötigten Form an die .NET-Seite weitergeben.
5.2. Die API lässt sich für .NET zuschneiden
Der C#-Seite können Sie die API in vertrauten Formen präsentieren:
stringbyte[]List<T>IDisposable- Ausnahmen
Dieser Unterschied wirkt unscheinbar, verändert aber den Aufwand für die nutzende Seite erheblich. Gerade in der Teamentwicklung zahlt sich aus, dass auch Mitglieder ohne Kenntnis der nativen Details problemlos damit arbeiten können.
5.3. Zuständigkeiten für Ausnahmen und Fehler lassen sich leichter ordnen
Mischen sich auf der nativen Seite Ausnahmen und Fehlercodes, ist es unhandlich, das unverändert auf der C#-Seite zu empfangen. Auf der C++/CLI-Seite lässt sich das an einer Stelle bündeln:
- Ausnahmen werden in .NET-Ausnahmen umgewandelt
- Fehlercodes werden in aussagekräftige Ausnahmen oder Ergebnistypen umgewandelt
- der für das Logging nötige Kontext wird ergänzt
Übersetzt man an der Grenze einmalig in einen „aussagekräftigen Fehlschlag“, wird es für die aufrufende Seite deutlich übersichtlicher.
5.4. Die Instabilität der ABI bleibt vor C# verborgen
C++-Klassen und -Methoden haben keine so einfache ABI wie C-Funktionen. Sobald C# direkt mit diesen Umständen in Berührung kommt, treten die Belange von exportierten Funktionen und Marshalling offen zutage.
Mit einem C++/CLI-Wrapper dazwischen lässt sich erreichen, dass die Belange von C++ auf der C++-Seite eingeschlossen bleiben und C# nur eine stabile Oberfläche zu sehen bekommt. Diese Trennung zahlt sich auch bei Aktualisierungen der Bibliothek aus.
5.5. Eine schrittweise Migration wird einfacher
Eine bestehende native DLL auf einen Schlag komplett neu zu schreiben, ist aufwendig. Mit einem C++/CLI-Wrapper lässt sich zunächst nur die benötigte API dünn umhüllen und von neuen C#-Bildschirmen oder Workflows aus nutzen – eine schrittweise Migration wird dadurch machbar.
Für Szenarien, in denen vorhandene Windows-Bestände weitergenutzt werden sollen, während das Umfeld in Richtung .NET verschoben wird, passt das ausgesprochen gut.
6. Code-Ausschnitte
Hier geht es nicht um ein „vollständiges, direkt lauffähiges Beispiel“, sondern nur um Ausschnitte, die eine Vorstellung von der Grenzfläche vermitteln.
6.1. Wie die API der nativen DLL aussieht
// NativeLib.hpp
#pragma once
#include <string>
#include <vector>
namespace NativeLib
{
struct AnalyzeOptions
{
int threshold;
std::wstring modelPath;
};
struct AnalyzeResult
{
bool ok;
std::wstring message;
std::vector<int> scores;
};
class Analyzer
{
public:
explicit Analyzer(const std::wstring& licensePath);
AnalyzeResult Analyze(const std::wstring& imagePath, const AnalyzeOptions& options);
};
}
Als natives C++ betrachtet, ist diese API ganz gewöhnlich. Aber sie direkt aus C# heraus anzufassen, ist ziemlich mühsam.
6.2. So sieht der Versuch mit P/Invoke aus
Zunächst muss, um es direkt aus C# aufzurufen, irgendwo auf C-förmige Funktionen heruntergebrochen werden. Zum Beispiel müssen Sie separat Brückenfunktionen wie diese bereitstellen.
// Skizze einer auf eine C-API heruntergebrochenen Bridge
extern "C"
{
__declspec(dllexport) void* Analyzer_Create(const wchar_t* licensePath);
__declspec(dllexport) void Analyzer_Destroy(void* handle);
__declspec(dllexport) int Analyzer_Analyze(
void* handle,
const wchar_t* imagePath,
const AnalyzeOptionsNative* options,
AnalyzeResultNative* result);
}
Auch die C#-Seite bekommt in etwa dieses Aussehen.
internal sealed class SafeAnalyzerHandle : SafeHandle
{
private SafeAnalyzerHandle() : base(IntPtr.Zero, ownsHandle: true) { }
public override bool IsInvalid => handle == IntPtr.Zero;
protected override bool ReleaseHandle()
{
NativeMethods.Analyzer_Destroy(handle);
return true;
}
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
internal struct AnalyzeOptionsNative
{
public int Threshold;
public IntPtr ModelPath;
}
internal static class NativeMethods
{
[DllImport("NativeBridge.dll", CharSet = CharSet.Unicode)]
internal static extern SafeAnalyzerHandle Analyzer_Create(string licensePath);
[DllImport("NativeBridge.dll", CharSet = CharSet.Unicode)]
internal static extern void Analyzer_Destroy(IntPtr handle);
[DllImport("NativeBridge.dll", CharSet = CharSet.Unicode)]
internal static extern int Analyzer_Analyze(
SafeAnalyzerHandle handle,
string imagePath,
ref AnalyzeOptionsNative options,
out AnalyzeResultNative result);
}
Wäre es damit getan, wäre das schön, aber in der Praxis kommen weitere Fragen hinzu:
- Wie gibt man Daten variabler Länge zurück?
- Wer gibt die Zeichenkettenpuffer frei?
- Wo legt man Fehlerdetails ab?
- Wie schützt man die Lebensdauer von Callbacks?
Mit anderen Worten: Man wollte eigentlich P/Invoke wählen, hat aber faktisch begonnen, eine C-kompatible API zu entwerfen – das kommt häufig vor.
Verlagert man diese Fragen zu C++/CLI, ersetzen sie sich wie folgt. Sie verschwinden nicht wie durch Zauberei – treffender ist: Sie wandern in eine Form, die sich auf der C++-Seite natürlich schreiben lässt.
| Fragen, die bei P/Invoke auftauchen | Typische Lösung auf der P/Invoke-Seite | Wie es sich auf der C++/CLI-Seite darstellt |
|---|---|---|
| Wie gibt man Daten variabler Länge zurück? | Eine „Funktion zur Abfrage der benötigten Größe“ und eine „Funktion zum Befüllen des Puffers“ zweistufig bereitstellen; die C#-Seite reserviert den Puffer | Das von der nativen Seite zurückgegebene std::vector wird unverändert entgegengenommen und in List<int> oder ein Array umgefüllt zurückgegeben (6.3) |
| Wer gibt die Zeichenkettenpuffer frei? | Eine Freigabefunktion zur C-API hinzufügen und die Konvention einhalten, dass die C#-Seite sie immer aufruft | Die Lebensdauer von std::wstring bleibt vollständig auf der nativen Seite; an C# wird nur ein neu erzeugtes String^ zurückgegeben (6.3) |
| Wo legt man Fehlerdetails ab? | Zusätzlich zum Fehlercode im Rückgabewert eine Funktion zum Auslesen der Details oder eine Struktur als out-Parameter bereitstellen | Native Ausnahmen werden mit try / catch abgefangen, in eine aussagekräftige .NET-Ausnahme umgewandelt und erneut geworfen (6.3) |
| Wie schützt man die Lebensdauer von Callbacks? | Über ein Feld o. Ä. dauerhaft eine Referenz halten, damit der Delegate nicht vom GC eingesammelt wird | Registrierung und Abmeldung von Callbacks bleiben auf der C++-Seite eingeschlossen; C# bekommt nur Events oder Delegates zu sehen |
| Wie stellt man den Besitz eines Handles dar? | Von SafeHandle ableiten und die Freigabefunktion aus ReleaseHandle aufrufen |
Das native Objekt wird im Destruktor/Finalizer des Wrappers per delete freigegeben (6.3) |
6.3. So lässt es sich mit einem C++/CLI-Wrapper schreiben
Auf der C++/CLI-Seite fangen Sie die nativen Belange ab und gestalten die API, die C# zu sehen bekommt.
// AnalyzerWrapper.h
#pragma once
#include "NativeLib.hpp"
using namespace System;
using namespace System::Collections::Generic;
public ref class AnalysisOptions
{
public:
property int Threshold;
property String^ ModelPath;
};
public ref class AnalysisResult
{
public:
property bool Ok;
property String^ Message;
property List<int>^ Scores;
};
public ref class AnalyzerWrapper : IDisposable
{
public:
AnalyzerWrapper(String^ licensePath);
~AnalyzerWrapper();
!AnalyzerWrapper();
AnalysisResult^ Analyze(String^ imagePath, AnalysisOptions^ options);
private:
NativeLib::Analyzer* _native;
};
C++/CLI-typisch sind hier die beiden Konstrukte ~AnalyzerWrapper() und !AnalyzerWrapper(). Beide sehen wie C++-Destruktoren aus, entsprechen aber in ihrer Rolle dem .NET-Dispose-Muster.
| Schreibweise in C++/CLI | Was der Compiler generiert | Verhalten aus Sicht von C# |
|---|---|---|
~AnalyzerWrapper() (Destruktor) |
Dispose(), das IDisposable implementiert |
Wird ausgeführt, wenn ein using-Block verlassen oder Dispose() aufgerufen wird |
!AnalyzerWrapper() (Finalizer) |
Finalize(), das Object::Finalize überschreibt |
Wird ausgeführt, wenn der GC das Objekt einsammelt. Wann das geschieht, ist nicht festgelegt |
Die bewährte Vorgehensweise lautet: Die Freigabe nativer Ressourcen im Finalizer schreiben und ihn aus dem Destruktor heraus aufrufen. Genau das macht die folgende Implementierung, in der ~AnalyzerWrapper() lediglich this->!AnalyzerWrapper() aufruft: Vergisst die C#-Seite, Dispose() aufzurufen, fängt der GC es am Ende trotzdem auf. Wird der Destruktor aufgerufen, unterdrückt GC::SuppressFinalize die Finalisierung, sodass es nicht zu einer doppelten Freigabe kommt.
Da Dispose(), Finalize() und Dispose(bool) vom Compiler generiert werden, schreiben Sie sie auf der C++/CLI-Seite nicht selbst. Umgekehrt können Sie aus C++/CLI-Code heraus Dispose() nicht direkt aufrufen – den Destruktor rufen Sie mit dem delete-Operator auf. Dieser Zusammenhang ist im Abschnitt „Destructors and finalizers“ von How to: Define and consume classes and structs (C++/CLI) - Microsoft Learn zusammengefasst.
// AnalyzerWrapper.cpp
#include "AnalyzerWrapper.h"
#include <msclr/marshal_cppstd.h>
using msclr::interop::marshal_as;
AnalyzerWrapper::AnalyzerWrapper(String^ licensePath)
{
_native = new NativeLib::Analyzer(marshal_as<std::wstring>(licensePath));
}
AnalyzerWrapper::~AnalyzerWrapper()
{
this->!AnalyzerWrapper();
}
AnalyzerWrapper::!AnalyzerWrapper()
{
delete _native;
_native = nullptr;
}
AnalysisResult^ AnalyzerWrapper::Analyze(String^ imagePath, AnalysisOptions^ options)
{
// Wird dies nach der Freigabe aufgerufen, hier stoppen, bevor es in die native Seite geht.
// Der Destruktor (= Dispose) setzt _native auf nullptr. Ohne diese Prüfung
// würde der Aufruf über einen Null-Zeiger in die native Seite gelangen,
// und das Programm stürzt mit einer Zugriffsverletzung ab, statt mit
// einer .NET-Ausnahme. Aus Sicht von C# ist die erwartete Reaktion
// "Zugriff nach Dispose löst ObjectDisposedException aus" - diese Prüfung
// wird in jeder Methode gebraucht, die _native verwendet
if (_native == nullptr)
{
throw gcnew ObjectDisposedException("AnalyzerWrapper");
}
NativeLib::AnalyzeOptions nativeOptions{};
nativeOptions.threshold = options->Threshold;
nativeOptions.modelPath = marshal_as<std::wstring>(options->ModelPath);
try
{
auto nativeResult = _native->Analyze(
marshal_as<std::wstring>(imagePath),
nativeOptions);
auto managed = gcnew AnalysisResult();
managed->Ok = nativeResult.ok;
managed->Message = gcnew String(nativeResult.message.c_str());
managed->Scores = gcnew List<int>();
for (int score : nativeResult.scores)
{
managed->Scores->Add(score);
}
return managed;
}
catch (const std::exception& ex)
{
throw gcnew InvalidOperationException(gcnew String(ex.what()));
}
}
Die C#-Seite wird dadurch bemerkenswert einfach.
using var analyzer = new AnalyzerWrapper(@"C:\license.dat");
var result = analyzer.Analyze(
@"C:\input.png",
new AnalysisOptions
{
Threshold = 80,
ModelPath = @"C:\model.bin"
});
if (!result.Ok)
{
Console.WriteLine(result.Message);
}
Was C# zu sehen bekommt, sind string, List<int> und IDisposable.
Die Belange von IntPtr, Freigabefunktionen und nativen Zeichenkettenpuffern bleiben unsichtbar.
Genau das ist der entscheidende Punkt.
7. Fälle, in denen Sie C++/CLI trotzdem nicht wählen sollten
Natürlich ist C++/CLI kein Allheilmittel. Es gibt auch Situationen, in denen man es besser nicht wählt.
- Die Gegenseite veröffentlicht von vornherein eine saubere C-API
- In diesem Fall ist P/Invoke die naheliegendere Wahl.
- Plattformunabhängigkeit wird benötigt
- C++/CLI setzt Windows voraus.
- Die Grenzfläche ist klein und die Typen sind einfach
- Der Aufwand für eine zusätzliche Wrapper-DLL kann dann größer sein als der Nutzen.
- AOT- oder Auslieferungsbeschränkungen werden sehr streng betrachtet
- Hier lohnt es sich, zuerst die Anforderungen der Gesamtarchitektur zu prüfen.
Nur der letzte Punkt, „AOT- und Auslieferungsbeschränkungen“, bleibt abstrakt. Deshalb sind hier die Einschränkungen aufgelistet, die in der Praxis tatsächlich greifen. Das ist eine Stelle, in die man leicht hineintappt, wenn man noch im Gefühl der .NET-Framework-Ära verharrt.
| Einschränkung | Inhalt | Auswirkung in der Praxis |
|---|---|---|
| Betriebssystem | C++/CLI, das .NET (die .NET-Core-Reihe) als Ziel hat, ist ausschließlich für Windows | Wenn geplant ist, es in Linux-Containern oder auf macOS auszuführen, scheidet die Option an dieser Stelle bereits aus |
| Native AOT | Auf der Liste der von Native AOT nicht unterstützten Funktionen ist C++/CLI ausdrücklich genannt. Ebenfalls nicht nutzbar sind dynamisches Laden wie Assembly.LoadFile, System.Reflection.Emit und in Windows integriertes COM |
Das lässt sich nicht mit dem Ansatz vereinbaren, über PublishAot eine einzelne native Binärdatei zu erzeugen |
| Ausgabeform | Bei .NET als Ziel ist nur eine DLL möglich, keine exe. Auch .NET Standard lässt sich nicht als Ziel wählen |
Der Einstiegspunkt wird in eine C#-exe gelegt, und C++/CLI wird als referenzierte DLL eingebunden |
| Projektform | Statt eines SDK-Style-csproj wird .vcxproj verwendet. Ein einzelnes Projekt kann auch nicht mehrere .NET-Versionen als Multi-Target haben |
Werden sowohl eine .NET-Framework- als auch eine .NET-Version benötigt, trennt man die Projektdateien |
| Laufzeitabhängigkeit | Da mit /clr auch /MD aktiviert wird, wird die DLL der MSVC-Laufzeit benötigt. Bei .NET als Ziel muss außerdem ijwhost.dll in der Ausgabe abgelegt werden |
Wer XCOPY-Verteilung oder Single-File-Veröffentlichung voraussetzt, sollte das vorher prüfen |
| CPU-Architektur | Da eine gemischte Assembly native Maschinenbefehle enthält, lässt sich nicht wie bei C#s AnyCPU eine einzige Binärdatei für alle Architekturen verwenden | Sie bauen und verteilen separat für jedes Ziel, etwa x86/x64 |
| Ladeverhalten | Ab .NET 7 wird stets in den Standard-AssemblyLoadContext geladen. Bei .NET 6 und früher kann es beim ersten Aufruf von der nativen Seite in einen separaten AssemblyLoadContext geladen werden |
Bei einer Architektur, die pro Plugin einen eigenen Ladekontext verwendet, sollte man das Verhalten vorher prüfen |
C++/CLI-Projekte können .NET (die .NET-Core-Reihe) übrigens erst ab Visual Studio 2019 als Ziel wählen. Steht nur eine ältere Umgebung zur Verfügung, muss man zunächst von .NET Framework als Grundlage ausgehen.
Das Entscheidungskriterium lautet also: „Wo lässt sich die Komplexität der nativen DLL am natürlichsten übersetzen?“ Einfach: P/Invoke. Komplex: C++/CLI. Mit dieser Aufteilung liegt man meist richtig.
8. Zusammenfassung
Als Methode, native DLLs aus C# zu nutzen, ist P/Invoke nach wie vor der Königsweg. Das gilt allerdings nur, wenn die Gegenseite als C-API unkompliziert ist.
Ist die native Seite als C++-Bibliothek entworfen, lässt sich die Grenzfläche in vielen Fällen sauberer halten, indem man einen dünnen C++/CLI-Wrapper baut, statt sich auf der C#-Seite mit IntPtr und Marshalling-Attributen abzumühen.
Vor allem wenn Folgendes eine Rolle spielt:
- klassenbasierte APIs
- Annahmen über Besitzverhältnisse
std::wstringundstd::vector- Umwandlung von Ausnahmen
- Callbacks
- schrittweise Migration
ist C++/CLI eine durchaus realistische Option.
Die Arbeit dabei ist nicht spektakulär. Aber solche Entscheidungen darüber, wo man die Grenze ordentlich zieht, wirken sich später ganz konkret auf die Wartbarkeit aus. Wenn Sie vorhandene Windows-Bestände und .NET gemeinsam nutzen wollen, ist C++/CLI nach wie vor ausgesprochen brauchbar.
9. Quellen
- Vollständiger Beispielcode zu diesem Artikel (native C++-Bibliothek, C++/CLI-Wrapper, C#-Konsumentenseite) - komurasoft-blog-samples (GitHub)
- Mixed (Native and Managed) Assemblies - Microsoft Learn
- .NET programming with C++/CLI - Microsoft Learn
- Migrate C++/CLI projects to .NET - Microsoft Learn
- How to: Define and consume classes and structs (C++/CLI) - Microsoft Learn
- /clr (Common Language Runtime compilation) - Microsoft Learn
- Native AOT deployment overview - Microsoft Learn
- Using C++ Interop (Implicit PInvoke) - Microsoft Learn
- Platform Invoke (P/Invoke) - Microsoft Learn
- Overview of Marshaling in C++/CLI - Microsoft Learn
- marshal_as - Microsoft Learn
- Performance considerations for interop (C++) - Microsoft Learn
Verwandte Artikel
Aktuelle Artikel mit denselben Schlagwörtern führen zu verwandten Themen weiter.
Laufen Business-Anwendungen unter Windows on Arm? ── Die Realität von x64-Emulation (Prism) und nativen DLLs/COM
Eine Antwort für Entwicklerinnen, Entwickler und IT-Verantwortliche auf die Frage „Läuft unsere Business-Anwendung unter Windows on Arm?“...
WMI/CIM aus C# und PowerShell verwenden — Praxisleitfaden für Hardwareinformationen, Prozessüberwachung und Remoteabfragen
WMI/CIM ist die Standardlösung, um die Seriennummer eines PCs auszulesen, freien Festplattenspeicher zu überwachen und den Start von Proz...
USB-Geräte aus einer Windows-App ansprechen — Die Wahl zwischen virtual COM, HID, WinUSB und Hersteller-SDKs
Ein Vergleich von vier Wegen, Geräte und USB-Hardware aus einer Windows-Anwendung anzusteuern: virtueller COM-Port, HID, WinUSB und Herst...
Code-Design für Geschäftssysteme — Produkt- und Kundencodes festlegen, und Prüfziffern
Ein praxisnaher Leitfaden zur Festlegung des Codesystems eines Geschäftssystems, einschließlich Produkt- und Kundencodes. Behandelt eine ...
Das Datenbankschema Ihrer Business-Anwendung versionieren ── Migrationspraxis gegen „jeder Kunde hat eine andere Datenbank“
Ein praktischer Leitfaden zur Versionierung des Datenbankschemas einer Business-Anwendung, deren Datenbanken über zahlreiche Kundenstando...
Verwandte Themen
Diese Seiten ordnen den Artikel in einen größeren Leistungs- und Entscheidungskontext ein.
Technische Windows-Themen
Portal zu Windows-Entwicklung, Fehleranalyse und der Nutzung bestehender Assets.
32-/64-Bit-Interoperabilität
32-/64-Bit-Kompatibilität, native Grenzen und Windows-Entscheidungen.
Leistungen zu diesem Thema
Dieser Artikel ist direkt mit den folgenden Leistungen verbunden.
Windows-App-Entwicklung
Geschäftsanwendungen, Geräteintegration und Kommunikationstools von den Anforderungen bis zur Umsetzung.
Häufige Fragen
Fragen, die in Beratungen zu diesem Artikelthema häufig gestellt werden.
- Wie unterscheidet man, wann man P/Invoke und wann einen C++/CLI-Wrapper einsetzt?
- Wenn die Gegenseite eine flache Menge von über extern "C" veröffentlichten C-Funktionen ist, ist P/Invoke die naheliegende und einfachste Wahl. Wenn die Gegenseite dagegen eine klassenzentrierte C++-Bibliothek ist, bei der Besitzverhältnisse, Zeichenketten, Arrays, Ausnahmen und Callbacks eine Rolle spielen, lässt sich das Ganze leichter warten, wenn man einen dünnen C++/CLI-Wrapper dazwischenlegt. Das Entscheidungskriterium lautet: „Wo lässt sich die Komplexität der nativen DLL am natürlichsten übersetzen?“ Bei einfachen Fällen funktioniert P/Invoke, bei komplexen C++/CLI – mit dieser Aufteilung liegt man meist richtig.
- Was wird durch einen C++/CLI-Wrapper einfacher?
- Auf der C++/CLI-Seite können Sie die nativen Header einbinden und std::wstring oder std::vector als reine C++-Typen behandeln, sodass die C#-Seite die C++-Welt nicht mehr nachbilden muss. C# bekommt nur eine .NET-typische API zu sehen – string, byte[], List<T>, IDisposable, Ausnahmen –, während IntPtr, Freigabefunktionen und die Belange des Marshalling verborgen bleiben. C++-Ausnahmen oder Fehlercodes lassen sich an der Grenze in .NET-Ausnahmen umwandeln, und eine schrittweise Migration, die vorhandene Bestände weiternutzt, wird dadurch ebenfalls einfacher.
- Wann wird es mühsam, ausschließlich mit P/Invoke voranzukommen?
- Ist die native DLL klassenzentriert um C++ herum entworfen, kann P/Invoke direkt nur die exportierten Funktionen der DLL ansprechen. Also braucht es irgendwo eine Schicht, die alles auf C-förmige Funktionen herunterbricht – im Grunde beginnt man damit, eine C-kompatible API zu entwerfen. Kommen dann noch Dinge hinzu wie die Rückgabe von std::wstring oder std::vector, der Empfang von Fortschritt über Callbacks oder das Auftreten von C++-Ausnahmen, häufen sich auf der C#-Seite MarshalAs, manuelle Puffer und die Verwaltung der Delegate-Lebensdauer an. Wenn man Besitzverhältnisse und Lebensdauer-Annahmen auf Basis von IntPtr ausdrückt, wird das beim späteren Wiederlesen ziemlich unangenehm.
- Gibt es Fälle, in denen man C++/CLI besser nicht wählen sollte?
- Ja, die gibt es. Wenn die Gegenseite von vornherein eine saubere C-API veröffentlicht, ist P/Invoke die naheliegendere Wahl. Da C++/CLI außerdem Windows voraussetzt, kommt es nicht infrage, wenn Plattformunabhängigkeit gebraucht wird. Ist die Grenzfläche klein und sind die Typen einfach, kann der Aufwand für eine zusätzliche Wrapper-DLL größer sein als der Nutzen, und wer AOT- oder Auslieferungsbeschränkungen streng im Blick hat, sollte zuerst die Anforderungen der Gesamtarchitektur klären.
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.