Безопасный вызов Win32 API из C# — практическое руководство по P/Invoke (DllImport / LibraryImport / CsWin32)

· · P/Invoke, DllImport, LibraryImport, CsWin32, C#, .NET, Win32, SafeHandle, Нативное взаимодействие, Разработка Windows, Техническая консультация

На этом блоге мы уже не раз писали о нативном взаимодействии: когда выбирать между C++/CLI-обёрткой и P/Invoke, как вызвать C# Native AOT DLL из C/C++, COM-мост для вызова 64-битной DLL из 32-битного приложения, как устроено разрешение имён DLL в Windows. Но статьи о самом P/Invoke — фундаменте, на котором держится всё перечисленное, — до сих пор не было.

P/Invoke подкупает простотой: достаточно объявить функцию DLL как extern, и её можно вызывать. Но за этой лёгкостью скрывается технология, на которой рано или поздно спотыкаешься — то на маршалинге строк, то на времени жизни дескрипторов, то на получении кода ошибки, то на раскладке структур. В этой статье мы последовательно разберём практические моменты, которые нужно держать в голове, — вокруг LibraryImport, ставшего стандартом по умолчанию начиная с .NET 7.

1. Сначала вывод

  • Начиная с .NET 7 в качестве значения по умолчанию стоит выбирать LibraryImport, а не DllImport. Он генерирует код маршалинга на этапе компиляции, поэтому совместим с Native AOT и trimming, не несёт затрат на генерацию IL-заглушки во время выполнения, а сгенерированный код можно пошагово отлаживать. Анализатор SYSLIB1054 подсказывает, где стоит переписать DllImport.12
  • Если приходится вручную выписывать сигнатуры Win32 API, стоит рассмотреть CsWin32. Достаточно перечислить нужные функции в NativeMethods.txt, и он сгенерирует сигнатуры LibraryImport, константы и структуры из официальных метаданных Win32.3
  • Для строк явно указывайте StringMarshalling и избегайте StringBuilder. Маршалинг StringBuilder всегда сопровождается копированием в нативный буфер — это неэффективный механизм, в котором к тому же легко ошибиться с обработкой завершающего нуля.4
  • Дескрипторы храните не как сырой IntPtr, а как классы, унаследованные от SafeHandle. Это базовое правило нативного взаимодействия в .NET, предотвращающее преждевременное освобождение дескриптора сборщиком мусора, двойное освобождение и «атаки через переиспользование» дескриптора.56
  • Если указан SetLastError = true, читайте Marshal.GetLastPInvokeError() сразу после вызова. Код ошибки нужно захватить раньше, чем его перезапишет выполнение другого управляемого кода.7
  • Для структур используйте LayoutKind.Sequential по умолчанию и осознанно решайте, указывать ли Pack явно. Фактическая раскладка при Pack = 0 (значение по умолчанию) — это не то же самое, что значение по умолчанию опции компилятора C++ /Zp (8 байт на x86/ARM/ARM64, 16 байт на x64/ARM64EC), и она может отличаться даже между .NET Framework и .NET 5+. Не стоит полагаться на то, что «раз это значение по умолчанию, значит, всё правильно».8
  • Управляйте временем жизни коллбэков (делегатов) так, чтобы сборщик мусора не собрал их раньше, чем нативная сторона закончит их использовать. Держите их в поле static или используйте GC.KeepAlive, а где возможно — отдавайте предпочтение UnmanagedCallersOnly.9
  • P/Invoke, C++/CLI-обёртка и COM-взаимодействие не конкурируют друг с другом, а разделяют сферы применения. Для простого C-интерфейса подходит P/Invoke, при работе с классами C++, владением ресурсами и исключениями — C++/CLI, а для пересечения границы процессов (например, моста между 32- и 64-разрядными процессами) — COM. Ось принятия решения сведена в таблицу в главе 10.

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 это обычное дело), маршалер освободит память не тем аллокатором, что приведёт к повреждению кучи или падению.10 Если заголовок или документация партнёра явно не гарантируют выделение через что-то совместимое с CoTaskMemAlloc, проектируйте возвращаемое значение как IntPtr, а не string, и вызывайте соответствующую функцию освобождения (или ту процедуру, которую требует партнёр) самостоятельно. Ещё лучше, если буфер выделяет и передаёт сам вызывающий код (массив символов вместо StringBuilder, о котором говорилось выше, либо паттерн буфера [Out], описанный далее) — тогда подобная неоднозначность владения памятью вообще не возникает.

Основные отличия от DllImport таковы.11

  • CharSet упразднён и заменён на StringMarshalling (Utf16 / Utf8 / пользовательский вариант). Вариант ANSI убран, а UTF-8 стал полноценной опцией первого класса.
  • CallingConvention заменён на UnmanagedCallConvAttribute.
  • Аналогов ExactSpelling и PreserveSig не предусмотрено: имя точки входа всегда указывается точным написанием, а преобразование возвращаемого значения всегда выполняется напрямую.
  • И класс, и вызываемый метод должны быть объявлены partial, а в проекте нужно включить AllowUnsafeBlocks.

DllImport по-прежнему нужен, когда требуется настройка, которую LibraryImport ещё не поддерживает (например, некоторые варианты MarshalAs). Анализатор сообщает ошибкой, если вы пытаетесь использовать неподдерживаемую настройку, так что реалистичный подход — сначала написать LibraryImport, а вернуться к DllImport только если он будет отклонён.11

3. CsWin32 — вариант без ручного написания сигнатур

Если объявлять Win32 API вручную по одной функции через DllImport/LibraryImport, риск ошибиться в типе параметра, значении константы или порядке полей структуры накапливается с каждой новой функцией. CsWin32 (Microsoft.Windows.CsWin32) — это source generator, который автоматически генерирует сигнатуры нужных функций, связанные константы и структуры из официальных метаданных Win32 API.3

Использовать его просто: добавить NuGet-пакет в проект и перечислить имена нужных функций в текстовом файле NativeMethods.txt.

GetDpiForWindow
SetWindowPos
CreateFileW
CloseHandle

При сборке для этих функций генерируются сигнатуры P/Invoke (включая возвращаемое значение, параметры и указание SetLastError). Обратите внимание, что по умолчанию генерация опирается на классический DllImport. Если вы ориентируетесь на Native AOT или trimming, можно переключиться на генерацию кода на основе LibraryImport, указав allowMarshaling: false в NativeMethods.json.3 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), вызов с этим значением по умолчанию приводит к «кракозябрам» или потере многобайтовых символов.12

В LibraryImport базовая практика — явно указывать StringMarshalling.Utf16. Поскольку сама опция ANSI упразднена, характерная для эпохи DllImport ошибка «положились на значение по умолчанию и неожиданно получили ANSI» структурно почти исключена.11

Ещё одна ловушка — параметр StringBuilder. Его часто используют в API, где «нативная сторона записывает буфер строки и возвращает его», но маршалинг StringBuilder всегда влечёт копирование в нативный буфер, а ToString() добавляет ещё одну аллокацию. Если буфер помечен как [Out] (значение по умолчанию), это неэффективный механизм, при котором на каждом вызове накапливается несколько аллокаций подряд. Кроме того, есть особенность: если возвращённый буфер не завершён NUL-символом или представляет собой строку с двойным NUL-завершением, поведение легко ломается. Для высокочастотных вызовов стабильнее использовать массив символов из ArrayPool<char>.4

Параметр [Out] string тоже стоит избегать: если строка оказалась интернированной, это может дестабилизировать рантайм.4

5. Управление временем жизни дескрипторов — зачем нужен SafeHandle

Хранить нативные ресурсы — файловые дескрипторы, ключи реестра, дескрипторы устройств — как сырой IntPtr в нативном взаимодействии .NET нежелательно. На то есть три причины.5

  • Преждевременное освобождение дескриптора сборщиком мусора. Если класс с финализатором хранит дескриптор в поле IntPtr, возможна гонка, при которой GC соберёт этот объект и закроет дескриптор прямо во время P/Invoke-вызова.
  • Атака через переиспользование дескриптора. Windows активно переиспользует значения дескрипторов. Если продолжать использовать устаревший IntPtr в тот момент, когда закрытое, казалось бы, значение дескриптора уже переназначено другому ресурсу, это приводит к серьёзной аварии — операции над совершенно посторонним ресурсом.
  • Утечка из-за асинхронного исключения. Если асинхронное прерывание, например прерывание потока, происходит в промежутке между получением дескриптора и его сохранением в поле, может произойти утечка дескриптора.

SafeHandle — абстрактный класс, спроектированный для решения этих проблем. Он наследуется от CriticalFinalizerObject, что гарантирует надёжное выполнение логики освобождения даже при аварийном завершении AppDomain. P/Invoke-вызовы автоматически увеличивают и уменьшают счётчик ссылок дескриптора, поэтому дескриптор не может быть переиспользован во время выполнения вызова.5

Для собственных дескрипторов наследуйтесь, например, от SafeHandleZeroOrMinusOneIsInvalid из пространства имён Microsoft.Win32.SafeHandles и переопределяйте ReleaseHandle(). Поскольку ReleaseHandle() выполняется в области с ограниченным выполнением, где предполагается, что операция «не может завершиться неудачей», стандартная практика — не писать в нём сложную логику, ограничившись простым вызовом API освобождения. Писать собственный финализатор не нужно (более того, этого стоит избегать).6

6. Обработка ошибок — SetLastError и GetLastPInvokeError

Большинство Win32 API при неудаче устанавливают через SetLastError код ошибки, локальный для потока, а вызывающая сторона читает его через GetLastError. Чтобы работать с этим из P/Invoke, установите true в DllImportAttribute.SetLastErrorLibraryImport есть одноимённое свойство).13

[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, значение будет перезаписано и потеряно, поэтому забирайте значение сразу, как только обнаружили сбой.13
  • Используйте Marshal.GetLastPInvokeError(), а не Marshal.GetLastWin32Error(). Начиная с .NET 6 эти два метода функционально идентичны, но первый — более новое имя, отражающее кроссплатформенный замысел, и рекомендуется к использованию.7
if (!SetCurrentDirectoryW(path))
{
    int error = Marshal.GetLastPInvokeError();
    throw new Win32Exception(error);
}

7. Маршалинг структур — блиттируемые типы и StructLayout

Типы, чьё битовое представление совпадает в .NET и в нативном коде, называют «блиттируемыми» (blittable) — их можно передавать без преобразования, а значит, быстро. Сюда относятся базовые типы вроде byte, int, long, а также структуры с фиксированной раскладкой, целиком состоящие из блиттируемых типов значений. Для блиттируемых структур sizeof() в C# работает быстрее, чем Marshal.SizeOf<T>(). А вот bool, наоборот, не блиттируем (нативный BOOL занимает 4 байта, тогда как bool в C/C++ — 1 байт), и бездумное его использование порождает ошибки, когда половина возвращаемого значения попросту отбрасывается.14

Раскладка структуры управляется через StructLayoutAttribute. По умолчанию используется LayoutKind.Sequential (поля располагаются в порядке объявления), а к LayoutKind.Explicit прибегают только тогда, когда нужно явно задать позиции полей, как в union.8

Легко упустить из виду поле Pack. Согласно официальной документации, выравнивание типа в целом берётся как меньшее из «размера самого большого поля» и «указанного значения Pack», а каждое поле размещается по меньшему из значений «собственный размер поля» и «выравнивание типа».8 Иначе говоря, если явно задать Pack небольшим значением (например, 2 или 4), оно будет работать как верхний предел выравнивания — аналогично #pragma pack(N) в C++. А вот значение по умолчанию 0 означает «выравнивание типа в целом равно размеру самого большого поля (без дополнительного специального предела)» — это отдельное правило, отличное от значения по умолчанию опции компилятора C++ /Zp (выравнивание членов структуры: по умолчанию 8-байтовая граница на x86/ARM/ARM64, 16-байтовая на x64/ARM64EC), и их нельзя просто отождествлять.15 Более того, эта раскладка по умолчанию может отличаться даже между .NET Framework и .NET 5+. Например, в официальной документации приведён пример, где структура, содержащая decimal, при упаковке по умолчанию занимает 28 байт в .NET Framework и 32 байта в .NET 5+ — из-за различий во внутреннем составе полей.8 То есть предположение «раз это значение по умолчанию, значит, всё верно» опасно применять в масштабе архитектуры. Если вы имеете дело с DLL, чей нативный заголовок явно меняет размер упаковки через #pragma pack или содержит поле, требующее выравнивания больше 8 байт, стоит либо явно указать Pack на стороне C#, либо проверить фактические смещения полей, например, через Marshal.OffsetOf. Пренебрежение этим приводит к смещению полей относительно ожидаемого и незаметной порче данных. И наоборот, для простого API, использующего заголовки Windows SDK как есть, где все поля — базовые типы размером не больше 8 байт, оставить выравнивание по умолчанию без изменения Pack на практике почти никогда не создаёт проблем.

// Пример, когда заголовок на нативной стороне явно указывает pack(4)
[StructLayout(LayoutKind.Sequential, Pack = 4)]
internal struct DeviceInfo
{
    public int DeviceId;
    public uint Flags;
    public long Timestamp;
}

8. Управление временем жизни коллбэков (делегатов)

Нередко нативному API нужно передать коллбэк с сутью «вызови эту функцию, когда закончишь». В управляемом коде эту роль играет delegate, но здесь есть характерная для GC ловушка. Даже получив указатель на функцию из делегата через Marshal.GetFunctionPointerForDelegate, сборщик мусора не отслеживает связь между этим указателем и делегатом. Если делегат будет собран в тот момент, когда нативная сторона всё ещё использует этот указатель на функцию, это приведёт к падению.9

Ещё один легко упускаемый момент — соглашение о вызовах. Когда делегат передаётся в нативный код как указатель на функцию через P/Invoke, по умолчанию используется «соглашение о вызовах, принятое на платформе по умолчанию»; если нужно явно его зафиксировать, добавьте UnmanagedFunctionPointerAttribute к типу делегата.16 На x64/ARM/ARM64 фактически существует только одно соглашение о вызовах, поэтому реального вреда обычно не бывает, даже если про это не думать. Но на Windows x86 (32-разрядной) Stdcall (соглашение по умолчанию для Win32 API) и Cdecl (распространённое среди C-библиотек юниксового происхождения) различаются, поэтому если заголовок партнёра использует Cdecl, оставленное по умолчанию значение может привести к повреждению стека.16

// Явно указываем соглашение о вызовах. Обязательно для 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.9

9. Различия между 32-разрядными и 64-разрядными процессами

Стоит один раз написать сигнатуру P/Invoke, и во время выполнения один и тот же путь кода используется независимо от того, вызывается ли она из 32-разрядного или 64-разрядного процесса. Здесь легко споткнуться о то, что ширина типов на нативной стороне следует за разрядностью процесса.

  • Типы-указатели вроде HANDLE, HWND, LPARAM занимают 4 байта в 32-разрядном процессе и 8 байт в 64-разрядном. На стороне .NET правильно принимать их как IntPtr/UIntPtr (или nint/nuint); если принимать их как int/long фиксированного размера, получится код, работающий только в одной из разрядностей.4
  • Если структура содержит поля указательного типа из пункта выше, её общий размер тоже меняется в зависимости от разрядности. С учётом того, что значение Pack по умолчанию из главы 7 тоже различается между архитектурами, тестируйте с расчётом на то, что одно и то же определение структуры может иметь разную бинарную раскладку в 32-разрядной и 64-разрядной сборке.
  • Само требование «использовать из существующего 32-разрядного приложения функциональность DLL, работающей только в 64-разрядном режиме» средствами P/Invoke не решить (DLL разной разрядности не могут сосуществовать в одном процессе). В этом случае нужно разделить процессы и перекинуть мост между ними через COM или именованные каналы. Практический пример см. в статье «COM-мост для вызова 64-разрядной DLL из 32-разрядного приложения».
  • Проблема «DLL вообще не находится» или «загружается не та версия, что задумывалась» — это не вопрос P/Invoke, а вопрос загрузчика Windows. В статье «Как устроено разрешение имён DLL в Windows» разобраны порядок поиска и поведение SxS — обращайтесь к ней при разборе причин DllNotFoundException.

10. Таблица решений — P/Invoke, C++/CLI-обёртка и COM-взаимодействие

P/Invoke — не единственный способ вызвать нативный код из C#. Если партнёр — сложная DLL с классами C++, владением ресурсами и исключениями, хорошо работает C++/CLI-обёртка, а если нужно пересечь границу процессов (мост между 32- и 64-разрядными процессами, использование из другого языка вроде 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 — таким путём решение принимается без колебаний. Для обратного направления (вызвать код C# из C/C++) используется не P/Invoke, а UnmanagedCallersOnly из 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[] блиттируем,
    // поэтому закрепляется (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})",
                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})",
                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 — если сомневаетесь, обращайтесь за консультацией.

Похожие статьи

Похожие направления консультаций

KomuraSoft LLC (合同会社小村ソフト) занимается технической консультацией по проектированию границы между C# и нативными DLL/Win32 API, разработкой и исследованием COM-компонентов, а также миграционными проектами, связывающими существующие нативные ресурсы с .NET.

Справочные ссылки

  1. Microsoft Learn, Source generation for platform invokes. О генерации кода маршалинга на этапе компиляции через LibraryImportAttribute, отличии от генерации IL-заглушки во время выполнения в DllImport и совместимости с Native AOT/trimming.  2 3

  2. Microsoft Learn, SYSLIB diagnostics for p/invoke source generation. О перечне диагностических идентификаторов, включая анализатор SYSLIB1054, который побуждает переписать DllImport на LibraryImport. 

  3. 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 3

  4. Microsoft Learn, Native interoperability best practices. О том, что маршалинг StringBuilder всегда сопровождается копированием в нативный буфер и неэффективен, что стоит избегать аргументов [Out] string, и о применении SafeHandle вместо финализаторов.  2 3 4

  5. Microsoft Learn, SafeHandle Class. О том, как SafeHandle предотвращает преждевременное освобождение дескриптора и атаки через переиспользование, и о гарантированном освобождении благодаря CriticalFinalizerObject.  2 3

  6. Microsoft Learn, Native interoperability best practices - General guidance. О рекомендации использовать SafeHandle для управления временем жизни неуправляемых ресурсов и избегать использования финализаторов.  2

  7. Microsoft Learn, Marshal.GetLastPInvokeError Method. О способе получения кода ошибки сразу после P/Invoke-вызова с SetLastError=true и о том, что начиная с .NET 6 этот метод рекомендован вместо GetLastWin32Error.  2

  8. Microsoft Learn, StructLayoutAttribute.Pack Field. О значении Pack по умолчанию, равном 0 («размер упаковки по умолчанию для текущей платформы»), и о правилах расчёта выравнивания полей.  2 3 4

  9. Microsoft Learn, Native interoperability best practices - Prevent delegate collection with GC.KeepAlive. О том, что GC не отслеживает связь между указателем на функцию, полученным через GetFunctionPointerForDelegate, и делегатом, о продлении времени жизни через GC.KeepAlive и о рекомендации использовать UnmanagedCallersOnly.  2 3

  10. Microsoft Learn, Default Marshalling Behavior - Memory management with the interop marshaller. О том, что маршалер всегда пытается освободить память, выделенную неуправляемым кодом, что на Windows для этого используется CoTaskMemFree и что память, выделенную не через CoTaskMemAlloc, нужно принимать как IntPtr и освобождать вручную. 

  11. Microsoft Learn, Source generation for platform invokes - Differences from DllImport. О замене CharSet на StringMarshalling, использовании UnmanagedCallConvAttribute вместо CallingConvention и отсутствии аналогов ExactSpelling/PreserveSig.  2 3

  12. Microsoft Learn, Charsets and marshalling. О том, что компиляторы C#, Visual Basic и F# по умолчанию присваивают CharSet.None, если CharSet не указан явно, и что CharSet.None ведёт себя так же, как CharSet.Ansi (маршалинг как не-Unicode). 

  13. Microsoft Learn, DllImportAttribute.SetLastError Field. О поведении в .NET при SetLastError равном true, в том числе о том, что информация об ошибке очищается при каждом вызове.  2

  14. Microsoft Learn, Native interoperability best practices - Blittable types. Об определении блиттируемых типов, ловушке, связанной с тем, что bool не блиттируем, и о преимуществе использования sizeof() для блиттируемых структур. 

  15. Microsoft Learn, /Zp (Struct Member Alignment). О том, что выравнивание членов структуры по умолчанию в компиляторе C++ составляет 8-байтовую границу на x86/ARM/ARM64 и 16-байтовую на x64/ARM64EC. 

  16. Microsoft Learn, Unmanaged calling conventions. О том, что на Windows x86 Stdcall и Cdecl являются разными соглашениями о вызовах по умолчанию, что на x64/ARM/ARM64 фактически существует только одно соглашение о вызовах, и о явном указании соглашения через UnmanagedFunctionPointerAttribute.  2

Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.

Если ваше Windows-приложение приняли за вирус — как реагировать на ложные срабатывания Microsoft Defender и жить с влиянием на производительность

Разбираем правильный порядок действий, если Microsoft Defender ложно определяет ваше Windows-приложение как вредоносное: как устроена сов...

Спящий режим, гибернация, Modern Standby и долго работающие приложения — как проектированием предотвратить «остановилось ночью»

Разбираем, почему долго работающее Windows-приложение оказывается «остановленным к утру», начиная с различий между спящим режимом S3, гиб...

Эти страницы показывают тему статьи в более широком контексте услуг и решений.

Статья напрямую связана со следующими услугами.

Частые вопросы

Вопросы, которые часто возникают при консультациях по теме статьи.

Что использовать — 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 нужно указать allowMarshaling: false в NativeMethods.json.
Как выбирать между P/Invoke, C++/CLI-обёрткой и COM?
Выбор зависит от природы вызываемой DLL и от того, нужно ли пересекать границу процессов. Для простого C-интерфейса (структуры и примитивные типы) P/Invoke обходится дешевле всего, а для Win32 API удобно дополнительно использовать CsWin32. Для сложной DLL, где задействованы классы C++, владение ресурсами, исключения или типы std::, стоит добавить прослойку в виде C++/CLI-обёртки. Если нужно пересечь границу процессов — например, мост между 32- и 64-разрядными процессами — или дать доступ из другого языка, такого как VBA, выбор падает на COM. DLL разной разрядности не могут сосуществовать в одном процессе, поэтому такое требование средствами P/Invoke не решить.

Об авторе

Страница с профилем автора статьи.

Го Комура

Представитель KomuraSoft LLC

Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.

Публичные ссылки

Вернуться в блог