Chiamare DLL native da C#: wrapper C++/CLI vs P/Invoke
· Aggiornato il: · Go Komura · C++/CLI, C#, Sviluppo Windows, Native Interop
Volere usare asset Windows esistenti o DLL esistenti da C# è un requisito molto comune. Se l’altra parte è un’interfaccia C diretta come le API Win32, P/Invoke basta.
Ma ciò che compare nel lavoro reale sono DLL più particolari.
Ci sono classi C++, convenzioni di ownership, eccezioni in giro, e std::wstring e std::vector che appaiono come cosa normale.
Se provi a forzare con solo P/Invoke qui, lo strato di confine di solito diventa sempre più doloroso.
In questo articolo guardiamo cosa diventa più facile quando inserisci un sottile wrapper C++/CLI in questi casi. Non è un argomento che P/Invoke sia cattivo — il punto è che i casi in cui P/Invoke è sufficiente e quelli in cui C++/CLI ripaga sono diversi.
Gli estratti di codice in questo articolo sono pubblicati su GitHub come un set di esempi completo, compilabile (una libreria C++ nativa, un bridge API C, un wrapper C++/CLI e codice C# consumer sia per la versione P/Invoke che C++/CLI).
cpp-cli-wrapper-for-native-dlls - komurasoft-blog-samples (GitHub)
1. La conclusione prima di tutto (in una riga)
- Se l’altra parte è un insieme di funzioni C, P/Invoke è la scelta naturale
- Se l’altra parte è una libreria C++, inserire un wrapper C++/CLI la rende più facile da mantenere
- Specialmente quando sono coinvolti classi, ownership, stringhe, array, eccezioni e callback, è meglio non far sforzare il lato C#
In sintesi: non portare i problemi della DLL nativa direttamente in C#. Assorbi i problemi nativi sul lato C++, e presenta a .NET solo una superficie rifinita. Quando questa divisione del lavoro funziona, sia il codice che il debug diventano considerevolmente più tranquilli.
2. Casi in cui P/Invoke è sufficiente
Se P/Invoke fa il lavoro, è l’opzione più semplice. Non c’è bisogno di forzare C++/CLI.
P/Invoke si adatta bene a casi come questi.
- L’API è un insieme piatto di funzioni esposte tramite
extern "C" - Argomenti e valori di ritorno sono interi, puntatori, struct semplici e così via
- Le convenzioni sulle stringhe sono chiare e le responsabilità sui buffer sono semplici
- La gestione delle risorse è facile da seguire, come
Create/Destroy - Puoi scrivere
SafeHandleeStructLayoutnaturalmente sul lato C#
Se le cose sono così ordinate, dichiari e chiami da C#, e poiché sembra vicino a chiamare le API Windows, l’implementazione resta leggibile.
3. Il confine dove P/Invoke diventa improvvisamente doloroso
Il problema è quando l’altra parte non è “solo una API C”. Qui l’umore cambia all’improvviso.
3.1. Quando inizi a trattare con classi C++
Se la DLL nativa è progettata intorno a classi C++, vorresti davvero chiamare i metodi di classe direttamente — ma ciò che P/Invoke può puntare sono le funzioni esportate della DLL. Questo significa che da qualche parte devi uno strato che appiattisce le cose in funzioni in stile C.
A quel punto, ciò che stai facendo è essenzialmente “scrivere un wrapper”.
E se è così, piuttosto che far germogliare mucchi di IntPtr e funzioni libere sul lato C#, spostare il wrapper sul lato C++ è più naturale.
3.2. Quando ownership e gestione del lifetime sono difficili da vedere
In C++, domande come queste sono del tutto routine:
- Chi libera?
- Il puntatore restituito è in prestito?
- È un
const&o un transfer di ownership? - Qualcosa è cached internamente con assunzioni sul lifetime?
Se esprimi questo con IntPtr sul lato C#, all’inizio può funzionare, ma è piuttosto doloroso rileggerlo in seguito.
Una volta che inizia il problema “aspetta, chi libera questo puntatore e quando?”, lo strato di confine diventa rapidamente torbido.
3.3. Quando appaiono std::wstring, std::vector, callback ed eccezioni
Da qui in poi, P/Invoke entra nel territorio del “scrivibile, ma non piacevole”.
- Vuoi rappresentare
std::wstringdirettamente da C# - Vuoi restituire un
std::vector<T> - Vuoi ricevere progresso nativo tramite callback
- Vengono lanciate eccezioni C++ in caso di fallimento
Mentre questi elementi si accumulano, il lato C# accumula MarshalAs, buffer manuali, array a lunghezza fissa, gestione del lifetime di delegate, interpretazione dei codici di errore e altro.
Certo, puoi scrivere tutto se ci provi abbastanza. La parte dolorosa è che lo sforzo non è dove sta la sostanza. Ciò che vuoi effettivamente costruire è logica di business o UI, non un’arte marziale del marshaling di confine.
3.4. Quando non vuoi che i problemi C++ fuoriescano in C#
L’API della DLL nativa non è necessariamente già sagomata per C# così com’è.
Ad esempio, anche se il lato nativo è progettato in modo tale che:
- Più chiamate a metodo sono combinate in un’unica operazione logica
- Gli errori sono restituiti tramite valori di ritorno e parametri out
- Ci sono assunzioni sull’ordine di inizializzazione
- Ci sono vincoli di thread safety
di solito vuoi mostrare al lato C# un’API più lineare. Come strato che esegue questa conversione, C++/CLI è notevolmente comodo.
4. L’architettura con un wrapper C++/CLI
L’architettura è semplice.
flowchart LR
Cs[app C#] -->|API progettata per .NET| Wrapper[DLL wrapper C++/CLI]
Wrapper -->|Lavora direttamente con header e tipi nativi| Native[DLL C++ nativo]
Assicurati che C# veda solo un’API in stile .NET, e confina quanto segue nel lato C++/CLI:
- Conversione di stringhe
- Conversione di array e vector
- Conversione di eccezioni
- Cleanup dell’ownership
- Interpretazione dei codici di errore
- Se necessario, assorbimento di confini di thread e callback
La cosa importante è non lasciare che il progetto C++/CLI stesso cresca troppo. Il suo ruolo è strettamente “traduzione” e “modellatura”. Se la logica di business inizia a insinuarsi, quel livello diventa il protagonista.
5. Cosa rende più facile C++/CLI
5.1. Puoi gestire i tipi C++ come tipi C++
Questo è importante. Sul lato C++/CLI puoi includere gli header nativi e usare i tipi C++ direttamente.
In altre parole, il lato C# non deve più ricreare forzatamente “il mondo C++”.
std::wstring e std::vector possono essere ricevuti prima come tipi C++, poi passati al lato .NET nella forma necessaria.
5.2. Puoi sagomare l’API per .NET
Al lato C# puoi esporre l’API in forme familiari:
stringbyte[]List<T>IDisposable- Eccezioni
Questa differenza sembra modesta ma cambia molto il carico sui consumatori. Specialmente nello sviluppo di team, paga il fatto che membri non familiari con i dettagli nativi possano comunque lavorarci comodamente.
5.3. Le responsabilità sulle eccezioni e gli errori sono più facili da organizzare
Quando il lato nativo mescola eccezioni e codici di errore, riceverli grezzi sul lato C# è scomodo. Sul lato C++/CLI puoi consolidare una volta:
- Convertire le eccezioni in eccezioni .NET
- Convertire i codici di errore in eccezioni o tipi di risultato significativi
- Aggiungere il contesto necessario per il logging
Se traduci i fallimenti in “fallimenti significativi” una volta al confine, il lato chiamante diventa molto più pulito.
5.4. Puoi nascondere l’instabilità dell’ABI a C#
Le classi e i metodi C++ non hanno un ABI semplice come le funzioni C. Una volta che C# inizia a conoscere quei dettagli direttamente, emergono preoccupazioni su funzioni esportate e marshaling nel tuo codice.
Con un wrapper C++/CLI in mezzo, i problemi C++ restano confinati sul lato C++, e C# vede solo una superficie stabile. Questa separazione ripaga anche quando la libreria viene aggiornata.
5.5. La migrazione incrementale è più facile
Riscrivere un’intera DLL nativa esistente in una volta è pesante. Con un wrapper C++/CLI, puoi wrappare solo le API che ti servono, sottilmente, e iniziare a usarle da nuove schermate e flussi C# — una migrazione incrementale.
Per scenari in cui vuoi mantenere vivi gli asset Windows esistenti mentre sposti la periferia verso .NET, l’abbinamento è eccellente.
6. Estratti di codice
Piuttosto che un “esempio completo che gira così com’è”, ecco abbastanza estratti da trasmettere com’è fatto il confine.
6.1. Com’è l’API della DLL nativa
// 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);
};
}
Per essere C++ nativo, questa API è ordinaria. Ma toccarla direttamente da C# richiede uno sforzo reale.
6.2. Cosa diventa se provi con P/Invoke
Per prima cosa, per chiamarla direttamente da C#, deve essere appiattita in funzioni in stile C da qualche parte. Finisci per preparare funzioni bridge come queste.
// Abbozzo di bridge appiattito in API C
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);
}
Il lato C# finisce per apparire più o meno così.
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);
}
Se finisse qui, andrebbe bene — ma in pratica continuano a venire altre domande:
- Come restituisci dati a lunghezza variabile?
- Chi libera i buffer stringa?
- Dove metti i dettagli dell’errore?
- Come proteggi i lifetime dei callback?
In altre parole, pensavi di aver scelto P/Invoke, ma in effetti hai iniziato a progettare un’API compatibile C.
6.3. Come si legge con un wrapper C++/CLI
Sul lato C++/CLI, assorbi i problemi nativi e sagomi l’API mostrata a C#.
// 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;
};
// 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)
{
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()));
}
}
Il lato C# diventa notevolmente semplice.
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);
}
Ciò che C# vede è string, List<int> e IDisposable.
Le preoccupazioni di IntPtr, funzioni libere e buffer stringa nativi sono invisibili.
Questo è ciò che conta.
7. Casi in cui non dovresti ancora scegliere C++/CLI
Certo, C++/CLI non è una panacea. Ci sono situazioni in cui non va scelto.
- L’altra parte espone già una API C pulita
- In quel caso, P/Invoke è la scelta più naturale.
- Hai bisogno di supporto cross-platform
- C++/CLI assume Windows.
- La superficie di confine è piccola e i tipi sono semplici
- Il costo di aggiungere un’altra DLL wrapper può superare il beneficio.
- Stai guardando molto strettamente i vincoli AOT o di distribuzione
- Meglio rivedere prima i requisiti dell’intera configurazione.
Quindi il criterio è: “data la complessità della DLL nativa, dov’è il posto più naturale per fare la traduzione?” Semplice → P/Invoke; complesso → C++/CLI. Questa divisione funziona bene la maggior parte delle volte.
8. Conclusione
Come modo per usare DLL native da C#, P/Invoke resta la via standard. Ma questo è vero quando l’altra parte è ben comportata come API C.
Se il lato nativo è progettato come libreria C++, allora piuttosto che allineare IntPtr e attributi di marshaling sul lato C# e resistere, costruire un sottile wrapper C++/CLI spesso mantiene il confine molto più pulito.
In particolare, quando sono coinvolti:
- API basate su classi
- Assunzioni di ownership
std::wstringestd::vector- Conversione di eccezioni
- Callback
- Migrazione incrementale
C++/CLI è un’opzione molto realistica.
Nessuno di questi è un lavoro appariscente. Ma decisioni come “dove ripulire il confine” ripagano quadratamente nella manutenibilità futura. Quando vuoi far convivere asset Windows esistenti e .NET, C++/CLI è ancora genuinamente utile.
9. Riferimenti
- Codice di esempio completo per questo articolo (libreria C++ nativa, wrapper C++/CLI, consumer C#) - 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
- 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
Articoli correlati
Articoli recenti con gli stessi tag per approfondire argomenti vicini.
Icone nella system tray e notifiche toast nelle app Windows — le insidie di NotifyIcon e come scegliere l'AppNotification giusta
Una guida pratica per mantenere un'applicazione Windows aziendale residente nella system tray (area di notifica) e avvisare l'utente tram...
Fino a quando funzioneranno le applicazioni VB6? — Lo stato del supporto al runtime e un percorso pratico verso la migrazione a .NET
Fino a quando continueranno a funzionare le applicazioni VB6? Questo articolo chiarisce l'asimmetria tra la politica di supporto del runt...
Era giapponese, festività e date di chiusura nelle applicazioni aziendali — progettazione resiliente ai cambi di era, JapaneseCalendar e calcolo dei giorni lavorativi nella pratica
Mostrare «Reiwa 8» su un documento, calcolare i giorni lavorativi escludendo le festività, pagare entro la fine del mese successivo dopo ...
Pitfall delle app di comunicazione seriale - Riconnessione e progettazione dei log
I pitfall delle app di comunicazione seriale da evitare nell'integrazione dispositivi e nel controllo strumenti, organizzati da una prosp...
Elenco di controllo pre-migrazione per il passaggio da .NET Framework a .NET
Un pratico elenco di controllo di cosa verificare prima della migrazione da .NET Framework a .NET: tipi di progetto, tecnologie non suppo...
Argomenti correlati
Queste pagine collocano l’argomento in un contesto più ampio di servizi e decisioni.
Argomenti tecnici Windows
Portale su sviluppo Windows, analisi dei problemi e valorizzazione delle risorse esistenti.
Interoperabilità a 32 / 64 bit
Compatibilità 32/64 bit, confini nativi e decisioni di progettazione Windows.
Servizi collegati all’argomento
L’articolo è direttamente collegato ai servizi seguenti.
Sviluppo di applicazioni Windows
Applicazioni aziendali, integrazione di dispositivi e strumenti di comunicazione, dai requisiti allo sviluppo.
Domande frequenti
Domande che ricorrono nelle consulenze sull’argomento dell’articolo.
- Quando P/Invoke è sufficiente per chiamare una DLL nativa da C#?
- P/Invoke è la scelta naturale quando l'altra parte è un insieme piatto di funzioni C esposte tramite extern "C", con argomenti e valori di ritorno che sono interi, puntatori o struct semplici. Aiuta anche se le convenzioni sulle stringhe sono chiare, le responsabilità sui buffer sono semplici e la gestione delle risorse segue un pattern facile da seguire come Create/Destroy. In quei casi dichiari e chiami da C#, in modo simile a chiamare le API Windows, e il codice resta leggibile.
- Quando dovrei usare un wrapper C++/CLI invece di P/Invoke?
- Un wrapper C++/CLI ripaga quando la DLL nativa è progettata come libreria C++ piuttosto che come API C — quando sono coinvolte classi C++, convenzioni di ownership, std::wstring, std::vector, eccezioni e callback. P/Invoke può puntare solo a funzioni esportate, quindi un'API a classi C++ deve comunque essere appiattita da qualche parte, e fare quella traduzione sul lato C++ è più naturale che ammucchiare IntPtr e attributi di marshaling in C#. La regola empirica: confine semplice, P/Invoke; confine C++ complesso, C++/CLI.
- Cosa rende effettivamente più facile un wrapper C++/CLI?
- Sul lato C++/CLI puoi includere gli header nativi e gestire tipi C++ direttamente, poi esporre un'API in stile .NET usando string, byte[], List<T>, IDisposable e eccezioni .NET. Conversione di stringhe e vector, conversione di eccezioni, cleanup dell'ownership e interpretazione dei codici di errore restano confinati nel wrapper, quindi C# non vede mai IntPtr o buffer nativi. Inoltre nasconde l'instabilità dell'ABI C++ dietro una superficie stabile e rende più facile la migrazione incrementale di asset nativi esistenti.
- Quando dovrei evitare C++/CLI?
- Evitalo quando il lato nativo espone già una API C pulita, perché P/Invoke è allora la scelta più naturale. C++/CLI assume Windows, quindi non è adatto se serve supporto cross-platform. Non vale la pena quando la superficie di confine è piccola e i tipi sono semplici, perché aggiungere un'altra DLL wrapper può costare più di quanto risparmi, e merita attenzione se hai vincoli rigidi su AOT o distribuzione.
Profilo dell’autore
Pagina di presentazione dell’autore dell’articolo.
Go Komura
Rappresentante di KomuraSoft LLC
Specializzato nello sviluppo di software Windows, nella consulenza tecnica e nell’analisi dei malfunzionamenti, soprattutto nei progetti con sistemi esistenti e guasti difficili da riprodurre.