Chiamare DLL native da C#: wrapper C++/CLI vs P/Invoke
· Aggiornato il: · Go Komura · C++/CLI, C#, Sviluppo Windows, Native Interop
Cronologia delle revisioni (prima versione, pubblicata il 7 Mar 2026)
- Prima pubblicazione
Citare questo articolo(DOI: 10.5281/zenodo.22173289)
Questo articolo è archiviato su Zenodo. Qui sotto trovi sia il DOI che rimanda sempre all'ultima versione sia quello fissato alla versione che stai leggendo.
Go Komura (2026). Chiamare DLL native da C#: wrapper C++/CLI vs P/Invoke. KomuraSoft LLC. https://doi.org/10.5281/zenodo.22173289 https://comcomponent.com/it/blog/2026/03/07/000-cpp-cli-wrapper-for-native-dlls/
- DOI (ultima versione)
- 10.5281/zenodo.22173289
- DOI (questa versione)
- 10.5281/zenodo.22173290
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.
Named pipe in pratica — L'IPC standard di Windows, dalla progettazione alla sicurezza
Guida pratica alle named pipe, il meccanismo standard di comunicazione tra processi su Windows. L'articolo organizza, a partire dalle fon...
Usare WMI/CIM da C# e PowerShell — Guida pratica a lettura hardware, monitoraggio processi e interrogazioni remote
Il modo classico per leggere il numero di serie del PC, monitorare lo spazio disco e rilevare l'avvio dei processi è WMI/CIM. L'articolo ...
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 ...
Argomenti correlati
Queste sono pagine di argomenti vicini a questo tema. A partire dall'articolo, potete passare ai servizi correlati e ad altri articoli.
Argomenti tecnici Windows
Portale che riunisce gli argomenti tecnici su sviluppo Windows, indagine sui difetti e valorizzazione delle risorse esistenti.
Tema: 32 bit / 64 bit
Argomento che mette in ordine 32 bit / 64 bit, integrazione nativa e C++/CLI.
Servizi collegati all’argomento
Questo articolo si collega alle pagine dei servizi seguenti. Partite da un ingresso vicino alla vostra situazione.
Sviluppo di applicazioni Windows
Supportiamo lo sviluppo di software Windows come applicazioni aziendali, integrazione con dispositivi e strumenti di comunicazione.
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
Si occupa soprattutto di sviluppo di software Windows, consulenza tecnica e indagine sui malfunzionamenti, con punti di forza nei progetti in cui restano risorse esistenti e nelle indagini su guasti dalla causa difficile da individuare.