Win32-APIs sicher aus C# aufrufen — Ein praxistauglicher P/Invoke-Leitfaden (DllImport / LibraryImport / CsWin32)

· · P/Invoke, DllImport, LibraryImport, CsWin32, C#, .NET, Win32, SafeHandle, Native Interop, Windows-Entwicklung, Technische Beratung

Auf diesem Blog haben wir bereits mehrere Artikel geschrieben, die native Interoperabilität berühren – die Wahl zwischen einem C++/CLI-Wrapper und P/Invoke, den Aufruf einer C#-Native-AOT-DLL aus C/C++, eine COM-Brücke zum Aufruf einer 64-Bit-DLL aus einer 32-Bit-App und wie die Windows-DLL-Namensauflösung funktioniert – hatten aber P/Invoke selbst, das Fundament unter all dem, noch nicht als eigenständiges Thema behandelt.

P/Invoke hat den Komfort, dass man „die DLL-Funktion nur als extern deklarieren muss, um sie aufrufen zu können“, ist aber auch eine Technik, bei der man sich mindestens einmal verbrennt – bei String-Marshalling, Handle-Lebensdauern, dem Abrufen von Fehlercodes oder dem Struktur-Layout. Dieser Artikel arbeitet die praxisrelevanten Punkte durch, mit Schwerpunkt auf LibraryImport, dem Standard seit .NET 7.

1. Das Wichtigste zuerst

  • Machen Sie ab .NET 7 LibraryImport statt DllImport zu Ihrem Standard. Es erzeugt Marshalling-Code zur Kompilierzeit, funktioniert dadurch mit Native AOT und Trimming, hat keine Laufzeitkosten für die IL-Stub-Erzeugung und lässt sich im Debugger durch den generierten Code durchsteppen. Der Analyzer SYSLIB1054 markiert Stellen, an denen ein DllImport umgeschrieben werden sollte.12
  • Wenn Sie Signaturen für Win32-APIs von Hand schreiben, ziehen Sie CsWin32 in Betracht. Listen Sie einfach die aufzurufenden Funktionsnamen in NativeMethods.txt auf, und es erzeugt LibraryImport-Signaturen, Konstanten und Strukturen aus den offiziellen Win32-Metadaten.3
  • Legen Sie StringMarshalling für Zeichenketten explizit fest, und vermeiden Sie StringBuilder. Das Marshalling von StringBuilder beinhaltet immer eine Kopie in einen nativen Puffer und ist ein Mechanismus, der sowohl ineffizient als auch fehleranfällig bei der Behandlung der Terminierung ist.4
  • Halten Sie Handles als von SafeHandle abgeleitete Klassen, nicht als rohe IntPtr-Werte. Das ist die grundlegende Disziplin nativer .NET-Interoperabilität, die vorzeitige Freigabe durch den GC, doppelte Freigabe und „Handle-Recycling-Angriffe“ verhindert.56
  • Setzen Sie SetLastError = true, lesen Sie unmittelbar nach dem Aufruf Marshal.GetLastPInvokeError(). Sie müssen es erfassen, bevor irgendeine andere Ausführung von verwaltetem Code den Fehlercode überschreibt.7
  • Setzen Sie für Strukturen standardmäßig LayoutKind.Sequential, und entscheiden Sie bewusst, ob Pack angegeben werden soll. Das tatsächliche Layout unter Pack = 0 (dem Standard) ist etwas anderes als der /Zp-Standard des C++-Compilers (8 Byte auf x86/ARM/ARM64, 16 Byte auf x64/ARM64EC), und es kann sich sogar zwischen .NET Framework und .NET 5+ unterscheiden. Nehmen Sie nicht an, „der Standard ist bestimmt korrekt“.8
  • Verwalten Sie die Lebensdauer von Callbacks (Delegaten) so, dass sie nicht vom GC eingesammelt werden, bevor die native Seite mit ihnen fertig ist. Halten Sie sie in einem static-Feld oder verwenden Sie GC.KeepAlive, und bevorzugen Sie nach Möglichkeit UnmanagedCallersOnly.9
  • P/Invoke, ein C++/CLI-Wrapper und COM-Interop sind keine Konkurrenten – sie sind eine Arbeitsteilung. Für eine reine C-Schnittstelle verwenden Sie P/Invoke; sind C++-Klassen, Besitzverhältnisse oder Ausnahmen im Spiel, verwenden Sie einen C++/CLI-Wrapper; müssen Sie eine Prozessgrenze überschreiten (etwa eine 32/64-Bit-Brücke), verwenden Sie COM. Die Entscheidungstabelle in Abschnitt 10 legt das dar.

2. DllImport vs. LibraryImport — welches verwenden

DllImport ist der altbewährte Mechanismus: Zur Laufzeit erzeugt die Runtime einen IL-Stub für das Marshalling, JIT-kompiliert ihn und ruft erst dann auf. Da die Erzeugung zur Laufzeit erfolgt, verträgt es sich schlecht mit Konfigurationen wie Native AOT oder Trimming, die die Assembly vorab kompilieren, und die Erzeugungskosten selbst sind nicht null.1

LibraryImport ist ein in .NET 7 hinzugefügter Source-Generator, der für partial-Methoden zur Kompilierzeit Marshalling-Code erzeugt. Da der erzeugte Code als C#-Quelltext existiert, können Sie ihn im Debugger durchsteppen, und Signaturfehler werden früh als Build-Fehler erkannt.1

using System.Runtime.InteropServices;

internal static partial class NativeMethods
{
    [LibraryImport("nativelib", EntryPoint = "to_lower", StringMarshalling = StringMarshalling.Utf16)]
    internal static partial string ToLower(string str);
}

In diesem string-Rückgabewert steckt eine leicht zu übersehende Annahme. Der Marshaller versucht nach dem Kopieren des Inhalts immer, den Speicher freizugeben, auf den der zurückgegebene Zeiger verweist. Unter Windows wird dazu CoTaskMemFree verwendet – hat die native Seite diesen Zeiger also mit etwas anderem als CoTaskMemAlloc reserviert (ein statischer Puffer, malloc, new[] und Ähnliches – bei einer C-API nicht ungewöhnlich), gibt der Marshaller den Speicher mit dem falschen Allokator frei, was zu Heap-Beschädigung oder einem Absturz führt.10 Sofern der Header oder die Dokumentation der Gegenseite nicht ausdrücklich angibt, dass mit etwas CoTaskMemAlloc-Kompatiblem reserviert wird, gestalten Sie den Rückgabewert so, dass er als IntPtr statt als string zurückkommt, und rufen Sie die entsprechende Freigabefunktion (oder das von der Gegenseite geforderte Freigabeverfahren) selbst auf. Den Aufrufer den Puffer reservieren und übergeben zu lassen (ein Zeichenarray anstelle von StringBuilder, wie oben besprochen, oder das später besprochene [Out]-Puffermuster) vermeidet von vornherein, diese Art von Besitzunklarheit einzuführen.

Die wesentlichen Unterschiede zu DllImport sind wie folgt.11

  • CharSet wurde entfernt, ersetzt durch StringMarshalling (Utf16 / Utf8 / eine benutzerdefinierte). ANSI ist weggefallen, und UTF-8 ist jetzt eine vollwertige Option.
  • CallingConvention wurde durch UnmanagedCallConvAttribute ersetzt.
  • Es gibt kein Äquivalent zu ExactSpelling oder PreserveSig. Der Name des Einstiegspunkts muss immer mit exakter Schreibweise angegeben werden, und die Konvertierung des Rückgabewerts erfolgt immer unmittelbar.
  • Sowohl die Klasse als auch die Zielmethode müssen partial sein, und das Projekt benötigt AllowUnsafeBlocks.

DllImport ist weiterhin nötig, wenn Sie sich auf Einstellungen verlassen, die LibraryImport noch nicht unterstützt (etwa bestimmte MarshalAs-Angaben). Da der Analyzer einen Fehler ausgibt, wenn Sie eine nicht unterstützte Einstellung verwenden wollen, ist ein praktisches Vorgehen: zunächst LibraryImport schreiben und nur bei Ablehnung auf DllImport zurückfallen.11

3. CsWin32 — die Option, Signaturen nicht von Hand zu schreiben

Deklariert man Win32-APIs einzeln von Hand mit DllImport/LibraryImport, häuft sich das Risiko, Parametertypen, Konstantenwerte oder die Feldreihenfolge von Strukturen falsch zu machen. CsWin32 (Microsoft.Windows.CsWin32) ist ein Source-Generator, der automatisch die Signaturen, zugehörigen Konstanten und Strukturen für die aufzurufenden Funktionen erzeugt, gezogen aus offiziell bereitgestellten Win32-API-Metadaten.3

Die Verwendung ist einfach: Fügen Sie das NuGet-Paket Ihrem Projekt hinzu und listen Sie die Namen der aufzurufenden Funktionen in einer Textdatei namens NativeMethods.txt auf.

GetDpiForWindow
SetWindowPos
CreateFileW
CloseHandle

Zur Build-Zeit werden P/Invoke-Signaturen für diese Funktionen erzeugt (einschließlich Rückgabewert, Parametern und SetLastError-Angabe). Beachten Sie, dass dies standardmäßig herkömmlichen DllImport-basierten Code erzeugt. Zielen Sie auf Native AOT oder Trimming ab, können Sie durch Angabe von allowMarshaling: false in NativeMethods.json auf LibraryImport-basierten generierten Quelltext umschalten.3 HANDLE-Typen kommen als passende, von SafeHandle abgeleitete Typen heraus, und Zeichenketten erhalten den korrekten CharSet/StringMarshalling, sodass die im handgeschriebenen Code üblichen CharSet-Verwechslungen und Fehler bei der Feldreihenfolge in Strukturen schlicht nicht entstehen können.

Wie wir in „wo sich ein C++/CLI-Wrapper lohnt“ geschrieben haben, ist das Einfügen eines dünnen Wrappers bei komplexen DLLs mit C++-Klassen, Besitzverhältnissen und Ausnahmen wirksam, aber wenn die Gegenseite eine reine Win32-API (oder eine ähnliche DLL mit C-Schnittstelle) ist, ist das automatische Erzeugen von Signaturen mit CsWin32 der kürzeste, am wenigsten fehleranfällige Weg. Für eigene interne DLLs können Sie CsWin32 nicht verwenden, aber auch dann können Sie den Stil des erzeugten Codes als Vorlage nutzen.

4. Fallstricke beim String-Marshalling

Die Compiler von C#, VB und F# weisen einer P/Invoke-Deklaration, die CharSet nicht angibt, standardmäßig CharSet.None zu. CharSet.None verhält sich identisch zu CharSet.Ansi – unter Windows wird es als Nicht-Unicode (eine lokalisierte Codepage) marshallt. Setzt die aufgerufene Win32-API die Unicode-Version voraus (das Suffix W), führt der Aufruf mit diesem Standard zu Zeichensalat oder verlorenen Mehrbyte-Zeichen.12

Bei LibraryImport ist die explizite Angabe von StringMarshalling.Utf16 der Standardansatz. Da die ANSI-Option selbst entfernt wurde, ist der klassische Unfall aus der DllImport-Ära, „sich auf den Standard verlassen und ungewollt ANSI erhalten“, strukturell deutlich unwahrscheinlicher geworden.11

Ein weiterer Fallstrick ist der StringBuilder-Parameter. Er wird häufig für APIs verwendet, bei denen „die native Seite einen Zeichenkettenpuffer beschreibt und zurückgibt“, aber das Marshalling von StringBuilder beinhaltet immer eine Kopie in einen nativen Puffer, und ToString() löst eine weitere Zuweisung aus. Ist der Puffer [Out] (der Standard), ist das ein ineffizienter Mechanismus, bei dem sich bei jedem Aufruf mehrere Zuweisungen stapeln. Hinzu kommt, dass er zu Fehlfunktionen neigt, wenn der zurückgegebene Puffer nicht NUL-terminiert oder eine doppelt NUL-terminierte Zeichenkette ist. Für häufige Aufrufe ist die Verwendung eines Zeichenarrays aus ArrayPool<char> stabiler.4

Auch ein [Out] string-Parameter ist eine zu vermeidende Angabe. Ist die Zeichenkette zufällig interniert, kann das die Runtime destabilisieren.4

5. Verwaltung der Handle-Lebensdauer — warum SafeHandle verwenden

Native Ressourcen wie Dateihandles, Registrierungsschlüssel oder Gerätehandles als rohe IntPtr-Werte zu halten, ist ein bei nativer .NET-Interoperabilität zu vermeidendes Design. Es gibt drei Gründe dafür.5

  • Vorzeitige Freigabe des Handles durch den GC. Hält eine Klasse, die einen Finalizer implementiert, ein Handle in einem IntPtr-Feld, kann ein Wettlauf auftreten, bei dem der GC dieses Objekt einsammelt und das Handle mitten in einem P/Invoke-Aufruf schließt.
  • Handle-Recycling-Angriffe. Windows verwendet Handle-Werte aktiv wieder. Verwenden Sie weiterhin einen veralteten IntPtr, während der Handle-Wert, den er einst trug, einer anderen Ressource neu zugewiesen wurde, wirken Sie am Ende auf eine unzusammenhängende Ressource ein – ein ernster Unfall.
  • Lecks durch asynchrone Ausnahmen. Eine asynchrone Unterbrechung, etwa ein Thread-Abbruch, die zwischen dem Erhalt eines Handles und dessen Speicherung in einem Feld auftritt, kann zu einem Handle-Leck führen.

SafeHandle ist eine abstrakte Klasse, die entwickelt wurde, um diese Probleme zu lösen. Sie erbt von CriticalFinalizerObject, was garantiert, dass ihre Freigabelogik auch bei abnormaler Beendigung der AppDomain zuverlässig ausgeführt wird. P/Invoke-Aufrufe erhöhen und verringern automatisch den Referenzzähler des Handles, sodass es nicht recycelt werden kann, während ein Aufruf läuft.5

Leiten Sie für eigene Handles von etwas wie SafeHandleZeroOrMinusOneIsInvalid im Namensraum Microsoft.Win32.SafeHandles ab und überschreiben Sie ReleaseHandle(). ReleaseHandle() läuft in einer eingeschränkten Ausführungsregion, in der vorausgesetzt wird, dass „es nicht fehlschlagen darf“, sodass die übliche Praxis ist, es frei von komplexer Logik zu halten und auf einen einfachen Freigabe-API-Aufruf zu beschränken. Sie müssen keinen eigenen Finalizer schreiben (und sollten das tatsächlich vermeiden).6

6. Fehlerbehandlung — SetLastError und GetLastPInvokeError

Die meisten Win32-APIs setzen bei einem Fehlschlag über SetLastError einen thread-lokalen Fehlercode, den der Aufrufer mit GetLastError liest. Um das von P/Invoke aus zu behandeln, setzen Sie DllImportAttribute.SetLastError (bei LibraryImport gibt es eine gleichnamige Eigenschaft) auf true.13

[LibraryImport("kernel32", EntryPoint = "SetCurrentDirectoryW", StringMarshalling = StringMarshalling.Utf16, SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool SetCurrentDirectoryW(string path);

Hier sind zwei Punkte beachtenswert.

  • Lesen Sie den Fehlercode direkt nach dem Aufruf. In .NET (ohne .NET Framework) wird bei jedem Aufruf eines P/Invoke mit SetLastError = true die Fehlerinformation zuerst gelöscht, und nur das Ergebnis dieses einen Aufrufs bleibt erhalten. Schieben Sie Protokollierung oder einen anderen API-Aufruf dazwischen, wird sie überschrieben und geht verloren – greifen Sie den Wert also in dem Moment ab, in dem Sie den Fehlschlag erkennen.13
  • Verwenden Sie Marshal.GetLastPInvokeError() statt Marshal.GetLastWin32Error(). Ab .NET 6 sind die beiden funktional identisch, aber Ersteres ist der neuere, empfohlene Name, der plattformübergreifende Absicht widerspiegelt.7
if (!SetCurrentDirectoryW(path))
{
    int error = Marshal.GetLastPInvokeError();
    throw new Win32Exception(error);
}

7. Struktur-Marshalling — blittable Typen und StructLayout

Typen, deren Bit-Darstellung zwischen .NET und nativem Code identisch ist, werden „blittable“ genannt und lassen sich unverändert ohne Konvertierung durchreichen, was sie schnell macht. Dazu zählen Grundtypen wie byte, int und long sowie Strukturen mit festem Layout, die ausschließlich aus blittable Werttypen bestehen. Für blittable Strukturen ist die Verwendung von C#s sizeof() schneller als Marshal.SizeOf<T>(). Umgekehrt ist bool nicht blittable (das native BOOL ist 4 Byte, während ein C/C++-bool 1 Byte ist), und eine unbedachte Verwendung erzeugt Fehler, bei denen die Hälfte des Rückgabewerts verworfen wird.14

Das Struktur-Layout wird mit StructLayoutAttribute gesteuert. Der Standard, LayoutKind.Sequential (in Deklarationsreihenfolge angeordnet), sollte Ihre Grundlage sein, und Sie greifen nur dann zu LayoutKind.Explicit, wenn Sie Feldpositionen explizit angeben müssen, wie bei einer Union.8

Das Feld Pack wird leicht übersehen. Laut offizieller Dokumentation ist die Gesamtausrichtung des Typs das Kleinere aus „der Größe des größten Felds“ und „dem angegebenen Pack-Wert“, und jedes Feld wird an der kleineren Stelle aus „seiner eigenen Größe“ und „der Ausrichtung des Typs“ platziert.8 Anders gesagt: Setzt man Pack explizit auf einen kleinen Wert (etwa 2 oder 4), wirkt es als Ausrichtungsobergrenze, ähnlich wie C++s #pragma pack(N). Andererseits bedeutet der Standardwert 0, dass „die Gesamtausrichtung des Typs der Größe des größten Felds entspricht (ohne weitere besondere Obergrenze)“ – eine andere Regel als der Standard der C++-Compiler-Option /Zp (Ausrichtung von Strukturmembern, standardmäßig eine 8-Byte-Grenze auf x86/ARM/ARM64 und eine 16-Byte-Grenze auf x64/ARM64EC), und beide sollten nicht einfach als gleichwertig behandelt werden.15 Hinzu kommt, dass sich dieses Standardlayout sogar zwischen .NET Framework und .NET 5+ unterscheiden kann. So gibt die offizielle Dokumentation etwa ein Beispiel, bei dem eine Struktur mit einem decimal unter Standard-Packing auf .NET Framework 28 Byte, auf .NET 5+ aber 32 Byte ergibt, aufgrund von Unterschieden in der internen Feldzusammensetzung.8 Anders gesagt: Nehmen Sie nicht pro Architektur an, „es muss stimmen, weil es der Standard ist“. Haben Sie es mit einer DLL zu tun, deren nativer Header über #pragma pack die Packing-Größe explizit ändert, oder die ein Feld mit einer Ausrichtungsanforderung über 8 Byte enthält, sollten Sie entweder Pack auf der C#-Seite explizit angeben oder die tatsächlichen Feld-Offsets mit etwas wie Marshal.OffsetOf verifizieren. Vernachlässigen Sie das, driften Feld-Offsets aus der Ausrichtung, und Daten werden lautlos beschädigt. Umgekehrt: Bei einer unkomplizierten API, die die Header des Windows SDK unverändert verwendet, bei der alle Felder Grundtypen von 8 Byte oder weniger sind, verursacht es in der Praxis fast nie Probleme, die Standardausrichtung unangetastet zu lassen, ohne Pack anzufassen.

// Beispiel, bei dem der native Header explizit pack(4) angibt
[StructLayout(LayoutKind.Sequential, Pack = 4)]
internal struct DeviceInfo
{
    public int DeviceId;
    public uint Flags;
    public long Timestamp;
}

8. Verwaltung der Callback-Lebensdauer (Delegaten)

Es ist nicht ungewöhnlich, dass eine native API einen Callback entgegennimmt, etwa nach dem Muster „diese Funktion aufrufen, wenn fertig“. In verwaltetem Code übernimmt delegate diese Rolle, aber hier lauert ein GC-spezifischer Fallstrick. Auch nachdem Sie über Marshal.GetFunctionPointerForDelegate einen Funktionszeiger aus einem Delegaten erhalten haben, verfolgt der GC den Zusammenhang zwischen diesem Funktionszeiger und dem Delegaten nicht. Wird der Delegat eingesammelt, während die native Seite den Funktionszeiger noch verwendet, führt das zu einem Absturz.9

Ein weiterer leicht zu übersehender Punkt ist die Aufrufkonvention. Übergibt man P/Invoke einen Delegaten als Funktionszeiger an nativen Code, wird ohne anderweitige Angabe die „plattformspezifische Standard-Aufrufkonvention“ verwendet; möchten Sie sie explizit angleichen, hängen Sie UnmanagedFunctionPointerAttribute an den Delegattyp an.16 Auf x64/ARM/ARM64 gibt es faktisch nur eine Aufrufkonvention, sodass das selten echten Schaden anrichtet, selbst wenn man nicht darüber nachdenkt, aber unter Windows x86 (32-Bit) unterscheiden sich Stdcall (der Win32-API-Standard) und Cdecl (verbreitet bei Unix-stämmigen C-Bibliotheken), sodass ein Belassen des Standards zu Stack-Beschädigung führen kann, falls der Header der Gegenseite Cdecl verwendet.16

// Aufrufkonvention explizit angeben. Auf x86-Builds erforderlich, wenn
// die Gegenseite Cdecl verwendet
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate void MyCallback(int code);

private static readonly MyCallback s_callback = OnNativeEvent;  // in einem static-Feld gehalten, um die Lebensdauer festzulegen

// [UnmanagedFunctionPointer] regelt die Konvention, mit der der Callback
// selbst "aufgerufen" wird - das ist etwas anderes als die Konvention
// dieses Aufrufs (RegisterCallback, ein P/Invoke) selbst.
// Der Standard von LibraryImport ist der Plattformstandard (entspricht
// stdcall unter Windows), daher muss dies auch hier explizit angegeben
// werden, wenn die Gegenseite eine Cdecl-C-DLL ist
[LibraryImport("nativelib")]
[UnmanagedCallConv(CallConvs = new[] { typeof(CallConvCdecl) })]
internal static partial void RegisterCallback(MyCallback callback);

private static void OnNativeEvent(int code)
{
    // ...
}

// Aufrufseite
RegisterCallback(s_callback);
GC.KeepAlive(s_callback);  // eine Variable, die kurz danach den Gültigkeitsbereich verlassen könnte, explizit am Leben erhalten

Hält man ihn in einem static-Feld, wird er für die Lebensdauer der Anwendung nicht vom GC eingesammelt. Wenn Sie sicher sind, dass die native Seite den Callback nur für die Dauer eines einzelnen Aufrufs verwendet (und den Funktionszeiger verwirft, sobald der Callback zurückkehrt), funktioniert auch ein leichtgewichtigerer Ansatz – eine lokale Variable plus GC.KeepAlive zur Verlängerung ihrer Lebensdauer.

Die offiziellen Best Practices empfehlen, wo immer möglich eine statische, mit UnmanagedCallersOnlyAttribute markierte Methode kombiniert mit einem Funktionszeiger (delegate*<...>) dem Delegate-Typ vorzuziehen. Das hat geringeren Overhead im Vergleich zum Delegaten-Marshalling und ist mit Native AOT besser kompatibel.9

9. 32-Bit-/64-Bit-Unterschiede

Eine einzige P/Invoke-Signatur zu schreiben bedeutet, dass zur Laufzeit derselbe Codepfad verwendet wird, egal ob er aus einem 32-Bit-Prozess oder einem 64-Bit-Prozess aufgerufen wird. Was hier tendenziell Ärger verursacht, ist, dass die Breite nativer Typen der Bitbreite des Prozesses folgt.

  • Zeigerähnliche Typen wie HANDLE, HWND und LPARAM sind in einem 32-Bit-Prozess 4 Byte und in einem 64-Bit-Prozess 8 Byte groß. Auf .NET-Seite ist es korrekt, sie als IntPtr/UIntPtr (oder nint/nuint) zu empfangen; sie als festgrößiges int/long zu empfangen, erzeugt Code, der nur unter 32-Bit oder nur unter 64-Bit funktioniert.4
  • Enthält eine Struktur eines dieser zeigerähnlichen Felder, ändert sich auch die Gesamtgröße der Struktur je nach Bitbreite. Kombiniert mit der Tatsache, dass sich der Standardwert von Pack aus Abschnitt 7 zwischen Architekturen unterscheidet, testen Sie unter der Annahme, dass dieselbe Strukturdefinition zwischen einem 32-Bit-Build und einem 64-Bit-Build am Ende ein unterschiedliches binäres Layout haben kann.
  • Die Anforderung, „aus einer bestehenden 32-Bit-App heraus Funktionalität aus einer nur 64-Bit-fähigen DLL nutzen zu wollen“, kann P/Invoke selbst nicht lösen (DLLs unterschiedlicher Bitbreite können nicht im selben Prozess koexistieren). In diesem Fall müssen Sie die Prozesse trennen und mit einer COM-Brücke oder Named Pipes überbrücken. Ein durchgearbeitetes Beispiel finden Sie in „Eine COM-Brücken-Fallstudie: eine 64-Bit-DLL aus einer 32-Bit-App aufrufen“.
  • Dass die DLL überhaupt nicht gefunden wird oder eine unbeabsichtigte Version geladen wird, ist kein P/Invoke-Problem – es ist ein Problem des Windows-Loaders. „Wie die Windows-DLL-Namensauflösung funktioniert“ behandelt Suchreihenfolge und SxS-Verhalten – prüfen Sie das bei der Ursachenermittlung einer DllNotFoundException.

10. Entscheidungstabelle — P/Invoke vs. C++/CLI-Wrapper vs. COM-Interop

P/Invoke ist nicht der einzige Weg, um nativen Code aus C# aufzurufen. Ist die Gegenseite eine komplexe DLL mit C++-Klassen, Besitzverhältnissen und Ausnahmen, funktioniert ein C++/CLI-Wrapper gut; müssen Sie eine Prozessgrenze überschreiten (eine 32/64-Bit-Brücke oder die Nutzung aus einer anderen Sprache wie VBA), wird COM zur Option.

Aspekt P/Invoke (LibraryImport) C++/CLI-Wrapper COM-Interop
Am besten geeignet für Eine reine C-Schnittstelle (Strukturen und primitive Typen) Eine DLL mit C++-Klassen, Besitzverhältnissen, Ausnahmen, std::-Typen Eine Gegenseite über eine Prozessgrenze hinweg, oder eine andere Sprache wie VBA
Implementierungsaufwand Niedrig bis mittel (nur die Signatur definieren) Mittel (eine weitere Wrapper-Schicht schreiben) Hoch (Schnittstellendesign, Registrierungseintrag)
Typsicherheit Mittel (ein handgeschriebener Signaturfehler zeigt sich unter Umständen erst zur Laufzeit; CsWin32 verbessert das) Hoch (kann direkt mit C++-Typen arbeiten) Mittel (durch IDL/Typbibliothek garantiert)
AOT-/Trimming-Unterstützung Ausgezeichnet (mit LibraryImport) Schlecht (C++/CLI unterstützt kein Native AOT) Schlecht
Ausnahmebehandlung Keine (muss manuell über Rückgabewert oder HRESULT beurteilt werden) Ausgezeichnet (C++-Ausnahmen lassen sich in .NET-Ausnahmen umwandeln) Gut (HRESULT wird in eine COM-Ausnahme umgewandelt)
Überschreiten von Prozessgrenzen Nein (nur prozessintern) Nein (nur prozessintern) Ausgezeichnet (Out-of-Process-Server sind möglich)
Debugging-Freundlichkeit Gut (der generierte Code von LibraryImport lässt sich durchsteppen) Gut (sowohl nativer als auch verwalteter Code lassen sich in VS debuggen) Schlecht (Probleme rund um Referenzzählung oder Registrierung sind schwer aufzuspüren)
Lernaufwand Niedrig Mittel bis hoch (C++/CLI-Syntax) Hoch (das gesamte Regelwerk von COM)

Ist die Gegenseite „eine C-funktionsbasierte Win32-API oder Ihre eigene unkomplizierte C-DLL“, entscheiden Sie sich für P/Invoke (nach Möglichkeit CsWin32); „ist die Gegenseite eine C++-Klasse und Sie möchten, dass Besitzverhältnisse und Ausnahmen natürlich durchgereicht werden“, entscheiden Sie sich für einen C++/CLI-Wrapper (Details siehe „Eine native DLL aus C# aufrufen: C++/CLI-Wrapper vs. P/Invoke“); und „müssen Sie eine Prozessgrenze überschreiten oder es aus VBA nutzbar machen“, entscheiden Sie sich für COM – dieser Reihenfolge zu folgen hält die Entscheidung unkompliziert. Für die entgegengesetzte Richtung (C#-Verarbeitung aus C/C++ aufrufen) ist die Einrichtung nicht P/Invoke, sondern das UnmanagedCallersOnly von Native AOT. Siehe „Wie man eine C#-Native-AOT-DLL aus C/C++ aufruft“.

11. Ein durchgearbeitetes Beispiel — Handle-Operationen und Fehlerbehandlung mit LibraryImport

Hier ein Beispiel, das alles bisher Behandelte kombiniert. Wir werden OpenDevice / CloseDevice / ReadDeviceData umhüllen, die von einem fiktiven Sensor-Geräte-SDK device.dll bereitgestellt werden, einschließlich der Handle-Verwaltung über SafeHandle, dem Kompilierzeit-Marshalling über LibraryImport sowie der Fehlerbehandlung über SetLastError + GetLastPInvokeError.

Zunächst die von SafeHandle abgeleitete Klasse, die das native Handle hält.

using Microsoft.Win32.SafeHandles;

// Umhüllt ein Handle von device.dll. Unabhängig von der GC-Lebensdauer
// verhindert dies doppelte Freigabe, Recycling-Angriffe und vorzeitige Freigabe des Handles
internal sealed class DeviceSafeHandle : SafeHandleZeroOrMinusOneIsInvalid
{
    // Ein parameterloser Konstruktor ist erforderlich, da dies als
    // Rückgabetyp von OpenDevice verwendet wird
    public DeviceSafeHandle() : base(ownsHandle: true)
    {
    }

    protected override bool ReleaseHandle()
        // ReleaseHandle läuft in einer eingeschränkten Ausführungsregion,
        // die "darf nicht fehlschlagen" voraussetzt. Auf einen einzigen,
        // einfachen nativen Freigabeaufruf beschränken
        => DeviceNativeMethods.CloseDevice(handle);
}

Als Nächstes die P/Invoke-Deklarationen. Zeichenketten geben explizit StringMarshalling.Utf16 an, und jeder Aufruf, der fehlschlagen kann, erhält SetLastError = true.

using System.Runtime.InteropServices;

internal static partial class DeviceNativeMethods
{
    private const string DeviceDll = "device.dll";

    // Das Handle zum Rückgabewert zu machen, lässt SafeHandle die Lebensdauer
    // in dem Moment verfolgen, in dem der Aufruf erfolgreich ist. Bei einem
    // Fehlschlag wird ein Handle mit IsInvalid = true zurückgegeben
    [LibraryImport(DeviceDll, EntryPoint = "OpenDevice",
        StringMarshalling = StringMarshalling.Utf16, SetLastError = true)]
    internal static partial DeviceSafeHandle OpenDevice(string devicePath);

    // Eine interne API, die direkt aus dem ReleaseHandle von SafeHandle
    // aufgerufen werden soll. handle dient nur der Freigabe, daher als
    // roher IntPtr empfangen
    [LibraryImport(DeviceDll, EntryPoint = "CloseDevice", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    internal static partial bool CloseDevice(IntPtr handle);

    // buffer ist ein bereits vom Aufrufer reserviertes Array. byte[] ist
    // blittable, wird daher fixiert (pinned), und das native Schreiben
    // erfolgt gegen denselben Speicher. Die explizite Angabe von [Out]
    // ist nicht zwingend erforderlich, wird aber zur Selbstdokumentation
    // der Absicht hinzugefügt
    [LibraryImport(DeviceDll, EntryPoint = "ReadDeviceData", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    internal static partial bool ReadDeviceData(
        DeviceSafeHandle handle,
        [Out] byte[] buffer,
        int bufferLength,
        out int bytesRead);
}

Zuletzt ein dünner Wrapper auf der konsumierenden Seite. Der Fehlercode wird in dem Moment erfasst, in dem ein Fehlschlag erkannt wird, und in eine Win32Exception verpackt, bevor er an den Aufrufer weitergegeben wird.

using System.ComponentModel;
using System.Runtime.InteropServices;

public sealed class DeviceConnection : IDisposable
{
    private readonly DeviceSafeHandle _handle;

    private DeviceConnection(DeviceSafeHandle handle) => _handle = handle;

    public static DeviceConnection Open(string devicePath)
    {
        DeviceSafeHandle handle = DeviceNativeMethods.OpenDevice(devicePath);
        if (handle.IsInvalid)
        {
            // Sofort bei Fehlschlag erfasst, bevor ein anderer API-Aufruf ihn überschreiben kann
            int error = Marshal.GetLastPInvokeError();
            handle.Dispose();
            throw new IOException(
                $"Gerät konnte nicht geöffnet werden: {devicePath} (Win32-Fehler {error})",
                new Win32Exception(error));
        }
        return new DeviceConnection(handle);
    }

    public byte[] Read(int maxBytes)
    {
        var buffer = new byte[maxBytes];
        if (!DeviceNativeMethods.ReadDeviceData(_handle, buffer, buffer.Length, out int bytesRead))
        {
            int error = Marshal.GetLastPInvokeError();
            throw new IOException($"Lesen vom Gerät fehlgeschlagen (Win32-Fehler {error})",
                new Win32Exception(error));
        }
        return bytesRead == buffer.Length ? buffer : buffer[..bytesRead];
    }

    // Nur SafeHandle.Dispose aufzurufen genügt; keinen Finalizer schreiben
    public void Dispose() => _handle.Dispose();
}

Code, der DeviceConnection konsumiert, braucht nichts weiter als einen using-Block und muss sich keine Sorgen um ausgelassene Handle-Freigaben machen. Das Prinzip, was wo erkannt und wie übersetzt wird, entspricht in dieser Art geschichteten Aufbaus genau der schichtweisen Verantwortungsaufteilung, die wir in „Wo sollten catch und Protokollierung bei der Ausnahmebehandlung liegen?“ beschrieben haben. Der entscheidende Schritt hier ist, Fehlercodes der nativen Schicht an der P/Invoke-Grenze in Ausnahmen zu übersetzen und alles oberhalb dieser Grenze als gewöhnliche .NET-Ausnahmen zu behandeln.

12. Zusammenfassung

Hinter dem Komfort, „die DLL-Funktion deklarieren, um sie aufrufen zu können“, ist P/Invoke eine Technik, bei der man sich mindestens einmal verbrennt – bei String-Marshalling, Handle-Lebensdauern, dem Zeitpunkt der Fehlercode-Abfrage oder dem Struktur-Layout. Machen Sie ab .NET 7 LibraryImport zu Ihrem Standard, und lassen Sie nach Möglichkeit CsWin32 die Signaturen selbst erzeugen. Legen Sie StringMarshalling für Zeichenketten explizit fest und vermeiden Sie StringBuilder. Halten Sie Handles mit SafeHandle. Verwenden Sie SetLastError, greifen Sie den Fehlercode unmittelbar nach dem Aufruf ab. Behalten Sie bei Strukturen im Hinterkopf, dass sich der Standardwert von Pack zwischen Architekturen unterscheidet. Verwalten Sie die Callback-Lebensdauer explizit. Jeder in diesem Artikel genannte Punkt ist die Art von Sache, „die nur wenige Zeilen kostet, wenn man sie kennt, aber zu einem Fehler wird, der sich nur in der Produktion reproduzieren lässt, wenn man sie nicht kennt“.

Und ob man mit P/Invoke durchhält oder auf einen C++/CLI-Wrapper oder COM umsteigt, hängt davon ab, wie „C-artig“ die betreffende DLL ist und ob eine Prozessgrenze überschritten werden muss. Anfragen zum Aufruf bestehender nativer Bestände aus C# oder umgekehrt zum Aufruf von C#-Beständen aus nativem Code lassen sich oft nicht ohne einen Blick auf die tatsächlichen Header-Dateien oder die DLL-Struktur zu einer optimalen Konfiguration auflösen – melden Sie sich also gerne, wenn Sie unsicher sind.

Verwandte Artikel

Verwandte Beratungsleistungen

Die KomuraSoft LLC übernimmt technische Beratung zum Grenzdesign zwischen C# und nativen DLLs/Win32-APIs, die Entwicklung und Untersuchung von COM-Komponenten sowie Migrationsprojekte, die bestehende native Bestände mit .NET verbinden.

  1. Microsoft Learn, Source generation for platform invokes. Zur Kompilierzeit-Marshalling-Erzeugung über LibraryImportAttribute, ihrem Unterschied zur Laufzeit-IL-Stub-Erzeugung von DllImport sowie ihrer Kompatibilität mit Native AOT/Trimming.  2 3

  2. Microsoft Learn, SYSLIB diagnostics for p/invoke source generation. Zur Liste der Diagnose-IDs einschließlich des Analyzers SYSLIB1054, der zum Umschreiben von DllImport auf LibraryImport auffordert. 

  3. Microsoft Learn, Build a C# .NET app with WinUI 3 and Win32 interop. Zur Einführung des C#/Win32 P/Invoke Source Generator (Microsoft.Windows.CsWin32) und zum Verfahren, Signaturen durch Auflisten von Funktionsnamen in NativeMethods.txt zu erzeugen.  2 3

  4. Microsoft Learn, Native interoperability best practices. Dazu, dass das Marshalling von StringBuilder immer eine Kopie in einen nativen Puffer beinhaltet und ineffizient ist, zum Vermeiden von [Out]-string-Argumenten, sowie zur Verwendung von SafeHandle unter Vermeidung von Finalizern.  2 3 4

  5. Microsoft Learn, SafeHandle Class. Dazu, wie SafeHandle vorzeitige Handle-Freigabe und Recycling-Angriffe verhindert, sowie zur durch CriticalFinalizerObject garantierten Freigabe.  2 3

  6. Microsoft Learn, Native interoperability best practices - General guidance. Zur Richtlinie, SafeHandle für die Verwaltung der Lebensdauer nicht verwalteter Ressourcen zu verwenden und die Verwendung von Finalizern zu vermeiden.  2

  7. Microsoft Learn, Marshal.GetLastPInvokeError Method. Dazu, wie man den Fehlercode unmittelbar nach einem P/Invoke-Aufruf mit SetLastError=true abruft, und dazu, dass dies ab .NET 6 gegenüber GetLastWin32Error empfohlen wird.  2

  8. Microsoft Learn, StructLayoutAttribute.Pack Field. Zur Bedeutung des Standardwerts 0 von Pack, “die aktuelle Standard-Packing-Größe der Plattform”, sowie zu den Regeln für die Berechnung der Feldausrichtung.  2 3 4

  9. Microsoft Learn, Native interoperability best practices - Prevent delegate collection with GC.KeepAlive. Dazu, dass der GC den Zusammenhang zwischen einem über GetFunctionPointerForDelegate erhaltenen Funktionszeiger und seinem Delegaten nicht verfolgt, zur Lebensdauerverlängerung mit GC.KeepAlive sowie zur Empfehlung, UnmanagedCallersOnly zu verwenden.  2 3

  10. Microsoft Learn, Default Marshalling Behavior - Memory management with the interop marshaller. Dazu, dass der Marshaller immer versucht, von nicht verwaltetem Code reservierten Speicher freizugeben, dass unter Windows CoTaskMemFree verwendet wird, und dass mit etwas anderem als CoTaskMemAlloc reservierter Speicher als IntPtr empfangen und manuell freigegeben werden muss. 

  11. Microsoft Learn, Source generation for platform invokes - Differences from DllImport. Dazu, dass CharSet durch StringMarshalling ersetzt wurde, zur Verwendung von UnmanagedCallConvAttribute statt CallingConvention, und dazu, dass es kein Äquivalent zu ExactSpelling/PreserveSig gibt.  2 3

  12. Microsoft Learn, Charsets and marshalling. Dazu, dass die Compiler von C#, Visual Basic und F# standardmäßig CharSet.None zuweisen, wenn CharSet nicht angegeben wird, und dass sich CharSet.None wie CharSet.Ansi verhält (Marshalling als Nicht-Unicode). 

  13. Microsoft Learn, DllImportAttribute.SetLastError Field. Zum Verhalten in .NET, wenn SetLastError auf true gesetzt wird, einschließlich der Tatsache, dass die Fehlerinformation bei jedem Aufruf gelöscht wird.  2

  14. Microsoft Learn, Native interoperability best practices - Blittable types. Zur Definition blittable Typen, zum durch die Nicht-Blittable-Eigenschaft von bool verursachten Fallstrick sowie zum Vorteil der Verwendung von sizeof() bei blittable Strukturen. 

  15. Microsoft Learn, /Zp (Struct Member Alignment). Dazu, dass die Standardausrichtung von Strukturmembern des C++-Compilers auf x86/ARM/ARM64 eine 8-Byte-Grenze und auf x64/ARM64EC eine 16-Byte-Grenze ist. 

  16. Microsoft Learn, Unmanaged calling conventions. Dazu, dass Stdcall und Cdecl unter Windows x86 unterschiedliche Standard-Aufrufkonventionen sind, dass es auf x64/ARM/ARM64 faktisch nur eine Aufrufkonvention gibt, sowie zur expliziten Angabe der Aufrufkonvention über UnmanagedFunctionPointerAttribute.  2

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.

Häufige Fragen

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

Sollte ich in C# DllImport oder LibraryImport verwenden?
Machen Sie ab .NET 7 LibraryImport zu Ihrem Standard. Es erzeugt Marshalling-Code zur Kompilierzeit, funktioniert dadurch mit Native AOT und Trimming, vermeidet die Laufzeitkosten der IL-Stub-Erzeugung von DllImport und lässt sich im Debugger durch den generierten Code durchsteppen. Der Analyzer SYSLIB1054 markiert Stellen, an denen ein DllImport umgeschrieben werden sollte. DllImport ist weiterhin nötig, wenn Sie sich auf Einstellungen verlassen, die LibraryImport noch nicht unterstützt, etwa bestimmte MarshalAs-Angaben – praktisch ist es, zunächst LibraryImport zu schreiben und nur bei Ablehnung durch den Analyzer zurückzufallen.
Warum sollte P/Invoke-Code für Handles SafeHandle statt IntPtr verwenden?
Native Handles als rohe IntPtr-Werte zu halten, setzt Sie drei Problemen aus: Der GC kann das besitzende Objekt einsammeln und das Handle mitten im Aufruf schließen, Windows verwendet Handle-Werte aktiv wieder, sodass ein veralteter IntPtr auf eine unzusammenhängende Ressource wirken kann (ein Handle-Recycling-Angriff), und asynchrone Unterbrechungen können Handles auslaufen lassen. SafeHandle löst diese Probleme – es erbt von CriticalFinalizerObject, sodass die Freigabelogik zuverlässig läuft, und P/Invoke-Aufrufe zählen die Referenzen des Handles automatisch, sodass es nicht recycelt werden kann, während ein Aufruf läuft. Leiten Sie für eigene Handles von SafeHandleZeroOrMinusOneIsInvalid ab und beschränken Sie ReleaseHandle auf einen einzigen einfachen Freigabeaufruf.
Wie erhalte ich nach einem fehlgeschlagenen P/Invoke-Aufruf den Win32-Fehlercode?
Setzen Sie SetLastError = true in der Deklaration und lesen Sie unmittelbar nach dem fehlgeschlagenen Aufruf Marshal.GetLastPInvokeError(). In .NET (ohne .NET Framework) löscht jeder P/Invoke-Aufruf mit SetLastError = true zuerst die Fehlerinformation und behält nur das Ergebnis dieses einen Aufrufs, sodass eingeschobene Protokollierung oder ein weiterer API-Aufruf den Wert überschreiben. GetLastPInvokeError ist der ab .NET 6 empfohlene Name; funktional ist er identisch mit GetLastWin32Error, spiegelt aber plattformübergreifende Absicht wider.
Was ist CsWin32 und wann sollte ich es verwenden?
CsWin32 (Microsoft.Windows.CsWin32) ist ein Source-Generator, der P/Invoke-Signaturen, Konstanten und Strukturen aus offiziellen Win32-API-Metadaten erzeugt – Sie listen lediglich die gewünschten Funktionsnamen in einer Datei NativeMethods.txt auf. Handles kommen als passende SafeHandle-abgeleitete Typen heraus und Zeichenketten erhalten das korrekte Marshalling, sodass die im handgeschriebenen Code üblichen CharSet-Verwechslungen und Fehler bei der Feldreihenfolge in Strukturen gar nicht erst entstehen können. Beachten Sie, dass standardmäßig DllImport-basierter Code erzeugt wird; geben Sie allowMarshaling: false in NativeMethods.json an, um LibraryImport-basierte Ausgabe für Native AOT oder Trimming zu erhalten. Für eigene interne DLLs lässt es sich nicht verwenden, aber der erzeugte Code funktioniert dennoch als Vorlage.

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