Вызов нативной DLL из C#: обёртка на C++/CLI или P/Invoke
· Обновлено: · Го Комура · C++/CLI, C#, Разработка Windows, Нативное взаимодействие
История изменений (1 обновлений, последнее 30 Aug 2026)
Журнал изменений этой статьи. Там, где версия до правки была заархивирована, она остаётся доступной для чтения по постоянной ссылке с DOI.
- Русский текст переписан как полноценный технический перевод, а не калька с японского. Утверждения статьи не менялись.
- Первая публикация
Цитирование статьи(DOI: 10.5281/zenodo.21619635)
Статья заархивирована на Zenodo. Ниже приведены DOI, который всегда ведёт к последней версии, и DOI, закреплённый за версией, которую вы читаете.
Го Комура (2026). Вызов нативной DLL из C#: обёртка на C++/CLI или P/Invoke. KomuraSoft LLC. https://doi.org/10.5281/zenodo.21619635 https://comcomponent.com/ru/blog/2026/03/07/000-cpp-cli-wrapper-for-native-dlls/
- DOI (последняя версия)
- 10.5281/zenodo.21619635
- DOI (эта версия)
- 10.5281/zenodo.21619636
Требование «вызывать из 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)
Кому статья и что предполагается
Статья рассчитана на разработчика, который уже вызывал нативную DLL из C# и умеет написать объявление DllImport, но останавливается, когда на той стороне оказывается библиотека классов C++. Сам C++/CLI можно не знать. Если P/Invoke вы ещё не писали, быстрее сначала прочитать «Безопасный вызов Win32 API из C# — практическое руководство по P/Invoke».
Предполагаемая среда — Windows + Visual Studio 2022, в одном решении лежат обёртка C++/CLI (.vcxproj) и проект C#. Цель может быть и .NET Framework, и .NET 8, но на стороне .NET есть свои ограничения — они собраны в главе 7.
Термины, которые стоит сразу пояснить
| Термин | Смысл |
|---|---|
| P/Invoke (Platform Invoke) | Механизм, которым атрибуты DllImport / LibraryImport в C# объявляют экспортируемые функции нативной DLL и вызывают их напрямую |
| Маршалинг | Взаимное преобразование типов .NET (string, массивы и т. п.) и нативного представления (wchar_t*, сырые указатели и т. п.) на границе |
| ABI (Application Binary Interface) | Договорённости, по которым собранные двоичные файлы стыкуются: соглашение о вызове, передача аргументов, раскладка структур в памяти, декорирование имён. У C-функций договорённости простые и стабильные; у классов C++ декорирование имён и раскладка vtable зависят от компилятора, и на них нельзя опираться напрямую из C# (5.4) |
SafeHandle |
Абстрактный класс .NET, который оборачивает нативный дескриптор. Им пользуются вместо сырого IntPtr, чтобы не терять освобождение дескриптора и не освобождать его, пока им ещё пользуются (6.2) |
StructLayout |
Атрибут, которым раскладку структуры C# подгоняют под нативную сторону. Типичные применения: LayoutKind.Sequential — поля в порядке объявления, CharSet — как обращаться со строками (6.2) |
marshal_as |
Вспомогательное преобразование, которое даёт C++/CLI. marshal_as<std::wstring>(managedString) взаимно преобразует типы .NET и нативные типы. Подключают заголовки вроде msclr/marshal_cppstd.h (6.3) |
| Смешанная сборка | DLL, в которой есть и нативный машинный код, и MSIL. Обёртка C++/CLI получается именно такой (глава 7) |
Содержание
- Сначала вывод (в двух словах)
- Случаи, когда P/Invoke достаточно
- Граница, за которой P/Invoke внезапно становится тяжёлым
- Архитектура с обёрткой на C++/CLI
- Что становится проще благодаря C++/CLI
- Фрагменты кода
- Случаи, когда C++/CLI всё же лучше не выбирать
- Итог
- Справочные материалы
Карта знаний этой статьи
Когда из C# вызывают native DLL, плоский набор функций extern C естественно закрывать через P/Invoke; если же это библиотека на классах C++ и в дело входят владение, строки, исключения и обратные вызовы, тонкая обёртка на C++/CLI проще в сопровождении. P/Invoke обычно выражает native-типы через SafeHandle и StructLayout, но в итоге приходится писать свой мост в стиле C. C++/CLI оставляет на стороне C++ преобразование типов через marshal_as, шаблон Dispose/Finalize через деструктор и финализатор и перевод исключений в .NET, а C# показывает только стабильный API. С другой стороны C++/CLI работает только на Windows и не совместим с Native AOT, требует компиляции с /clr и зависимости от ijwhost.dll, поэтому при кроссплатформенности или жёстких ограничениях распространения его выбрать нельзя.
flowchart LR
accTitle: Карта знаний: когда C++/CLI-обёртка, когда P/Invoke
accDescr: Схема, как выбирать P/Invoke и C++/CLI-обёртку в зависимости от сложности native DLL, и как при этом меняются маршалинг, владение, исключения, обратные вызовы и ограничения распространения
cpp_cli["C++/CLI"]
p_invoke["P/Invoke"]
c_api_bridge["мост C API"]
c_native_api["плоский C API (extern #quot;C#quot;)"]
cpp_class_native_library["нативная библиотека C++ на классах"]
com_marshaling["маршалинг"]
marshal_as["marshal_as"]
safehandle["SafeHandle"]
structlayout["StructLayout"]
dispose_finalize_pattern["паттерн Dispose/Finalize (C++/CLI)"]
ownership_lifetime_management["управление владением и временем жизни"]
exception_translation["преобразование исключений в .NET"]
callback_delegate_lifetime["управление lifetime callback-делегата"]
native_aot["Native AOT"]
ijwhost["ijwhost.dll"]
clr_compilation_flag["параметр компиляции /clr"]
mixed_assembly["смешанная сборка (mixed assembly)"]
cross_platform_requirement["требование кроссплатформенности"]
p_invoke -.->|"требует"| c_api_bridge
p_invoke -->|"рекомендуется для"| c_native_api
cpp_cli -->|"рекомендуется для"| cpp_class_native_library
p_invoke -->|"не рекомендуется"| cpp_class_native_library
cpp_cli -->|"не рекомендуется"| c_native_api
cpp_cli -->|"использует"| com_marshaling
marshal_as -->|"реализует"| com_marshaling
p_invoke -.->|"использует"| safehandle
p_invoke -.->|"использует"| structlayout
cpp_cli -->|"реализует"| dispose_finalize_pattern
cpp_cli -->|"требует"| ownership_lifetime_management
cpp_cli -->|"реализует"| exception_translation
cpp_cli -.->|"требует"| callback_delegate_lifetime
p_invoke -.->|"требует"| callback_delegate_lifetime
cpp_cli -->|"несовместимо с"| native_aot
cpp_cli -.->|"требует"| ijwhost
cpp_cli -->|"настраивается"| clr_compilation_flag
cpp_cli -->|"реализует"| mixed_assembly
c_api_bridge -->|"требует"| c_native_api
cpp_cli -.->|"преемник"| c_api_bridge
cpp_cli -->|"несовместимо с"| cross_platform_requirement
На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 21, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle
1. Сначала вывод (в двух словах)
- Если на той стороне — набор C-функций, естественнее P/Invoke
- Если на той стороне — библиотека на C++, сопровождать код проще, вставив одну обёртку на C++/CLI
- Особенно если задействованы классы, владение, строки, массивы, исключения и обратные вызовы, не стоит взваливать это на сторону C#
Иными словами, не тащить особенности нативной DLL напрямую в C#. Особенности нативной стороны принимает C++, а .NET показывают только приведённую в порядок поверхность. Когда такое разделение работает, и код, и отладка становятся заметно спокойнее.
flowchart TB
accTitle: Выбор по форме DLL на той стороне
accDescr: Если на той стороне набор C-функций, естественнее P/Invoke; если библиотека на C++, сопровождать проще, вставив одну обёртку на C++/CLI — вывод статьи показан как ветвление.
q{"Какая DLL на той стороне?"}
q -->|"набор C-функций"| pi["P/Invoke естественнее"]
q -->|"библиотека на C++"| cli["Вставить обёртку C++/CLI"]
cli -.-> note["Нативные особенности принимает сторона C++"]
Рис. 1: Вывод по выбору. Набор C-функций — P/Invoke, библиотека на C++ — обёртка C++/CLI.
2. Случаи, когда P/Invoke достаточно
Если задачу закрывает P/Invoke, это самый простой вариант. Тащить сюда C++/CLI незачем.
P/Invoke подходит, например, в таких случаях.
- API — плоский набор функций, опубликованных через
extern "C" - аргументы и возвращаемые значения обходятся целыми, указателями, простыми структурами и тому подобным
- соглашения о строках понятны, ответственность за буферы проста
- управление ресурсами устроено понятно, по схеме
Create/Destroy - на стороне C# естественно пишутся
SafeHandleиStructLayout
Если всё настолько аккуратно, достаточно объявить и использовать это на стороне C# — ощущение близко к вызову Windows API, поэтому реализация тоже остаётся читаемой.
flowchart TB
accTitle: Условия, при которых достаточно P/Invoke
accDescr: Если есть плоский C API, простые аргументы и возвращаемые значения и ясные соглашения о строках и ресурсах, достаточно объявить и использовать это на стороне C#.
c1["Плоский функциональный API через extern C"] --> ok["Достаточно P/Invoke"]
c2["Аргументы и возвращаемые значения просты"] --> ok
c3["Соглашения о строках и ресурсах ясны"] --> ok
ok --> use["Достаточно объявить и использовать на стороне C#"]
Рис. 2: Если API на той стороне настолько аккуратен, тащить C++/CLI незачем.
3. Граница, за которой P/Invoke внезапно становится тяжёлым
Проблема начинается, когда на той стороне уже не «просто C API». Именно здесь ситуация резко меняется.
3.1. Когда приходится иметь дело с классами C++
Если нативная DLL спроектирована вокруг классов C++, на самом деле хочется вызывать методы классов напрямую, но через P/Invoke напрямую можно обращаться только к экспортируемым функциям DLL. То есть рано или поздно всё равно понадобится слой, сводящий всё к функциям в стиле C.
На этом этапе то, что вы делаете, — почти «писать обёртку».
А раз так, естественнее перенести обёртку на сторону C++, чем плодить на стороне C# горы IntPtr и функций освобождения.
flowchart TB
accTitle: К чему приводит выбор P/Invoke против классов C++
accDescr: Методов классов C++ хочется вызывать напрямую, но P/Invoke видит только экспортируемые функции DLL, поэтому нужен слой сведения к C, и по сути вы уже пишете обёртку.
want["Хочется вызвать методы класса C++"] --> limit["Вызвать можно только экспортируемые функции"]
limit --> bridge["Нужен слой сведения к функциям в стиле C"]
bridge --> fact["По сути это уже обёртка"]
fact -.-> better["Тогда естественнее сдвинуть её на сторону C++"]
Рис. 3: Даже если идти только P/Invoke, против классов C++ обёртку всё равно придётся писать где-то.
3.2. Когда владение и время жизни плохо видны
В C++ совершенно обычны вопросы вроде:
- освобождает ли объект вызывающая сторона;
- является ли возвращённый указатель заимствованным;
- это
const&или передача владения; - есть ли внутреннее кеширование с ограничениями по времени жизни.
Если выражать всё это через IntPtr на стороне C#, поначалу код может работать, но перечитывать его позже довольно тяжело.
Как только начинается «а кто и когда должен удалить этот указатель», граница быстро размывается.
flowchart TB
accTitle: Как размывается граница, когда предпосылки владения выражают через IntPtr
accDescr: Кто освобождает, заимствование это или передача владения, есть ли ограничения по времени жизни — если эти обстоятельства C++ выражать через IntPtr на стороне C#, позже код тяжело читать и граница размывается.
q1["Кто освобождает?"] --> ptr["Выражают через IntPtr на стороне C#"]
q2["Заимствование или передача владения?"] --> ptr
q3["Есть ли ограничения по времени жизни?"] --> ptr
ptr --> bad["Сначала работает, потом не читается"]
bad --> muddy["Граница сразу размывается"]
Рис. 4: Если предпосылки владения и времени жизни таскать через IntPtr, граница размывается из-за вопроса «кто и когда это удаляет».
3.3. Когда появляются std::wstring, std::vector, обратные вызовы и исключения
С этого момента P/Invoke входит в область «написать можно, но приятного мало».
- хочется представить
std::wstringнапрямую средствами C# - хочется вернуть
std::vector<T> - хочется получать прогресс нативной обработки через обратный вызов
- при сбое выбрасывается исключение C++
По мере накопления таких элементов на стороне C# растёт количество MarshalAs, ручных буферов, массивов фиксированной длины, управления временем жизни делегатов и интерпретации кодов ошибок.
Конечно, если постараться, всё это можно написать. Но тяжело то, что место приложения усилий — не суть задачи. На самом деле хочется заниматься прикладной логикой или интерфейсом, а не борьбой на границе.
flowchart TB
accTitle: Рост элементов C++ и накопление нагрузки на стороне C#
accDescr: Чем больше элементов вроде возврата wstring или vector, прогресса через обратный вызов и исключений C++ при сбое, тем больше на стороне C# копятся MarshalAs, ручные буферы и управление временем жизни делегатов.
e1["Хочется вернуть wstring или vector"] --> pile["На стороне C# описание копится"]
e2["Хочется прогресс через обратный вызов"] --> pile
e3["При сбое летит исключение C++"] --> pile
pile --> load["MarshalAs, ручные буферы"]
pile --> load2["Время жизни делегатов, разбор ошибок"]
Рис. 5: Каждый новый элемент в духе C++ добавляет нагрузку на граничный код на стороне C#.
3.4. Когда не хочется, чтобы особенности C++ просачивались в C#
API нативной DLL не обязательно изначально ориентирован на C#.
Например, даже если на нативной стороне заложено:
- объединение нескольких вызовов методов в одну логическую операцию
- возврат ошибок через возвращаемое значение и out-параметры
- ограничения на порядок инициализации
- ограничения потокобезопасности
на стороне C# часто хотят показать более прямой API. Как слой, который это преобразует, C++/CLI оказывается весьма удобным.
flowchart TB
accTitle: Слой, который преобразует особенности native и показывает их C#
accDescr: Особенности проектирования нативного API вроде порядка инициализации и ограничений потокобезопасности принимает преобразовательный слой C++/CLI и показывает стороне C# более прямой API.
nat["Особенности проектирования нативного API"] -.-> ex["Порядок инициализации, ограничения потоков и т. п."]
nat --> conv["Преобразовательный слой C++/CLI"]
conv --> api["C# показывают прямой API"]
Рис. 6: Особенности проектирования нативной стороны не направляют в C# как есть, а вставляют C++/CLI как слой преобразования.
4. Архитектура с обёрткой на C++/CLI
Архитектура получается простой.
flowchart LR
Cs[приложение на C#] -->|API для .NET| Wrapper[обёрточная DLL на C++/CLI]
Wrapper -->|напрямую работает с нативными заголовками и типами| Native[нативная DLL на C++]
Рис. 7: Между приложением на C# и нативной DLL на C++ вставляют одну обёрточную DLL на C++/CLI.
Со стороны C# должен быть виден только API в духе .NET, а на стороне C++/CLI нужно спрятать:
- преобразование строк
- преобразование массивов и векторов
- преобразование исключений
- разбор владения
- интерпретацию кодов ошибок
- при необходимости — поглощение границ потоков и обратных вызовов
Важно не давать проекту C++/CLI разрастаться сверх меры. Его роль — исключительно «перевод» и «приведение к нужной форме». Если в него начинает проникать прикладная логика, этот слой сам становится главным действующим лицом.
flowchart TB
accTitle: Работа, которую оставляют в обёртке C++/CLI
accDescr: Перевод и приведение к нужной форме — преобразование строк и массивов, исключений и кодов ошибок, разбор владения — оставляют на стороне C++/CLI и прикладную логику туда не кладут.
w["Роль обёртки C++/CLI"] --> t1["Преобразование строк и массивов"]
w --> t2["Преобразование исключений и кодов ошибок"]
w --> t3["Разбор владения"]
w -.-> warn["Прикладную логику не класть"]
Рис. 8: Роль обёртки ограничивают «переводом» и «приведением к нужной форме» и не раздувают — это важно.
5. Что становится проще благодаря C++/CLI
5.1. Типы C++ можно обрабатывать как типы C++
Это довольно важный момент. На стороне C++/CLI можно подключить нативные заголовки и работать напрямую с типами C++.
То есть на стороне C# больше не нужно насильно «воссоздавать мир C++».
И std::wstring, и std::vector можно сначала принять как типы C++, а затем передать в .NET в нужной форме.
flowchart TB
accTitle: Поток: принять типы C++ и передать в .NET
accDescr: Нативные заголовки подключают, wstring и vector принимают как типы C++, преобразуют в нужную форму и передают в .NET — на стороне C# мир C++ воссоздавать не нужно.
nt["Нативные wstring и vector"] --> recv["На стороне C++/CLI принимают как типы C++"]
recv --> conv["Преобразуют в нужную форму"]
conv --> net["Передают в .NET"]
net -.-> nofake["Мир C++ на стороне C# не воссоздают"]
Рис. 9: Типы C++ сначала принимают как типы C++, преобразуют и только потом передают в .NET.
5.2. API можно привести к виду, привычному для .NET
Стороне C# можно показать API в привычной форме:
stringbyte[]List<T>IDisposable- исключения
Эта разница выглядит скромно, но сильно меняет нагрузку на использующую сторону. Особенно в командной разработке это ценно тем, что даже участники, не знакомые с тонкостями нативного кода, могут спокойно с этим работать.
5.3. Ответственность за исключения и ошибки легче привести в порядок
Если на нативной стороне вперемешку встречаются исключения и коды ошибок, принимать их как есть на стороне C# неудобно. На стороне C++/CLI можно один раз всё собрать:
- преобразовать исключения в исключения .NET
- преобразовать коды ошибок в осмысленные исключения или типы результата
- дополнить контекстом, необходимым для журналирования
Если один раз на границе перевести сбой в «осмысленный сбой», вызывающая сторона становится намного чище.
flowchart TB
accTitle: Перевод исключений и кодов ошибок на границе
accDescr: Смешанные на нативной стороне исключения и коды ошибок один раз собирают на стороне C++/CLI, исключения переводят в исключения .NET, коды ошибок — в осмысленные исключения или типы результата и дополняют контекстом для журнала.
mixed["Нативные исключения и коды ошибок"] --> tr["На стороне C++/CLI один раз собирают"]
tr --> e1["Исключения переводят в исключения .NET"]
tr --> e2["Коды ошибок переводят в осмысленную форму"]
tr -.-> log["Дополняют контекстом для журнала"]
Рис. 10: Если на границе один раз перевести сбой в «осмысленный сбой», вызывающая сторона на C# становится чище.
5.4. Нестабильность ABI можно скрыть от стороны C#
Классы и методы C++ не имеют такого простого ABI, как C-функции. Как только C# напрямую начинает знать эти особенности, наружу вылезают заботы об экспортируемых функциях и маршалинге.
Если вставить обёртку на C++/CLI, можно оставить особенности C++ на стороне C++ и показывать C# только стабильную поверхность. Такое разделение оказывается полезным и при обновлении библиотеки.
flowchart TB
accTitle: Как обёртка перекрывает нестабильность ABI
accDescr: У классов и методов C++ нет такого простого ABI, как у C-функций, поэтому эти особенности оставляют на стороне C++, C# показывают только стабильную поверхность, и разделение работает и при обновлении библиотеки.
abi["ABI классов C++ непростое"] --> hide["Особенности C++ оставляют на стороне C++"]
hide --> stable["C# показывают только стабильную поверхность"]
stable -.-> update["Разделение работает и при обновлении библиотеки"]
Рис. 11: Особенности маршалинга и экспортируемых функций наружу не выпускают, 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.
flowchart TB
accTitle: Как вариант P/Invoke по сути становится проектированием C-совместимого API
accDescr: Даже если собирались вызывать через P/Invoke напрямую, приходится отдельно готовить C-мост, писать на стороне C# SafeHandle и StructLayout, добавляются вопросы переменной длины, освобождения и обратных вызовов — и по сути начинается проектирование C-совместимого API.
start["Собирались вызывать через P/Invoke напрямую"] --> bridge["Отдельно готовят C-мост"]
bridge --> decl["Пишут SafeHandle и StructLayout"]
decl --> more["Вопросы переменной длины, освобождения, обратных вызовов"]
more --> real["По сути начинается проектирование C-совместимого API"]
Рис. 12: Казалось, «просто выбрали P/Invoke», а по факту уже проектируете C-совместимый API.
Эти вопросы, если сдвинуть их к C++/CLI, заменяются так. Они не исчезают как по волшебству: точнее сказать, что они переходят в форму, которую на стороне C++ пишут естественно.
| Вопрос, который возникает при P/Invoke | Типичный ответ на стороне P/Invoke | Как это выглядит на стороне C++/CLI |
|---|---|---|
| Как возвращать данные переменной длины | Готовят две ступени — «функция, которая спрашивает нужный размер» и «функция, которая заполняет буфер», — и буфер выделяют на стороне C# | std::vector, который вернула нативная сторона, принимают как есть и перекладывают в List<int> или массив (6.3) |
| Кто освобождает строковые буферы | В C API добавляют функцию освобождения и на стороне C# всегда её вызывают | Время жизни std::wstring замыкается на нативной стороне, в C# уходит только новый String^ (6.3) |
| Куда помещать подробности об ошибке | К коду ошибки в возвращаемом значении добавляют функцию извлечения подробностей или структуру в out-параметре | try / catch принимают нативное исключение и перебрасывают его как осмысленное исключение .NET (6.3) |
| Как защитить время жизни обратного вызова | Чтобы сборщик не забрал делегат, ссылку держат в поле и т. п. | Регистрацию и снятие обратного вызова оставляют на стороне C++, C# показывают только события или делегаты |
| Как выразить владение дескриптором | Наследуют SafeHandle и из ReleaseHandle вызывают функцию освобождения |
В деструкторе / финализаторе обёртки нативный объект делают delete (6.3) |
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;
};
Специфика C++/CLI здесь — пара ~AnalyzerWrapper() и !AnalyzerWrapper(). Оба выглядят как деструкторы C++, но по роли соответствуют шаблону Dispose в .NET.
| Запись в C++/CLI | Что генерирует компилятор | Поведение со стороны C# |
|---|---|---|
~AnalyzerWrapper() (деструктор) |
Dispose(), реализующий IDisposable |
Выполняется при выходе из using или при вызове Dispose() |
!AnalyzerWrapper() (финализатор) |
Finalize(), переопределяющий Object::Finalize |
Выполняется, когда сборщик забирает объект. Когда именно — не определено |
Правило такое: освобождение нативного ресурса пишут в финализаторе и вызывают его из деструктора. В реализации ниже ~AnalyzerWrapper() только вызывает this->!AnalyzerWrapper() — как раз это. Тогда даже если сторона C# забудет Dispose(), в конце объект подберёт сборщик. Если деструктор уже вызван, GC::SuppressFinalize подавляет финализацию, поэтому двойного освобождения нет.
Dispose(), Finalize() и Dispose(bool) генерирует компилятор, на стороне C++/CLI их сами не пишут. Наоборот, из кода C++/CLI Dispose() напрямую вызвать нельзя: деструктор вызывают оператором delete. Это соответствие собрано в разделе «Destructors and finalizers» статьи How to: Define and consume classes and structs (C++/CLI) - Microsoft Learn.
flowchart TB
accTitle: Разделение ролей деструктора и финализатора
accDescr: Из using или Dispose на стороне C# вызывается деструктор и через финализатор освобождает нативный ресурс; если Dispose забыли, сборщик вызывает финализатор и в конце подбирает объект; SuppressFinalize предотвращает двойное освобождение.
us["using или Dispose на стороне C#"] --> dtor["Деструктор (аналог Dispose)"]
forget["Dispose забыли"] -.-> gc["Финализатор при сборке сборщиком"]
dtor --> fin["Вызывают финализатор"]
fin --> del["нативный ресурс делают delete"]
gc -.-> del
dtor -.-> sup["SuppressFinalize предотвращает двойное освобождение"]
Рис. 13: Правило: освобождение пишут в финализаторе и вызывают его из деструктора. Даже если Dispose забыли, в конце объект подберёт сборщик.
// 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)
{
// Если вызвали после уничтожения объекта, останавливаемся здесь,
// до входа в нативный код.
// Деструктор (= Dispose) обнуляет _native,
// и без этой проверки вызов уйдёт в нативный код через нулевой указатель:
// процесс упадёт по нарушению доступа, а не по исключению .NET.
// Со стороны C# ожидаемое поведение — ObjectDisposedException
// при обращении после Dispose; проверка нужна во всех методах, которые используют _native
if (_native == nullptr)
{
throw gcnew ObjectDisposedException("AnalyzerWrapper");
}
NativeLib::AnalyzeOptions nativeOptions{};
nativeOptions.threshold = options->Threshold;
nativeOptions.modelPath = marshal_as<std::wstring>(options->ModelPath);
try
{
auto nativeResult = _native->Analyze(
marshal_as<std::wstring>(imagePath),
nativeOptions);
auto managed = gcnew AnalysisResult();
managed->Ok = nativeResult.ok;
managed->Message = gcnew String(nativeResult.message.c_str());
managed->Scores = gcnew List<int>();
for (int score : nativeResult.scores)
{
managed->Scores->Add(score);
}
return managed;
}
catch (const std::exception& ex)
{
throw gcnew InvalidOperationException(gcnew String(ex.what()));
}
}
На стороне 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 или распространения
- Сначала стоит посмотреть требования архитектуры в целом.
Последний пункт — «AOT и ограничения распространения» — звучит слишком абстрактно, поэтому ниже перечислены ограничения, которые реально сказываются. Здесь легко оступиться, если оставаться на ощущениях эпохи .NET Framework.
| Ограничение | Содержание | Влияние на практике |
|---|---|---|
| ОС | C++/CLI с целью .NET (семейство .NET Core) — только Windows | Если планируете Linux-контейнеры или macOS, на этом шаге выбрать нельзя |
| Native AOT | В списке несовместимости Native AOT C++/CLI указан явно. Вместе с ним нельзя динамическую загрузку вроде Assembly.LoadFile, System.Reflection.Emit и встроенный COM Windows |
С курсом на один нативный двоичный файл через PublishAot это не совмещается |
| Формат вывода | Если цель — .NET, exe сделать нельзя, только DLL. .NET Standard тоже нельзя взять целью |
Точку входа ставят в exe на стороне C#, C++/CLI подключают как DLL |
| Формат проекта | Используют не SDK-style csproj, а .vcxproj. Один проект не мультитаргетится на несколько .NET |
Если нужны и .NET Framework, и .NET, файлы проектов разделяют |
| Зависимость от среды выполнения | /clr включает и /MD, поэтому нужны DLL среды выполнения MSVC. Если цель — .NET, в вывод дополнительно кладут ijwhost.dll |
Если исходите из XCOPY-распространения или публикации одним файлом, это проверяют заранее |
| Архитектура ЦП | Смешанная сборка содержит нативный машинный код, поэтому одним двоичным файлом, как AnyCPU в C#, все архитектуры не закрыть | Собирают и распространяют отдельно для x86 / x64 и т. д. |
| Как загружается | С .NET 7 всегда загружается в AssemblyLoadContext по умолчанию. В .NET 6 и раньше при первом вызове со стороны native могла загрузиться в другой AssemblyLoadContext |
В схемах, где у каждого плагина свой контекст загрузки, поведение проверяют |
C++/CLI-проект может брать целью .NET (семейство .NET Core) начиная с Visual Studio 2019. Если доступна только более старая среда, сначала исходят из .NET Framework.
То есть критерий выбора — «учитывая сложность нативной DLL, где преобразование получится самым естественным». Для простого случая — P/Invoke, для сложного — C++/CLI. Такое разделение обычно работает.
flowchart TB
accTitle: Ситуации, в которых C++/CLI лучше не выбирать
accDescr: Четыре ситуации, в которых обёртку C++/CLI лучше не выбирать: чистый C API уже есть, нужна кроссплатформенность, граница мала и типы просты, жёстко смотрят на AOT и ограничения распространения.
n1["C API уже есть"] --> no["C++/CLI не выбирать"]
n2["Нужна кроссплатформенность"] --> no
n3["Граница мала, типы просты"] --> no
n4["Жёсткие ограничения AOT и распространения"] --> no
no -.-> judge["Где преобразование получится естественным"]
Рис. 14: C++/CLI не универсален. Если подходит одно из этих четырёх, сначала P/Invoke или пересмотр архитектуры.
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
- How to: Define and consume classes and structs (C++/CLI) - Microsoft Learn
- /clr (Common Language Runtime compilation) - Microsoft Learn
- Native AOT deployment overview - Microsoft Learn
- Using C++ Interop (Implicit PInvoke) - Microsoft Learn
- Platform Invoke (P/Invoke) - Microsoft Learn
- Overview of Marshaling in C++/CLI - Microsoft Learn
- marshal_as - Microsoft Learn
- Соображения по производительности Interop (C++) - Microsoft Learn
Похожие статьи
Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.
Безопасный вызов Win32 API из C# — практическое руководство по P/Invoke (DllImport / LibraryImport / CsWin32)
Разбираем, как на практике вызывать Win32 API из C# через P/Invoke: чем DllImport отличается от LibraryImport, как CsWin32 генерирует сиг...
CI/CD для WinForms / WPF: сборка, подпись и распространение в GitHub Actions
Практическое руководство по CI/CD для WinForms / WPF в GitHub Actions. Минимальный YAML сборки и тестов на windows-latest, нумерация верс...
Спящий режим, гибернация и Modern Standby: как не дать долгоживущему приложению остановиться ночью
Разбираем, почему долгоживущее Windows-приложение к утру оказывается остановленным: чем отличаются спящий режим S3, гибернация и Modern S...
Сетевые диски и UNC-пути: типичные ловушки ── как бизнес-приложению работать с файловым сервером (общей папкой)
Разбираем типичные сбои, когда бизнес-приложение пишет в общую папку или следит за ней. Почему службе не видна буква диска (Z:), какие пр...
Защита Windows-приложения от повторного запуска — именованный Mutex и активация окна при втором старте
Разбираем, как в бизнес-приложении Windows запретить повторный запуск через именованный Mutex. Разберём ловушку RDP из-за разницы Global\...
Связанные темы
Эти страницы показывают тему статьи в более широком контексте услуг и решений.
Технические темы 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, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.