Безопасный вызов Win32 API из C# — практическое руководство по P/Invoke (DllImport / LibraryImport / CsWin32)
· Обновлено: · Го Комура · P/Invoke, DllImport, LibraryImport, CsWin32, C#, .NET, Win32, SafeHandle, Нативное взаимодействие, Разработка Windows, Техническая консультация
История изменений (1 обновлений, последнее 30 Aug 2026)
Журнал изменений этой статьи. Там, где версия до правки была заархивирована, она остаётся доступной для чтения по постоянной ссылке с DOI.
- Русский текст переписан как полноценный технический перевод, а не калька с японского. Утверждения статьи не менялись.
- Первая публикация
Цитирование статьи(DOI (зарегистрированный архив): 10.5281/zenodo.21619956)
Приведённые ниже DOI относятся к ранее зарегистрированным архивным версиям, которые могут отличаться от текущего текста. Для ссылки на текущий текст используйте URL этой страницы.
Го Комура (2026). Безопасный вызов Win32 API из C# — практическое руководство по P/Invoke (DllImport / LibraryImport / CsWin32). KomuraSoft LLC. https://comcomponent.com/ru/blog/pinvoke-safe-guide/
- DOI (зарегистрированный архив)
- 10.5281/zenodo.21619956
- DOI (последняя зарегистрированная версия)
- 10.5281/zenodo.21619957
В этом блоге мы уже не раз писали о взаимодействии с нативным кодом: когда выбирать обёртку C++/CLI, а когда P/Invoke, как вызвать C# Native AOT DLL из C/C++, COM-мост, чтобы из 32-bit приложения вызвать 64-bit DLL, как Windows разрешает имена DLL. Не хватало одной статьи: о самом P/Invoke, на котором всё это стоит.
P/Invoke кажется простым: объявил функцию DLL как extern — и можно вызывать. На деле это технология, на которой рано или поздно спотыкаешься: то на маршалинге строк, то на времени жизни дескрипторов, то на том, когда читать код ошибки, то на раскладке структуры. Здесь мы пройдём то, что нужно держать в голове на практике, опираясь на LibraryImport — это значение по умолчанию начиная с .NET 7.
Термины, которые используются дальше
Сначала зафиксируем термины, которые ниже идут без пояснений.
| Термин | Смысл |
|---|---|
| P/Invoke | Platform Invoke (вызов платформы). Механизм .NET, которым управляемый код вызывает функции неуправляемой DLL |
| маршалинг | взаимное преобразование представления типов на границе управляемого и неуправляемого кода. Сюда входят передача строк, структур, массивов и делегатов |
| IL-заглушка | промежуточный код, который во время выполнения среда выполнения генерирует для вызова DllImport; в нём есть маршалинг, и после JIT-компиляции им пользуются для фактического вызова1 |
| Native AOT | способ публикации, при котором приложение .NET заранее компилируется в нативный код. Механизмы, которые генерируют код во время выполнения, здесь недоступны, поэтому схема с IL-заглушкой сочетается с ним плохо1 |
| trimming (обрезка) | функция публикации, которая вырезает неиспользуемый код и уменьшает размер развёртывания. Код, который порождается динамически во время выполнения, отследить нельзя1 |
| blittable-тип | тип, у которого битовое представление в управляемом и неуправляемом коде совпадает, поэтому его можно передать без преобразования. Подробности — в главе 72 |
1. Сначала выводы
Текст длинный, поэтому сначала четыре пункта, на которых ломаются чаще всего.
- Начиная с .NET 7 по умолчанию берите
LibraryImport, а неDllImport. Код маршалинга генерируется на этапе компиляции, поэтому есть совместимость с Native AOT и trimming, нет стоимости генерации IL-заглушки во время выполнения, а сгенерированный код можно пошагово отлаживать. АнализаторSYSLIB1054подсказывает, где переписатьDllImport.13 - Дескрипторы храните не как сырой
IntPtr, а как классы, производные отSafeHandle. Это базовая практика нативного взаимодействия в .NET: она не даёт сборщику мусора освободить дескриптор слишком рано, закрыть его дважды и попасть на «атаку через повторное использование».45 - Для строк явно указывайте
StringMarshallingи не используйтеStringBuilder. МаршалингStringBuilderвсегда копирует данные в нативный буфер: это неэффективно, и на обработке завершающего нуля легко ошибиться.6 - Если задали
SetLastError = true, сразу после вызова читайтеMarshal.GetLastPInvokeError(). Код ошибки нужно забрать до того, как его перезапишет другой управляемый код.7
Эти четыре пункта оказываются рядом не случайно. Объявление P/Invoke несколькими строками сразу фиксирует контракт перехода через границу: типы, строки, время жизни памяти и дескрипторов, способ получения ошибки.
flowchart TB
subgraph MG["управляемая сторона (.NET)"]
CODE["вызывающий код на C#"]
OBJ["объекты, которыми управляет GC<br/>• blittable-массив → pin и передача как есть<br/>• строка и другие non-blittable → преобразование и копирование<br/>• делегат → удерживать в живых на время вызова"]
SH["SafeHandle<br/>владеет временем жизни нативного дескриптора"]
end
subgraph BD["граница (маршалер)"]
SIG["объявление DllImport / LibraryImport<br/>= весь контракт границы — то, что здесь написано"]
CONV["преобразование типов и соглашение о вызовах"]
TMP["временный буфер для преобразования"]
end
subgraph NT["нативная сторона (Win32 API / своя C DLL)"]
FN["экспортированная функция"]
NRES["нативная память и дескрипторы"]
end
CODE --> SIG --> CONV --> FN --> NRES
OBJ -.->|"способ передачи зависит от природы значения"| CONV
CONV --> TMP
TMP -.->|"передаёт скопированное значение"| FN
NRES -.->|"кто освобождает, из объявления не видно<br/>решают по спецификации API"| SH
Рис. 1: Несколько строк объявления одновременно фиксируют типы, строки, время жизни и ошибки. Способ передачи зависит от природы значения (закрепить, скопировать или только удержать в живых); если что-то из этого нарушено, симптом один — «иногда падает».
Остальное — вещи, на которых спотыкаешься, если не знать, и которые закрываются парой строк, если знать. Ниже — только выводы; подробности смотрите в соответствующей главе.
| Тема | Вывод | Где подробно |
|---|---|---|
| Сигнатуры Win32 API | Не выписывайте вручную — пусть генерирует CsWin32. Достаточно перечислить имена функций в NativeMethods.txt, и из официальных метаданных Win32 появятся сигнатуры, константы и структуры8 |
Глава 3 |
| Раскладка структуры | По умолчанию LayoutKind.Sequential; отдельно решите, указывать ли Pack явно. Pack = 0 (значение по умолчанию) — это не «без верхней границы», а «размер упаковки по умолчанию для платформы»; правило выбора значения другое, чем у /Zp в C++. Оно может отличаться и между .NET Framework и .NET 5+9 |
Глава 7 |
| Обратный вызов (делегат) | Управляйте временем жизни так, чтобы GC не собрал делегат, пока нативная сторона им ещё пользуется. Держите его в поле static или используйте GC.KeepAlive; где можно, предпочитайте UnmanagedCallersOnly10 |
Глава 8 |
| 32-bit / 64-bit | Типы-указатели принимайте как IntPtr/nint. DLL разной разрядности не могут жить в одном процессе, поэтому такое требование средствами P/Invoke не решить |
Глава 9 |
| Что кроме P/Invoke | Простой C-интерфейс — P/Invoke; классы C++, владение и исключения — C++/CLI; пересечение границы процессов — COM. Это не конкуренция, а разделение ролей | Глава 10 |
На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 21, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle
2. DllImport и LibraryImport — что выбрать
DllImport — давно существующий механизм: во время выполнения среда генерирует IL-заглушку для маршалинга, компилирует её через JIT и только потом выполняет вызов. Раз генерация идёт во время выполнения, с конфигурациями вроде Native AOT или trimming, где сборка компилируется заранее, этот подход сочетается плохо, да и сама стоимость генерации не нулевая.1
LibraryImport — source generator, добавленный в .NET 7: для методов partial он генерирует код маршалинга на этапе компиляции. Сгенерированный код существует как обычный исходник C#, поэтому его можно пошагово отлаживать, а ошибки в сигнатуре всплывают рано — как ошибки сборки.1
using System.Runtime.InteropServices;
internal static partial class NativeMethods
{
[LibraryImport("nativelib", EntryPoint = "to_lower", StringMarshalling = StringMarshalling.Utf16)]
internal static partial string ToLower(string str);
}
У этого возвращаемого значения string есть легко упускаемое допущение. Маршалер, скопировав строку по вернувшемуся указателю, всегда пытается освободить эту память. На Windows для этого вызывается CoTaskMemFree. Если нативная сторона выделила указатель не через CoTaskMemAlloc (статический буфер, malloc, new[] и тому подобное — для C API это обычное дело), маршалер освободит память чужим аллокатором, и это приведёт к порче кучи или падению.11 Пока заголовок или документация вызываемой стороны явно не обещают выделение, совместимое с CoTaskMemAlloc, принимайте возврат как IntPtr, а не как string, и сами вызывайте соответствующую функцию освобождения (или ту процедуру, которую требует вызываемая сторона). Ещё лучше, если буфер выделяет и передаёт вызывающий код (массив символов вместо StringBuilder, о котором речь выше, либо шаблон буфера [Out], о котором ниже) — тогда неоднозначность владения памятью вообще не возникает.
Главные отличия от DllImport такие.12
CharSetупразднён и заменён наStringMarshalling(Utf16/Utf8/ свой вариант). Вариант ANSI убран, UTF-8 стал полноценной опцией первого класса.CallingConventionзаменён наUnmanagedCallConvAttribute.- Аналогов
ExactSpellingиPreserveSigнет: имя точки входа всегда задаётся точным написанием, а преобразование возвращаемого значения всегда выполняется напрямую. - И класс, и вызываемый метод должны быть
partial, а в проекте нужно включитьAllowUnsafeBlocks.
DllImport по-прежнему нужен, когда требуется настройка, которую LibraryImport ещё не поддерживает (например, некоторые варианты MarshalAs). Анализатор сообщает ошибкой, если вы пытаетесь использовать неподдерживаемую настройку, поэтому реалистичный путь — сначала написать LibraryImport и вернуться к DllImport только если генератор это отклонит.12
3. CsWin32 — вариант, в котором сигнатуры не пишут вручную
Если объявлять Win32 API по одной функции через DllImport/LibraryImport, риск ошибиться в типе параметра, значении константы или порядке полей структуры копится с каждой новой функцией. CsWin32 (Microsoft.Windows.CsWin32) — source generator, который из официальных метаданных Win32 API автоматически генерирует сигнатуры нужных функций, связанные константы и структуры.8
Пользоваться им просто: добавьте в проект пакет NuGet и перечислите имена нужных функций в текстовом файле NativeMethods.txt.
GetDpiForWindow
SetWindowPos
CreateFileW
CloseHandle
В одной строке NativeMethods.txt можно указать не только имя метода, но и имя типа, константы, пространства имён или модуля; минус в начале строки означает исключение.13
При сборке для этих функций генерируются сигнатуры P/Invoke (включая возвращаемое значение, параметры и указание SetLastError). Важно: по умолчанию генерация идёт на базе классического DllImport. Если целитесь в Native AOT или trimming, положите в корень проекта NativeMethods.json и отключите allowMarshaling — тогда генерация переключится на код, который не зависит от маршалера среды выполнения.13 Содержимого из двух строк достаточно:
{
"$schema": "https://aka.ms/CsWin32.schema.json",
"allowMarshaling": false
}
Строка $schema не обязательна, но с ней во многих JSON-редакторах появляются дополнение, подсказки и проверка, и оттуда же можно пройтись по списку доступных настроек.13
HANDLE выводится как подходящий тип, производный от SafeHandle, а строки — с корректными CharSet/StringMarshalling, поэтому типичные ручные ошибки — перепутанный CharSet или неверный порядок полей структуры — просто не возникнут.
Как мы писали в статье «Когда оправдана обёртка C++/CLI», для сложных DLL, где задействованы классы C++, владение ресурсами и исключения, хорошо работает тонкая обёртка. Но если на той стороне простой Win32 API (или близкая к нему DLL с C-интерфейсом), автогенерация сигнатур через CsWin32 — самый короткий и наименее рискованный путь. К своим DLL компании CsWin32 неприменим, но и тогда стиль сгенерированного им кода можно взять за образец.
4. Ловушки маршалинга строк
Компиляторы C#, VB и F# по умолчанию ставят CharSet.None объявлению P/Invoke, если CharSet не указан явно. Фактическое поведение CharSet.None совпадает с CharSet.Ansi: на Windows это маршалинг как не-Unicode (в локализованной кодовой странице). Если вызываемый Win32 API рассчитан на Unicode-версию (суффикс W), вызов с этим значением по умолчанию даёт кракозябры или потерю многобайтовых символов.14
В LibraryImport базовая практика — явно указывать StringMarshalling.Utf16. Сама опция ANSI упразднена, поэтому характерная для эпохи DllImport ошибка «положились на значение по умолчанию и неожиданно получили ANSI» структурно почти исключена.12
Ещё одна ловушка — параметр StringBuilder. Его часто используют в API вида «нативная сторона записывает строковый буфер и возвращает его», но маршалинг StringBuilder всегда копирует данные в нативный буфер, а ToString() добавляет ещё одну аллокацию. Если буфер помечен как [Out] (значение по умолчанию), на каждом вызове копится несколько аллокаций подряд — это неэффективный механизм. Плюс есть особенность: если вернувшийся буфер не завершён NUL-символом или это строка с двойным NUL-завершением, поведение легко ломается. Для частых вызовов стабильнее брать массив символов из ArrayPool<char>.6
Параметр [Out] string тоже стоит избегать: если строка оказалась интернированной, это может дестабилизировать среду выполнения.6
5. Время жизни дескрипторов — зачем нужен SafeHandle
Хранить нативные ресурсы — файловые дескрипторы, ключи реестра, дескрипторы устройств — как сырой IntPtr в нативном взаимодействии .NET нежелательно. Причин три.4
- Преждевременное освобождение дескриптора сборщиком мусора. Если класс с финализатором держит дескриптор в поле
IntPtr, возможна гонка: GC соберёт этот объект и закроет дескриптор прямо во время вызова P/Invoke. - Атака через повторное использование дескриптора. Windows активно повторно использует значения дескрипторов. Если продолжать пользоваться устаревшим
IntPtrв момент, когда «закрытое» значение уже отдано другому ресурсу, получится серьёзный сбой — операции над совершенно посторонним ресурсом. - Утечка из-за асинхронного исключения. Если асинхронное прерывание вроде прерывания потока происходит между получением дескриптора и записью его в поле, дескриптор может утечь.
SafeHandle — абстрактный класс, рассчитанный на эти проблемы. Он наследует CriticalFinalizerObject, поэтому логика освобождения гарантированно выполняется даже при аварийном завершении AppDomain. Вызовы P/Invoke автоматически увеличивают и уменьшают счётчик ссылок дескриптора, так что во время вызова дескриптор не могут повторно использовать.4
Для своих дескрипторов наследуйтесь, например, от SafeHandleZeroOrMinusOneIsInvalid из пространства имён Microsoft.Win32.SafeHandles и переопределяйте ReleaseHandle(). ReleaseHandle() выполняется в области ограниченного выполнения, где предполагается, что операция «не может завершиться неудачей», поэтому стандартная практика — не писать в нём сложную логику и ограничиться простым вызовом API освобождения. Свой финализатор писать не нужно (более того, этого стоит избегать).5
6. Обработка ошибок — SetLastError и GetLastPInvokeError
Большинство Win32 API при неудаче записывают через SetLastError код ошибки, локальный для потока, а вызывающая сторона читает его через GetLastError. Чтобы работать с этим из P/Invoke, поставьте true в DllImportAttribute.SetLastError (у LibraryImport есть одноимённое свойство).15
[LibraryImport("kernel32", EntryPoint = "SetCurrentDirectoryW", StringMarshalling = StringMarshalling.Utf16, SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool SetCurrentDirectoryW(string path);
Здесь важны два момента.
- Код ошибки читайте сразу после вызова. В .NET (кроме .NET Framework) при каждом вызове P/Invoke с
SetLastError = trueинформация об ошибке сначала очищается, и сохраняется результат только этого одного вызова. Если между вызовом и чтением вставить журналирование или другой вызов API, значение перезапишется и пропадёт, поэтому забирайте его сразу, как только увидели сбой.15 - Используйте
Marshal.GetLastPInvokeError(), а неMarshal.GetLastWin32Error(). Начиная с .NET 6 эти два метода функционально одинаковы, но первый — более новое имя, отражающее кроссплатформенный замысел, и именно его рекомендуют.7
if (!SetCurrentDirectoryW(path))
{
int error = Marshal.GetLastPInvokeError();
throw new Win32Exception(error);
}
7. Маршалинг структур — blittable-типы и StructLayout
Типы, чьё битовое представление совпадает в .NET и в нативный коде, называют blittable: их можно передавать без преобразования, а значит быстро. Сюда относятся базовые типы вроде byte, int, long, а также структуры с фиксированной раскладкой, целиком из blittable-типов-значений. Для blittable-структур sizeof() в C# работает быстрее, чем Marshal.SizeOf<T>(). А вот bool, наоборот, не blittable (нативный BOOL занимает 4 байта, bool в C/C++ — 1 байт), и бездумное его использование порождает ошибки, когда половина возвращаемого значения просто отбрасывается.2
Раскладкой структуры управляет StructLayoutAttribute. По умолчанию берите LayoutKind.Sequential (поля идут в порядке объявления); к LayoutKind.Explicit прибегайте только когда нужно явно задать позиции полей, как в union.9
Легко упустить поле Pack. По официальной документации правило размещения двухступенчатое.9
- Выравнивание типа в целом = меньшее из «размера самого большого поля» и «заданного значения
Pack» - Граница размещения каждого поля = меньшее из «собственного размера поля» и «выравнивания типа»
То есть если явно задать Pack небольшим значением (например, 2 или 4), оно работает как верхняя граница выравнивания — по аналогии с #pragma pack(N) в C++. Проблема в значении по умолчанию Pack = 0.
7.1 Соответствие «архитектура × значение по умолчанию»
Pack = 0 не означает «верхней границы нет». В формулировке официальной документации 0 — это «размер упаковки по умолчанию для текущей платформы».9 Верхняя граница существует; вы просто не задали её сами. Это и не «то же самое, что значение по умолчанию /Zp в C++». Оба говорят о верхней границе размера упаковки, но значение выбирается по-разному, поэтому переносить цифру с одной стороны на другую нельзя: расчёт перестанет сходиться.
| Среда | Что стоит за значением по умолчанию | x86 | x64 | ARM / ARM64 | ARM64EC |
|---|---|---|---|---|---|
Pack = 0 в C# (по умолчанию) |
выравнивание типа в целом = меньшее из «размера самого большого поля» и «размера упаковки по умолчанию для платформы»9 | то же | то же | то же | то же |
/Zp в C++ (верхняя граница выравнивания членов структуры)16 |
член размещается по меньшей из границ «собственный размер» и «граница N байт» | 8 байт | 16 байт | 8 байт | 16 байт |
На практике важно не читать это значение по умолчанию как «без верхней границы» и не считать смещения вручную. Особенно в структурах с полями, у которых естественное выравнивание велико, верхняя граница действует и при значении по умолчанию, и расчёт перестаёт сходиться. Если в ответ наугад проставить Pack, раскладка зафиксируется уже в расхождении с нативной стороной — в P/Invoke это самое опасное состояние. Когда в смещениях нет уверенности, измерьте их через Marshal.SizeOf и Marshal.OffsetOf из раздела 7.2 и сверьте с нативным заголовком.
К тому же раскладка по умолчанию на стороне C# может меняться и от версии среды выполнения. В официальной документации есть пример: структура с decimal из-за разного внутреннего состава полей при упаковке по умолчанию занимает 28 байт в .NET Framework и 32 байта в .NET 5+.9
| Ось сравнения | Что меняется |
|---|---|
| 32-bit процесс / 64-bit процесс | ширина полей-указателей (глава 9). За ней меняется и размер структуры целиком |
| .NET Framework / .NET 5+ | размер по умолчанию у структур с некоторыми типами (пример с decimal выше)9 |
| сторона C# / сторона C++ | само правило выравнивания по умолчанию (таблица выше) |
Нельзя исходить из того, что «раз это значение по умолчанию, значит, всё совпадёт» — вот вывод этого раздела. Если нативный заголовок явно меняет размер упаковки через #pragma pack или DLL содержит поле, которое требует выравнивания больше 8 байт, на стороне C# либо явно укажите Pack, либо проверьте фактические смещения полей способом из следующего раздела. Если этого не сделать, смещения полей уедут относительно ожидаемых и данные испортятся незаметно.
Наоборот, для простого API, который использует заголовки Windows SDK как есть, и все поля — базовые типы размером не больше 8 байт, оставить выравнивание по умолчанию и не трогать Pack на практике почти никогда не создаёт проблем.
// Пример: нативный заголовок явно указывает pack(4)
[StructLayout(LayoutKind.Sequential, Pack = 4)]
internal struct DeviceInfo
{
public int DeviceId;
public uint Flags;
public long Timestamp;
}
7.2 Как проверить раскладку на деле — Marshal.OffsetOf
Совпадение раскладки надёжнее проверять запуском, а не подсчётом на бумаге. Консольное приложение ниже печатает размер структуры и смещение каждого поля. Вставьте интересующую структуру, запустите и сверьте с нативным заголовком — как рабочий инструмент.
// Замените этим Program.cs проекта, созданного через dotnet new console (.NET 8 / C# 12)
using System.Runtime.InteropServices;
Console.WriteLine($"Архитектура процесса: {RuntimeInformation.ProcessArchitecture}");
Console.WriteLine($"IntPtr.Size : {IntPtr.Size} байт");
Console.WriteLine($"Marshal.SizeOf : {Marshal.SizeOf<DeviceInfo>()} байт");
Console.WriteLine("--- смещения полей ---");
foreach (var field in typeof(DeviceInfo).GetFields())
{
IntPtr offset = Marshal.OffsetOf<DeviceInfo>(field.Name);
Console.WriteLine($"{field.Name,-12} : {offset}");
}
// Сюда вставляют структуру, которую нужно проверить.
// Важно: она должна стоять после top-level statements
[StructLayout(LayoutKind.Sequential, Pack = 4)]
internal struct DeviceInfo
{
public int DeviceId;
public uint Flags;
public long Timestamp;
}
Marshal.OffsetOf<T>(string fieldName) возвращает смещение поля в байтах от начала в том виде, в каком структура маршалируется как неуправляемая. На нативной стороне те же числа получают макросами offsetof и sizeof языка C/C++ и сравнивают.
// Для сравнения. Подключите нативный заголовок (тот, где определена DeviceInfo)
#include <stdio.h>
#include <stddef.h>
#include "device.h"
int main(void)
{
printf("sizeof(DeviceInfo) = %zu\n", sizeof(DeviceInfo));
printf("offsetof(DeviceId) = %zu\n", offsetof(DeviceInfo, DeviceId));
printf("offsetof(Flags) = %zu\n", offsetof(DeviceInfo, Flags));
printf("offsetof(Timestamp) = %zu\n", offsetof(DeviceInfo, Timestamp));
return 0;
}
Если значения совпали по всем полям, на этой платформе раскладка сходится. Стоит одному полю разъехаться — ошибка в Pack либо в типе или порядке полей. Если поставляете и 32-bit, и 64-bit сборки, прогоните эту проверку на обеих разрядностях. Типичное проявление таких дефектов — «на одной разрядности сходится, на другой нет».
8. Время жизни обратных вызовов (делегатов)
Нередко native API нужно передать обратный вызов вида «когда закончишь, вызови эту функцию». В управляемом коде эту роль играет delegate, но здесь есть ловушка, характерная для GC. Даже получив указатель на функцию из делегата через Marshal.GetFunctionPointerForDelegate, сборщик мусора не отслеживает связь между этим указателем и делегатом. Если делегат соберут в тот момент, когда нативная сторона ещё пользуется указателем, процесс упадёт.10
Ещё один легко упускаемый момент — соглашение о вызовах. Когда делегат передаётся в нативный код как указатель на функцию через P/Invoke, по умолчанию берётся «соглашение о вызовах платформы по умолчанию»; чтобы зафиксировать его явно, повесьте на тип делегата UnmanagedFunctionPointerAttribute.17 На x64/ARM/ARM64 фактически существует только одно соглашение о вызовах, поэтому реального вреда обычно нет, даже если об этом не думать. Но на Windows x86 (32-bit) Stdcall (соглашение Win32 API по умолчанию) и Cdecl (часто встречается у C-библиотек юниксового происхождения) различаются, поэтому если заголовок вызываемой стороны использует Cdecl, оставленное по умолчанию значение может разрушить стек.17
// Явно указываем соглашение о вызовах. Обязательно для x86-сборки, если вызываемая сторона использует Cdecl
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate void MyCallback(int code);
private static readonly MyCallback s_callback = OnNativeEvent; // хранение в static фиксирует время жизни
// [UnmanagedFunctionPointer] задаёт соглашение для момента, когда обратный вызов
// «вызывают» с нативной стороны — это отдельная вещь от соглашения
// для самого этого вызова (RegisterCallback, это P/Invoke).
// По умолчанию LibraryImport использует соглашение платформы (на Windows
// это эквивалент stdcall), поэтому если вызываете C DLL с Cdecl,
// здесь тоже нужно указать это явно
[LibraryImport("nativelib")]
[UnmanagedCallConv(CallConvs = new[] { typeof(CallConvCdecl) })]
internal static partial void RegisterCallback(MyCallback callback);
private static void OnNativeEvent(int code)
{
// ...
}
// Вызывающая сторона
RegisterCallback(s_callback);
GC.KeepAlive(s_callback); // явно продлеваем жизнь переменной, которая иначе могла бы сразу выйти из области видимости
Если хранить обратный вызов в поле static, сборщик мусора не соберёт его, пока живёт приложение. Если точно известно, что нативная сторона использует обратный вызов только в рамках одного вызова (и отбрасывает указатель сразу после возврата из него), допустим и более лёгкий вариант — продлить жизнь локальной переменной через GC.KeepAlive.
Официальные рекомендации советуют, где это возможно, использовать статический метод с UnmanagedCallersOnlyAttribute и указатель на функцию (delegate*<...>) вместо типа Delegate. Накладные расходы меньше, чем при маршалинге делегата, и это лучше сочетается с Native AOT.10
9. Различия 32-bit и 64-bit процессов
Стоит один раз написать сигнатуру P/Invoke — и во время выполнения один и тот же путь кода используется и из 32-bit, и из 64-bit процесса. Здесь легко споткнуться о то, что ширина типов на нативной стороне следует за разрядностью процесса.
- Типы-указатели вроде
HANDLE,HWND,LPARAMзанимают 4 байта в 32-bit процессе и 8 байт в 64-bit. На стороне .NET их правильно принимать какIntPtr/UIntPtr(илиnint/nuint); если принимать какint/longфиксированного размера, получится код, который работает только в одной из разрядностей.6 - Если в структуре есть поля-указатели из пункта выше, её общий размер тоже меняется в зависимости от разрядности. Вместе с тем, что значение
Packпо умолчанию из главы 7 различается между архитектурами, тестируйте исходя из того, что одно и то же определение структуры может иметь разную двоичную раскладку в 32-bit и 64-bit сборке. - Само требование «из существующего 32-bit приложения использовать функциональность DLL, которая работает только в 64-bit» средствами P/Invoke не решить (DLL разной разрядности не могут сосуществовать в одном процессе). В этом случае процессы разделяют и соединяют мостом через COM или именованные каналы. Практический пример — в статье «COM-мост: вызов 64-bit DLL из 32-bit приложения».
- Проблема «DLL вообще не находится» или «загружается не та версия» — это не вопрос P/Invoke, а вопрос загрузчика Windows. В статье «Как Windows разрешает имена DLL» разобраны порядок поиска и поведение SxS — смотрите её при разборе причин
DllNotFoundException.
10. Таблица решений — P/Invoke, обёртка C++/CLI и COM-взаимодействие
P/Invoke — не единственный способ вызвать нативный код из C#. Если на той стороне сложная DLL с классами C++, владением ресурсами и исключениями, хорошо работает обёртка C++/CLI; если нужно пересечь границу процессов (мост 32-bit / 64-bit, использование из другого языка вроде VBA), выбор падает на COM.
| Аспект | P/Invoke (LibraryImport) | Обёртка C++/CLI | COM-взаимодействие |
|---|---|---|---|
| Кому подходит | Простой C-интерфейс (структуры, примитивные типы) | DLL с классами C++, владением ресурсами, исключениями, типами std:: |
Контрагент за границей процесса, другие языки вроде VBA |
| Стоимость реализации | Низкая–средняя (только определение сигнатур) | Средняя (нужно написать ещё один слой-обёртку) | Высокая (проектирование интерфейса, регистрация в реестре) |
| Типобезопасность | Средняя (при ручном написании ошибка в сигнатуре может всплыть только во время выполнения; CsWin32 это улучшает) | Высокая (с типами C++ можно работать напрямую) | Средняя (гарантируется через IDL / библиотеку типов) |
| Поддержка AOT/trimming | Отлично (при использовании LibraryImport) | Слабо (C++/CLI не поддерживает Native AOT) | Слабо |
| Обработка исключений | Нет (нужно самостоятельно проверять возвращаемое значение или HRESULT) | Отлично (исключения C++ можно преобразовать в исключения .NET) | Хорошо (HRESULT преобразуется в COM-исключение) |
| Пересечение границы процессов | Нет (только внутри одного процесса) | Нет (только внутри одного процесса) | Отлично (возможны внепроцессные серверы) |
| Удобство отладки | Хорошо (сгенерированный код LibraryImport можно отлаживать пошагово) | Хорошо (в VS можно отлаживать и нативный и управляемый код) | Слабо (проблемы со счётчиком ссылок или регистрацией трудно отследить) |
| Стоимость освоения | Низкая | Средняя–высокая (синтаксис C++/CLI) | Высокая (весь свод соглашений COM) |
Если идти по порядку — «на той стороне Win32 API на базе C-функций или своя простая C DLL» → P/Invoke (по возможности с CsWin32); «на той стороне класс C++, и владение ресурсами и исключения нужно передавать естественно» → обёртка C++/CLI (подробности в статье «Вызов нативной DLL из C#: обёртка C++/CLI против P/Invoke»); «нужно пересечь границу процессов или дать доступ из VBA» → COM — решение принимается без колебаний. Если разложить этот порядок на схему, видно, что развилка — только в первых двух вопросах.
flowchart TD
Q0{"в какую сторону вызываем"}
Q0 -->|"нужно вызвать код C# из C/C++"| QR{"в каком виде<br/>вызываемый .NET"}
QR -->|"нужен обратный вызов<br/>в уже работающий .NET"| CB["передать делегат или указатель на функцию<br/>(глава 8); достаточно обычной среды выполнения"]
QR -->|"экспортировать как<br/>native DLL"| AOT["Native AOT + UnmanagedCallersOnly<br/>(это не P/Invoke)"]
Q0 -->|"нужно вызвать нативный код из C#"| QC{"другая сторона уже<br/>публикует COM?"}
QC -->|"да (In-proc / Out-of-proc)"| COM["COM-взаимодействие"]
QC -->|"нет"| Q1{"всё укладывается в один процесс?"}
Q1 -->|"нужно пересечь границу процессов"| QI{"нужен COM<br/>(например, вызов из VBA)?"}
QI -->|"да"| COM2["сами поднимаем out-of-process<br/>сервер COM"]
QI -->|"нет"| IPC["мост через готовый IPC:<br/>именованный канал, сокет, RPC (глава 9)"]
Q1 -->|"достаточно одного процесса"| Q2{"каков интерфейс другой стороны"}
Q2 -->|"в основном C-функции и структуры"| PI["P/Invoke (LibraryImport;<br/>для Win32 API — CsWin32)"]
Q2 -->|"классы C++, владение, исключения"| CLI["обёртка C++/CLI<br/>(Native AOT недоступен)"]
Рис. 2: Три подхода не конкурируют, а закрывают разные случаи. Если другая сторона уже публикует COM, COM — естественная точка входа даже внутри одного процесса; если нужно лишь разнести процессы, достаточно существующего IPC, не обязательно COM.
Обратное направление (вызвать код C# из C/C++) тоже делится на два случая. Если нативной стороне нужен только обратный вызов в уже работающий процесс .NET, достаточно передать делегат или указатель на функцию UnmanagedCallersOnly из главы 8 — обычной среды выполнения хватает. Если код C# нужно экспортировать как отдельную нативную DLL и загружать со стороны, которая про .NET ничего не знает, публикация идёт через Native AOT. Про второй случай см. «Как вызвать C# Native AOT DLL из C/C++».
11. Пример реализации — операции с дескрипторами и обработка ошибок через LibraryImport
Пример, который собирает сказанное выше. Обернём функции OpenDevice / CloseDevice / ReadDeviceData вымышленного SDK датчика device.dll — с управлением дескрипторами через SafeHandle, маршалингом на этапе компиляции через LibraryImport и обработкой ошибок через SetLastError + GetLastPInvokeError.
Сначала класс, производный от SafeHandle, который хранит нативный дескриптор.
using Microsoft.Win32.SafeHandles;
// Оборачивает дескриптор device.dll. Независимо от времени жизни в GC
// предотвращает двойное освобождение, атаку повторного использования и преждевременное закрытие
internal sealed class DeviceSafeHandle : SafeHandleZeroOrMinusOneIsInvalid
{
// Нужен конструктор без параметров: этот тип используется как возвращаемое значение OpenDevice
public DeviceSafeHandle() : base(ownsHandle: true)
{
}
protected override bool ReleaseHandle()
// Внутри ReleaseHandle действует область ограниченного выполнения, где предполагается, что сбоя не будет.
// Ограничиваемся одним простым вызовом нативного освобождения
=> DeviceNativeMethods.CloseDevice(handle);
}
Дальше — объявления P/Invoke. Для строк явно указан StringMarshalling.Utf16, а на всех вызовах, которые могут завершиться неудачей, стоит SetLastError = true.
using System.Runtime.InteropServices;
internal static partial class DeviceNativeMethods
{
private const string DeviceDll = "device.dll";
// Если сделать дескриптор возвращаемым значением, SafeHandle начинает
// отслеживать его время жизни сразу после успешного вызова.
// При неудаче возвращается дескриптор с IsInvalid равным true
[LibraryImport(DeviceDll, EntryPoint = "OpenDevice",
StringMarshalling = StringMarshalling.Utf16, SetLastError = true)]
internal static partial DeviceSafeHandle OpenDevice(string devicePath);
// Внутренний API для прямого вызова из ReleaseHandle класса SafeHandle.
// handle используется только для освобождения, поэтому принимается как сырой IntPtr
[LibraryImport(DeviceDll, EntryPoint = "CloseDevice", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool CloseDevice(IntPtr handle);
// buffer — массив, уже выделенный вызывающей стороной. byte[] blittable,
// поэтому закрепляется (pinning), и запись с нативной стороны идёт в ту же память.
// Указывать [Out] явно не обязательно, но это добавлено, чтобы намерение было видно из кода
[LibraryImport(DeviceDll, EntryPoint = "ReadDeviceData", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool ReadDeviceData(
DeviceSafeHandle handle,
[Out] byte[] buffer,
int bufferLength,
out int bytesRead);
}
Наконец, тонкая обёртка на стороне потребителя. Код ошибки захватывается сразу после обнаружения сбоя и оборачивается в Win32Exception перед передачей вызывающей стороне.
using System.ComponentModel;
using System.Runtime.InteropServices;
public sealed class DeviceConnection : IDisposable
{
private readonly DeviceSafeHandle _handle;
private DeviceConnection(DeviceSafeHandle handle) => _handle = handle;
public static DeviceConnection Open(string devicePath)
{
DeviceSafeHandle handle = DeviceNativeMethods.OpenDevice(devicePath);
if (handle.IsInvalid)
{
// Захватываем код ошибки сразу после сбоя, до того как его перезапишет другой вызов API
int error = Marshal.GetLastPInvokeError();
handle.Dispose();
throw new IOException(
$"Не удалось открыть устройство: {devicePath} (Win32 error {error})",
new Win32Exception(error));
}
return new DeviceConnection(handle);
}
public byte[] Read(int maxBytes)
{
var buffer = new byte[maxBytes];
if (!DeviceNativeMethods.ReadDeviceData(_handle, buffer, buffer.Length, out int bytesRead))
{
int error = Marshal.GetLastPInvokeError();
throw new IOException($"Не удалось прочитать данные с устройства (Win32 error {error})",
new Win32Exception(error));
}
return bytesRead == buffer.Length ? buffer : buffer[..bytesRead];
}
// Достаточно вызвать SafeHandle.Dispose; финализатор писать не нужно
public void Dispose() => _handle.Dispose();
}
Коду, который использует DeviceConnection, достаточно обернуть его в using — об утечке незакрытых дескрипторов можно не думать. Принцип «что и где обнаруживать и во что преобразовывать» в такой многослойной конструкции — то же разделение ответственности по слоям, о котором мы писали в статье «Где в обработке исключений должны жить catch и журналирование». Ключевой момент здесь — переводить коды ошибок нативного слоя в исключения именно на границе P/Invoke, а выше этой границы обращаться с ними как с обычными исключениями .NET.
12. Заключение
За кажущейся простотой P/Invoke — «объявил функцию DLL, и её можно вызывать» — скрывается технология, на которой рано или поздно спотыкаешься: на маршалинге строк, на времени жизни дескрипторов, на моменте получения кода ошибки, на раскладке структур. Начиная с .NET 7 лучше сделать LibraryImport вариантом по умолчанию и, где возможно, поручить генерацию самих сигнатур CsWin32. Для строк явно указывайте StringMarshalling и избегайте StringBuilder. Дескрипторы храните через SafeHandle. Если используете SetLastError, забирайте код ошибки сразу после вызова. Для структур помните, что значение Pack по умолчанию различается между архитектурами. Временем жизни обратных вызовов управляйте явно. Каждый из пунктов этой статьи относится к той категории вещей, которые «закрываются парой строк, если о них знать заранее, и превращаются в дефект, воспроизводимый только в продакшене, если не знать».
А выбор — продолжать ли использовать P/Invoke или переключиться на обёртку C++/CLI либо COM — зависит от того, насколько «C-подобна» вызываемой DLL и нужно ли пересекать границу процессов. Вопросы о том, как вызвать существующий нативный код из C# или, наоборот, использовать код C# из нативной стороны, часто нельзя решить оптимально без взгляда на реальные заголовочные файлы или структуру DLL — если сомневаетесь, обращайтесь за консультацией.
Похожие статьи
- Вызов нативной DLL из C#: обёртка C++/CLI против P/Invoke
- Как вызвать C# Native AOT DLL из C/C++
- Пример COM-моста: вызов 64-bit DLL из 32-bit приложения
- Как Windows разрешает имена DLL — порядок поиска и SxS
Смежные темы консультаций
KomuraSoft LLC (合同会社小村ソフト) занимается технической консультацией по проектированию границы между C# и нативными DLL / Win32 API, разработкой и исследованием COM-компонентов, а также миграционными проектами, которые связывают существующий нативный код с .NET.
- Техническая консультация и ревью архитектуры
- Разработка COM-компонентов
- Разработка Windows-приложений
- Контакты
Справочные ссылки
-
Microsoft Learn, Source generation for platform invokes. О генерации кода маршалинга на этапе компиляции через LibraryImportAttribute, отличии от генерации IL-заглушки во время выполнения в DllImport и совместимости с Native AOT / trimming. ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
Microsoft Learn, Native interoperability best practices - Blittable types. Об определении blittable-типов, ловушке, связанной с тем, что bool не blittable, и о преимуществе sizeof() для blittable-структур. ↩ ↩2
-
Microsoft Learn, SYSLIB diagnostics for p/invoke source generation. О перечне диагностических идентификаторов, включая анализатор SYSLIB1054, который побуждает переписать DllImport на LibraryImport. ↩
-
Microsoft Learn, SafeHandle Class. О том, как SafeHandle предотвращает преждевременное освобождение дескриптора и атаку через повторное использование, и о гарантированном освобождении благодаря CriticalFinalizerObject. ↩ ↩2 ↩3
-
Microsoft Learn, Native interoperability best practices - General guidance. О рекомендации использовать SafeHandle для управления временем жизни неуправляемых ресурсов и избегать финализаторов. ↩ ↩2
-
Microsoft Learn, Native interoperability best practices. О том, что маршалинг StringBuilder всегда сопровождается копированием в нативный буфер и неэффективен, что стоит избегать аргументов [Out] string, и о применении SafeHandle вместо финализаторов. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, Marshal.GetLastPInvokeError Method. О способе получить код ошибки сразу после вызова P/Invoke с SetLastError=true и о том, что начиная с .NET 6 этот метод рекомендуют вместо GetLastWin32Error. ↩ ↩2
-
Microsoft Learn, Build a C# .NET app with WinUI 3 and Win32 interop. О подключении C#/Win32 P/Invoke Source Generator (Microsoft.Windows.CsWin32) и о процедуре генерации сигнатур через перечисление имён функций в NativeMethods.txt. ↩ ↩2
-
Microsoft Learn, StructLayoutAttribute.Pack Field. О значении Pack по умолчанию, равном 0 («размер упаковки по умолчанию для текущей платформы»), и о правилах расчёта выравнивания полей. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7
-
Microsoft Learn, Native interoperability best practices - Prevent delegate collection with GC.KeepAlive. О том, что GC не отслеживает связь между указателем на функцию, полученным через GetFunctionPointerForDelegate, и делегатом, о продлении времени жизни через GC.KeepAlive и о рекомендации использовать UnmanagedCallersOnly. ↩ ↩2 ↩3
-
Microsoft Learn, Default Marshalling Behavior - Memory management with the interop marshaller. О том, что маршалер всегда пытается освободить память, выделенную неуправляемым кодом, что на Windows для этого используется CoTaskMemFree и что память, выделенную не через CoTaskMemAlloc, нужно принимать как IntPtr и освобождать вручную. ↩
-
Microsoft Learn, Source generation for platform invokes - Differences from DllImport. О замене CharSet на StringMarshalling, использовании UnmanagedCallConvAttribute вместо CallingConvention и отсутствии аналогов ExactSpelling / PreserveSig. ↩ ↩2 ↩3
-
CsWin32, Getting Started. О том, что в каждой строке NativeMethods.txt можно указать имя метода, типа, константы, пространства имён или модуля (минус в начале — исключение), что настройки меняются файлом NativeMethods.json в корне проекта, что при отключённом allowMarshaling генерация не зависит от маршалера среды выполнения, и что
$schemaсо значениемhttps://aka.ms/CsWin32.schema.jsonвключает в JSON-редакторе дополнение, подсказки и проверку, а список настроек лежит там же. ↩ ↩2 ↩3 -
Microsoft Learn, Charsets and marshalling. О том, что компиляторы C#, Visual Basic и F# по умолчанию присваивают CharSet.None, если CharSet не указан явно, и что CharSet.None ведёт себя так же, как CharSet.Ansi (маршалинг как не-Unicode). ↩
-
Microsoft Learn, DllImportAttribute.SetLastError Field. О поведении в .NET при SetLastError равном true, в том числе о том, что информация об ошибке очищается при каждом вызове. ↩ ↩2
-
Microsoft Learn, /Zp (Struct Member Alignment). О том, что выравнивание членов структуры по умолчанию в компиляторе C++ составляет 8-байтовую границу на x86/ARM/ARM64 и 16-байтовую на x64/ARM64EC. ↩
-
Microsoft Learn, Unmanaged calling conventions. О том, что на Windows x86 Stdcall и Cdecl — разные соглашения о вызовах по умолчанию, что на x64/ARM/ARM64 фактически существует только одно соглашение о вызовах, и о явном указании соглашения через UnmanagedFunctionPointerAttribute. ↩ ↩2
Похожие статьи
Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.
Спящий режим, гибернация и Modern Standby: как не дать долгоживущему приложению остановиться ночью
Разбираем, почему долгоживущее Windows-приложение к утру оказывается остановленным: чем отличаются спящий режим S3, гибернация и Modern S...
MAX_PATH и ловушки путей и имён файлов в Windows — лимит 260 символов, зарезервированные имена, точка в конце, регистр
Разбираем ограничения путей и имён файлов — типичную причину ошибки «файл не найден». Из чего складывается MAX_PATH=260, как включить дли...
Сетевые диски и UNC-пути: типичные ловушки ── как бизнес-приложению работать с файловым сервером (общей папкой)
Разбираем типичные сбои, когда бизнес-приложение пишет в общую папку или следит за ней. Почему службе не видна буква диска (Z:), какие пр...
Защита Windows-приложения от повторного запуска — именованный Mutex и активация окна при втором старте
Разбираем, как в бизнес-приложении Windows запретить повторный запуск через именованный Mutex. Разберём ловушку RDP из-за разницы Global\...
Глубины ввода-вывода Windows (часть 4) — диспетчер кэша: когда WriteFile оказывается на диске
Четвёртая часть серии со схемами диспетчера кэша Windows. Разбираем кэш как проекцию файла, упреждающее чтение и отложенную запись, когда...
Связанные темы
Эти страницы показывают тему статьи в более широком контексте услуг и решений.
Технические темы Windows
Раздел о разработке Windows, расследовании сбоев и использовании существующих активов.
Услуги по этой теме
Статья напрямую связана со следующими услугами.
Разработка приложений для Windows
Бизнес-приложения, интеграция оборудования и средства связи — от требований до разработки.
Частые вопросы
Вопросы, которые часто возникают при консультациях по теме статьи.
- Что выбрать — DllImport или LibraryImport?
- Начиная с .NET 7 по умолчанию берите LibraryImport. DllImport во время выполнения генерирует IL-заглушку для маршалинга, а LibraryImport — это source generator, который создаёт код маршалинга на этапе компиляции: поэтому он совместим с Native AOT и trimming, а сгенерированный код можно пошагово отлаживать. Анализатор SYSLIB1054 подсказывает, где переписать DllImport. К DllImport возвращайтесь только если нужна настройка, которую LibraryImport ещё не поддерживает (например, некоторые варианты MarshalAs).
- Почему в P/Invoke опасно хранить дескрипторы как IntPtr?
- Из-за трёх проблем. Во-первых, возможна гонка с преждевременным освобождением: во время вызова P/Invoke сборщик мусора может собрать объект и закрыть дескриптор. Во-вторых, Windows активно повторно использует значения дескрипторов, поэтому «закрытый» IntPtr может привести к операциям над чужим ресурсом — это атака через повторное использование дескриптора. В-третьих, из-за асинхронного исключения возможна утечка дескриптора. Классы, производные от SafeHandle, закрывают всё это автоматическим счётчиком ссылок и гарантированным освобождением.
- Можно ли не писать сигнатуры Win32 API вручную?
- Да: для этого есть source generator CsWin32 (Microsoft.Windows.CsWin32). Добавьте пакет NuGet и перечислите нужные функции в текстовом файле NativeMethods.txt — сигнатуры, константы и структуры будут сгенерированы из официальных метаданных Win32. HANDLE выводится как подходящий тип, производный от SafeHandle, поэтому типичные ручные ошибки — перепутанный CharSet или неверный порядок полей структуры — просто не возникнут. По умолчанию генерация идёт на базе DllImport; если целитесь в Native AOT, в NativeMethods.json укажите allowMarshaling: false.
- Как выбирать между P/Invoke, обёрткой C++/CLI и COM?
- Смотрите на характер вызываемой DLL и на то, нужно ли пересекать границу процессов. Для простого C-интерфейса (структуры и примитивные типы) P/Invoke обходится дешевле всего; для Win32 API к нему полезно добавить CsWin32. Если DLL сложная — классы C++, владение ресурсами, исключения, типы std:: — вставьте слой-обёртку на C++/CLI. Если нужно пересечь границу процессов, как в мосте 32-bit / 64-bit, или отдать доступ из другого языка вроде VBA, берите COM. DLL разной разрядности не могут жить в одном процессе, поэтому такое требование средствами P/Invoke не решить.
Об авторе
Страница с профилем автора статьи.
Го Комура
Представитель KomuraSoft LLC
Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.