Вызов нативных DLL из C#: обёртка на C++/CLI или P/Invoke
· Го Комура · C++/CLI, C#, Разработка Windows, Нативное взаимодействие
Требование «использовать из C# существующие Windows-активы или существующие DLL» встречается довольно часто. Если на другой стороне — простой C-интерфейс вроде Win32 API, P/Invoke вполне достаточно.
Но на практике встречаются DLL с гораздо более сложным характером.
В них есть классы C++, свои соглашения о владении объектами, летают исключения, а std::wstring и std::vector появляются как нечто само собой разумеющееся.
Если пытаться продавить это только P/Invoke, граница взаимодействия, как правило, постепенно становится всё более мучительной.
В этой статье рассказывается о том, что становится проще, если в таких случаях вставить тонкую обёртку на C++/CLI. Речь не о том, что P/Invoke — это плохо: смысл в том, что случаи, когда P/Invoke достаточно, и случаи, когда эффективнее C++/CLI, — разные.
Отметим, что фрагменты кода из этой статьи опубликованы на GitHub как полный собираемый набор примеров (нативная библиотека на C++, C API-мост, обёртка на C++/CLI, а также код потребления на C# для версий P/Invoke и C++/CLI).
cpp-cli-wrapper-for-native-dlls - komurasoft-blog-samples (GitHub)
Содержание
- Сначала вывод (в двух словах)
- Случаи, когда P/Invoke достаточно
- Граница, за которой P/Invoke внезапно становится тяжёлым
- Архитектура с обёрткой на C++/CLI
- Что становится проще благодаря C++/CLI
- Фрагменты кода
- Случаи, когда C++/CLI всё же лучше не выбирать
- Итог
- Источники
1. Сначала вывод (в двух словах)
- Если на другой стороне — набор C-функций, естественнее выбрать P/Invoke
- Если на другой стороне — библиотека на C++, поддерживать код проще, если вставить одну обёртку на C++/CLI
- Особенно если задействованы классы, владение объектами, строки, массивы, исключения и обратные вызовы, не стоит заставлять сторону C# справляться с этим самостоятельно
Иными словами, речь о том, чтобы не переносить особенности нативной DLL напрямую в C#. Особенности нативной стороны принимает на себя C++, а .NET показывается только приведённая в порядок поверхность. Когда такое разделение труда работает, и код, и отладка становятся заметно спокойнее.
2. Случаи, когда P/Invoke достаточно
Если задачу решает P/Invoke, это самый простой вариант. Не нужно насильно тащить сюда C++/CLI.
P/Invoke подходит, например, в таких случаях.
- API представляет собой плоский набор функций, опубликованных через
extern "C" - аргументы и возвращаемые значения обходятся целыми числами, указателями, простыми структурами и тому подобным
- соглашения о строках понятны, а ответственность за буферы проста
- управление ресурсами понятно устроено по схеме
Create/Destroy - на стороне C# можно естественно написать
SafeHandleиStructLayout
Если всё настолько аккуратно, достаточно объявить и использовать это на стороне C# — ощущение близко к вызову Windows API, поэтому реализация тоже остаётся читаемой.
3. Граница, за которой P/Invoke внезапно становится тяжёлым
Проблема возникает, когда на другой стороне не «просто C API». Именно здесь атмосфера резко меняется.
3.1. Когда приходится иметь дело с классами C++
Если нативная DLL спроектирована вокруг классов C++, на самом деле хочется вызывать методы классов напрямую, но через P/Invoke напрямую можно обращаться только к экспортируемым функциям DLL. То есть рано или поздно всё равно понадобится слой, сводящий всё к функциям в стиле C.
На этом этапе то, что вы делаете, — это, по сути, «написание обёртки».
А раз так, естественнее перенести обёртку на сторону C++, чем плодить на стороне C# горы IntPtr и функций освобождения.
3.2. Когда владение объектами и управление временем жизни плохо видны
В C++ совершенно нормальны вопросы вроде:
- освобождает ли объект вызывающая сторона;
- является ли возвращённый указатель заимствованным;
- это
const&или передача владения; - есть ли внутреннее кеширование с предположениями о времени жизни.
Если выражать всё это через IntPtr на стороне C#, поначалу код может работать, но перечитывать его позже довольно тяжело.
Как только начинается «а кто и когда должен удалить этот указатель», граница взаимодействия быстро мутнеет.
3.3. Когда появляются std::wstring, std::vector, обратные вызовы и исключения
С этого момента P/Invoke входит в область «написать можно, но приятного мало».
- хочется представить
std::wstringнапрямую средствами C# - хочется вернуть
std::vector<T> - хочется получать прогресс нативной обработки через обратный вызов
- при сбое выбрасывается исключение C++
По мере накопления таких элементов на стороне C# растёт количество MarshalAs, ручных буферов, массивов фиксированной длины, управления временем жизни делегатов и интерпретации кодов ошибок.
Конечно, если постараться, всё это можно написать. Но тяжело то, что место приложения усилий не является сутью дела. На самом деле хочется заниматься бизнес-логикой или интерфейсом, а не борьбой на границе взаимодействия.
3.4. Когда не хочется, чтобы особенности C++ просачивались в C#
API нативной DLL не обязательно изначально ориентирован на C#.
Например, даже если на нативной стороне заложено:
- объединение нескольких вызовов методов в одну логическую операцию
- возврат ошибок через возвращаемое значение и out-параметры
- предположения о порядке инициализации
- ограничения на потокобезопасность
на стороне C# зачастую хочется показать более простой API. В качестве слоя, выполняющего это преобразование, C++/CLI оказывается весьма удобным.
4. Архитектура с обёрткой на C++/CLI
Архитектура получается простой.
flowchart LR
Cs[C# приложение] -->|API для .NET| Wrapper[Обёртка C++/CLI DLL]
Wrapper -->|Работает напрямую с нативными заголовками и типами| Native[Нативная C++ DLL]
Со стороны C# должен быть виден только API в духе .NET, а на стороне C++/CLI нужно замкнуть:
- преобразование строк
- преобразование массивов и векторов
- преобразование исключений
- упорядочивание владения объектами
- интерпретацию кодов ошибок
- при необходимости — поглощение границ потоков и обратных вызовов
Важно не давать проекту C++/CLI разрастаться сверх меры. Его роль — исключительно «перевод» и «приведение к нужной форме». Если в него начинает проникать бизнес-логика, этот слой сам становится главным действующим лицом.
5. Что становится проще благодаря C++/CLI
5.1. Типы C++ можно обрабатывать как типы C++
Это довольно важный момент. На стороне C++/CLI можно подключить нативные заголовочные файлы и работать напрямую с типами C++.
То есть на стороне C# больше не нужно насильно «воссоздавать мир C++».
И std::wstring, и std::vector можно сначала принять как типы C++, а затем передать в .NET в нужной форме.
5.2. API можно привести к виду, привычному для .NET
Стороне C# можно показать API в привычной форме:
stringbyte[]List<T>IDisposable- исключения
Эта разница выглядит скромно, но сильно меняет нагрузку на использующую сторону. Особенно в командной разработке это ценно тем, что даже участники, не знакомые с тонкостями нативного кода, могут спокойно с этим работать.
5.3. Ответственность за исключения и ошибки легче упорядочить
Если на нативной стороне вперемешку встречаются исключения и коды ошибок, принимать их как есть на стороне C# неудобно. На стороне C++/CLI можно один раз всё привести в порядок:
- преобразовать исключения в исключения .NET
- преобразовать коды ошибок в осмысленные исключения или типы результата
- дополнить контекстом, необходимым для журналирования
Если один раз на границе перевести сбой в «осмысленный сбой», вызывающая сторона становится намного чище.
5.4. Нестабильность ABI можно скрыть от стороны C#
Классы и методы C++ не имеют такого простого ABI, как C-функции. Как только C# напрямую начинает знать эти особенности, наружу вылезают заботы об экспортируемых функциях и маршалинге.
Если вставить обёртку на C++/CLI, можно замкнуть особенности C++ на стороне C++ и показывать C# только стабильную поверхность. Такое разделение оказывается полезным и при обновлении библиотеки.
5.5. Легче выполнять постепенную миграцию
Переписывать всю существующую нативную DLL целиком и сразу — тяжело. С обёрткой на C++/CLI проще выполнить постепенную миграцию: сначала тонко обернуть только нужные API и начать использовать их из новых экранов или рабочих процессов на стороне C#.
Это хорошо подходит для сценариев, когда нужно сохранить существующие активы Windows, постепенно перенося окружение на .NET.
6. Фрагменты кода
Здесь приводятся не «полностью рабочие готовые примеры», а лишь фрагменты, достаточные для того, чтобы представить себе границу взаимодействия.
6.1. Как выглядит API на стороне нативной DLL
// 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);
};
}
Как нативный C++, этот API выглядит вполне обычно. Но работать с ним напрямую из C# довольно трудоёмко.
6.2. Что получится, если попытаться сделать это через P/Invoke
Прежде всего, чтобы вызывать это напрямую из C#, где-то нужно свести всё к функциям в стиле C. Например, придётся отдельно подготовить вот такие функции-мосты.
// Набросок моста, приведённого к C API
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);
}
На стороне C# всё это будет выглядеть примерно так.
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);
}
Если бы на этом всё заканчивалось, было бы хорошо, но на практике добавляются ещё такие вопросы:
- как возвращать данные переменной длины;
- кто освобождает строковые буферы;
- куда помещать подробности об ошибке;
- как защитить время жизни обратного вызова.
То есть часто оказывается, что, вроде бы выбрав P/Invoke, вы фактически начали проектировать C-совместимый API.
6.3. Как это можно написать с обёрткой на C++/CLI
На стороне C++/CLI особенности нативного кода принимаются на себя, а API, показываемый 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()));
}
}
Сторона C# становится весьма простой.
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);
}
Со стороны C# видны только string, List<int> и IDisposable.
Особенности IntPtr, функций освобождения и нативных строковых буферов не видны.
Именно в этом главное преимущество.
7. Случаи, когда C++/CLI всё же лучше не выбирать
Разумеется, C++/CLI не панацея. Есть ситуации, когда его лучше не выбирать.
- Другая сторона изначально предоставляет чистый C API
- В этом случае естественнее выбрать P/Invoke.
- Нужна кроссплатформенность
- C++/CLI рассчитан только на Windows.
- Граница взаимодействия небольшая, а типы простые
- Стоимость добавления ещё одной обёрточной DLL иногда оказывается выше выгоды.
- Строго учитываются ограничения AOT или распространения приложения
- Сначала стоит проверить требования архитектуры в целом.
То есть критерий выбора — «где естественнее всего выполнить перевод с учётом сложности нативной DLL». Для простого случая — P/Invoke, для сложного — C++/CLI. Такое разделение в большинстве случаев работает хорошо.
8. Итог
Как способ использования нативных DLL из C#, P/Invoke по-прежнему остаётся столбовой дорогой. Но это верно, когда другая сторона ведёт себя прилично как C API.
Если нативная сторона спроектирована как библиотека C++, то вместо того чтобы упорно расставлять на стороне C# IntPtr и атрибуты маршалинга, часто лучше сохраняет чистоту границы взаимодействия создание тонкой обёртки на C++/CLI.
Особенно если задействованы:
- API на основе классов
- предположения о владении объектами
std::wstringиstd::vector- преобразование исключений
- обратные вызовы
- поэтапная миграция
C++/CLI оказывается вполне реалистичным выбором.
Сама по себе эта работа не выглядит эффектной. Но то, где именно наводить порядок на границе, впоследствии напрямую сказывается на удобстве сопровождения. Когда нужно совместно использовать существующие активы Windows и .NET, C++/CLI по-прежнему остаётся удобным инструментом.
9. Источники
- Полный набор примеров кода для этой статьи (нативная библиотека на C++, обёртка на C++/CLI, потребляющий код на 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
- Interop (C++) のパフォーマンスに関する考慮事項 - Microsoft Learn
Похожие статьи
Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.
Безопасный вызов Win32 API из C# — практическое руководство по P/Invoke (DllImport / LibraryImport / CsWin32)
Разбираем практические аспекты вызова Win32 API из C# через P/Invoke: разницу между DllImport и LibraryImport, автогенерацию сигнатур чер...
Версионирование схемы БД бизнес-приложения — практика миграций, предотвращающая «у каждого клиента своя база»
Практическое руководство по версионированию схемы БД бизнес-приложения, установленного у множества клиентов. Разбираем PRAGMA user_versio...
CI/CD для приложений WinForms / WPF на практике — автоматизация от сборки до подписи и распространения через GitHub Actions
Практическое руководство по настройке CI/CD для приложений WinForms / WPF через GitHub Actions. Минимальный YAML для сборки и тестов на w...
Если ваше Windows-приложение приняли за вирус — как реагировать на ложные срабатывания Microsoft Defender и жить с влиянием на производительность
Разбираем правильный порядок действий, если Microsoft Defender ложно определяет ваше Windows-приложение как вредоносное: как устроена сов...
Спящий режим, гибернация, Modern Standby и долго работающие приложения — как проектированием предотвратить «остановилось ночью»
Разбираем, почему долго работающее Windows-приложение оказывается «остановленным к утру», начиная с различий между спящим режимом S3, гиб...
Связанные темы
Эти страницы показывают тему статьи в более широком контексте услуг и решений.
Технические темы Windows
Раздел о разработке Windows, расследовании сбоев и использовании существующих активов.
Совместимость 32 и 64 бит
Совместимость 32/64 бит, нативные границы и решения по проектированию Windows.
Услуги по этой теме
Статья напрямую связана со следующими услугами.
Разработка приложений для Windows
Бизнес-приложения, интеграция оборудования и средства связи — от требований до разработки.
Частые вопросы
Вопросы, которые часто возникают при консультациях по теме статьи.
- Как выбирать между P/Invoke и обёрткой на C++/CLI?
- Если на другой стороне — плоский набор C-функций, опубликованных через extern "C", P/Invoke — самый простой и естественный выбор. Если же нативная DLL спроектирована вокруг классов C++ и в дело вступают владение объектами, строки, массивы, исключения и обратные вызовы, поддерживать код проще, если вставить тонкую обёртку на C++/CLI. Критерий выбора — «где естественнее всего выполнить перевод с учётом сложности нативной DLL»: для простого случая — P/Invoke, для сложного — C++/CLI. Такое разделение в большинстве случаев работает хорошо.
- Что становится проще, если вставить обёртку на C++/CLI?
- На стороне C++/CLI можно подключить нативные заголовочные файлы и работать с std::wstring и std::vector как с обычными типами C++, поэтому на стороне C# больше не нужно воссоздавать мир C++. Для C# можно показать только привычные для .NET API — string, byte[], List<T>, IDisposable, исключения — и скрыть заботы об IntPtr, функциях освобождения и маршалинге. Исключения и коды ошибок C++ можно на границе преобразовать в исключения .NET, что также облегчает поэтапную миграцию с использованием существующих активов.
- Когда продвигаться только на P/Invoke становится тяжело?
- Если нативная DLL спроектирована вокруг классов C++, то напрямую через P/Invoke можно вызывать только экспортируемые функции DLL, поэтому рано или поздно всё равно потребуется слой, сводящий всё к функциям в стиле C, — а это фактически означает, что вы начинаете проектировать C-совместимый API. Кроме того, по мере того как добавляются такие элементы, как возврат std::wstring или std::vector, получение прогресса через обратные вызовы или выброс исключений C++, на стороне C# накапливаются MarshalAs, ручные буферы и управление временем жизни делегатов. Если выражать предположения о владении и времени жизни объектов через IntPtr, перечитывать такой код позже становится довольно тяжело.
- Бывают ли случаи, когда C++/CLI лучше не выбирать?
- Да, бывают. Если другая сторона изначально предоставляет чистый C API, естественнее выбрать P/Invoke. Кроме того, C++/CLI рассчитан только на Windows, поэтому он не подходит, если требуется кроссплатформенность. Если граница взаимодействия невелика, а типы просты, стоимость добавления ещё одной обёрточной DLL может оказаться выше выгоды, а при строгих ограничениях на AOT или распространение приложения сначала стоит проверить требования всей архитектуры в целом.
Об авторе
Страница с профилем автора статьи.
Го Комура
Представитель KomuraSoft LLC
Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.
Публичные ссылки