Поделиться через


Рекомендации по использованию взаимодействия на уровне машинного кода

.NET предоставляет различные способы настройки собственного кода взаимодействия. В этой статье приводятся рекомендации, которым следуют команды Microsoft .NET для обеспечения взаимодействия с нативными системами.

Общее руководство

Рекомендации в этом разделе относятся ко всем сценариям взаимодействия.

  • ✔️ При возможности используйте [LibraryImport], если возможно, при работе с .NET 7+.
    • Существуют случаи, когда используется [DllImport] соответствующим образом. Анализатор кода с идентификатором SYSLIB1054 сообщает вам, когда это так.
  • ✔️ РЕКОМЕНДУЕТСЯ использовать такое же именование и регистр для методов и параметров, как и для нативного метода, который вы хотите вызвать.
  • ✔️ РАССМОТРИТЕ использование такого же именования и написания для константных значений.
  • ✔️ РЕКОМЕНДУЕТСЯ использовать типы .NET, которые наиболее сопоставимы с нативным типом. Например, если собственный тип в C# — uint, используйте unsigned int.
  • ✔️ Предпочитайте выражение собственных типов более высокого уровня с помощью структур .NET, а не классов.
  • ✔️ Рекомендуется использовать указатели функций, а не Delegate типы при передаче обратных вызовов в неуправляемые функции в C#.
  • ✔️ Обязательно используйте атрибуты [In] и [Out] для параметров массива.
  • ✔️ Используйте [In] и [Out] атрибуты только для других типов, если нужное поведение отличается от поведения по умолчанию.
  • ✔️ Подумайте о использовании System.Buffers.ArrayPool<T> для объединения нативных буферов массивов.
  • ✔️ ДОПУСТИМО создавать оболочку для объявлений P/Invoke в классе с таким же именем и регистром, как в нативной библиотеке.
    • Это позволяет [LibraryImport][DllImport] использовать функцию языка C# nameof , чтобы передать имя собственной библиотеки и убедиться, что вы не пропустили имя собственной библиотеки.
  • ✔️ Используйте SafeHandle дескриптор для управления временем существования объектов, которые инкапсулируют неуправляемые ресурсы. Дополнительные сведения см. в разделе Очистка неуправляемых ресурсов.
  • ❌ Избегайте завершения для управления временем существования объектов, которые инкапсулируют неуправляемые ресурсы. Дополнительные сведения см. в разделе «Реализация метода Dispose».

Параметры атрибута LibraryImport

Анализатор кода с идентификатором SYSLIB1054 помогает вам LibraryImportAttribute. В большинстве случаев использование LibraryImportAttribute требует явного объявления, а не использования параметров по умолчанию. Эта конструкция является преднамеренной и помогает избежать непреднамеренного поведения в сценариях взаимодействия.

Параметры атрибута DllImport

Настройка По умолчанию. Рекомендация Сведения
PreserveSig true Сохранить значение по умолчанию Если явно задано значение false, неудачные возвращаемые значения HRESULT будут преобразованы в исключения (при этом возвращаемое значение в определении становится null).
SetLastError false Зависит от API Присвойте этому параметру значение true, если для получения значения в API используется GetLastError и Marshal.GetLastWin32Error. Если API устанавливает условие, указывающее на ошибку, перед тем как выполнить другие вызовы, получите информацию об ошибке, чтобы избежать ее непреднамеренной перезаписи.
CharSet Определяемый компилятором (указанный в документации по charset) Если в определении есть строки или символы, используйте CharSet.Unicode или CharSet.Ansi в явном виде Указывает специфику обработки строк и что делает ExactSpelling, когда false. Обратите внимание, что в Unix CharSet.Ansi имеет кодировку UTF8. Обычно в Windows используется Юникод, а в Unix — UTF8. См. дополнительные сведения в документации по кодировке.
ExactSpelling false true Если присвоить этому параметру значение true, можно немного повысить производительность — среда выполнения не будет искать другие имена функций с суффиксом "A" или "W" в зависимости от значения параметра CharSet ("A" для CharSet.Ansi и "W" для CharSet.Unicode).

Параметры строки

Объект string закрепляется и используется непосредственно машинным кодом (а не копируется), когда он передается по значению (не ref или out) и соответствует любому из следующих условий:

❌ Не используйте [Out] string параметры. Строковые параметры, передаваемые по значению с атрибутом [Out], могут дестабилизировать среду выполнения, если строка является интернированной. См. дополнительные сведения в документации по методу интернирования строк String.Intern.

✔️ Рассмотрите char[] или byte[] массивы из ArrayPool, когда ожидается, что нативный код заполнит буфер символов. Для этого требуется передать аргумент как [Out].

Руководство, cпецифичное для DllImport

✔️ Рассмотрите возможность настройки свойства CharSet в [DllImport] так, чтобы среда выполнения знала ожидаемое кодирование строк.

✔️ Следует избегать параметров StringBuilder. При маршаллингe StringBuilderвсегда создается копия нативного буфера. Таким образом, это может быть крайне неэффективным. Выполните обычный сценарий вызова API Windows, который принимает строку:

  1. Создайте нужную StringBuilder емкость (выделяет управляемую емкость). {1}
  2. Взывать:
    1. Выделяет собственный буфер {2}.
    2. Копирует содержимое, если [In](по умолчанию для StringBuilder параметра)
    3. Копирует собственный буфер в только что выделенный управляемый массив, если [Out]{3}(также по умолчанию для StringBuilder).
  3. ToString() выделяет еще один управляемый массив {4}.

{4} Это выделение для получения строки из машинного кода. Лучшее, что можно сделать, чтобы ограничить потери — это повторно использовать StringBuilder в другом вызове, но это по-прежнему экономит только одно выделение. Гораздо лучше использовать и кэшировать буфер символов из ArrayPool. Затем можно перейти к выделению только для ToString() во время последующих вызовов.

Еще одна проблема, связанная с StringBuilder, заключается в том, что этот атрибут всегда создает резервную копию буфера возврата к первому значению NULL. Если возвращенная строка не завершена или завершается двумя символами NULL, атрибут P/Invoke задан неправильно (в лучшем случае).

Если вы все же используетеStringBuilder, возникает еще одна проблема — емкость не включает скрытого значения NULL, которое всегда учитывается при взаимодействии. Пользователи часто ошибаются, так как для многих API требуется, чтобы в размер буфера включалось значение NULL. Это может привести к чрезмерному или ненужному распределению. Кроме того, эта ошибка не позволяет среде выполнения оптимизировать маршалинг StringBuilder для минимизации количества копий.

Дополнительные сведения см. в статье Маршалинг по умолчанию для строк и разделе Customizing string parameters (Настройка строковых параметров).

Для среды Windows. Для строк [Out] в среде CLR по умолчанию используется CoTaskMemFree (для свободных строк) или SysStringFree (для строк, обозначенных как UnmanagedType.BSTR). Для большинства API с буфером выходной строки: число переданных символов должно включать нулевой символ. Если возвращаемое значение меньше, чем количество переданных символов, значит, вызов завершился успешно. В таком случае значение — это количество символов без NULL в конце. В противном случае значение - требуемый размер буфера, включая нулевой символ.

  • Передайте 5, получите 4: строка длиной 4 символа с завершающим нулевым символом.
  • Передайте 5, получите 6: строка имеет длину 5 символов, требуется 6 символьных буфера, чтобы сохранить значение NULL. Windows Data Types for Strings (Типы данных Windows для работы со строками)

Логические параметры и поля

Булевые значения легко напутать. По умолчанию, тип .NET bool маршалируется в тип Windows BOOL, где он представлен как значение из 4 байт. Но типы _Bool и bool в C и C++ имеют размер один байт. Это может привести к сложностям в отслеживании ошибок, поскольку половина возвращаемого значения будет отброшена, что лишь возможно изменит результат. Для получения дополнительной информации об управлении значениями .NET bool для типов C или C++ bool, см. документацию по настройке маршалинга булевых полей.

Идентификаторы GUID

Идентификаторы GUID можно использовать непосредственно в сигнатурах. Многие API Windows принимают такие псевдонимы типа GUID&, как REFIID. Если сигнатура метода содержит ссылочный параметр, в объявление параметра GUID поместите ключевое слово ref или атрибут [MarshalAs(UnmanagedType.LPStruct)].

GUID Идентификатор GUID для передачи по ссылке
KNOWNFOLDERID REFKNOWNFOLDERID

❌ Не используйте [MarshalAs(UnmanagedType.LPStruct)] ничего, кроме ref параметров GUID.

Плоские типы

Непреобразуемые типы — это типы данных с одинаковым представлением на битовом уровне в управляемом и машинном коде. Для маршалинга в нативный код и из него эти типы не нужно преобразовывать в другой формат, что увеличивает производительность. Поэтому им следует отдавать предпочтение. Некоторые типы не являются blittable, но, как известно, содержат переносимое содержимое. Эти типы имеют аналогичные оптимизации, как блиттбл-типы, когда они не содержатся в другом типе, но не считаются блиттбл в полях структур или для целей UnmanagedCallersOnlyAttribute.

Типы blittable при включенном маршалинге среды выполнения

Блитабельные типы данных:

  • byte, sbyte, short, ushort, int, uint, long, ulong, single, double
  • структуры с фиксированным макетом, которые имеют только типы значений, допускающих перерезку для полей экземпляра
    • Для фиксированного макета требуется указать [StructLayout(LayoutKind.Sequential)] или [StructLayout(LayoutKind.Explicit)]
    • Структуры являются по LayoutKind.Sequential умолчанию

Типы с содержимым, которое можно напрямую обменивать:

  • невложенные одномерные массивы блитабельных примитивных типов (например, int[])
  • классы с фиксированным макетом, которые имеют только типы значений, поддерживающие преобразование в память, для полей экземпляра.
    • Для фиксированной структуры требуется указать [StructLayout(LayoutKind.Sequential)] или [StructLayout(LayoutKind.Explicit)].
    • Классы LayoutKind.Auto включены по умолчанию

Преобразуемые типы данных:

  • bool

ПЕРИОДИЧЕСКИ преобразуемые типы данных:

  • char

Типы, содержимое которых иногда является blittable:

  • string

При передаче блиттабельных типов по ссылке с помощью in, ref или out, или при передаче типов с блиттабельным содержимым по значению, они просто закрепляются маршаллером, а не копируются в промежуточный буфер.

Тип char является блиттабельным в одномерном массиве или, если он является частью типа, который явно помечен [StructLayout] с CharSet = CharSet.Unicode.

[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
public struct UnicodeCharStruct
{
    public char c;
}

string содержит блиттовое содержимое, если он не содержится в другом типе и передается значением (не ref или out) в качестве аргумента и при одном из следующих условий:

  • StringMarshalling определяется как Utf16.
  • Аргумент явно помечен как [MarshalAs(UnmanagedType.LPWSTR)].
  • CharSet — Юникод.

Вы можете узнать, является ли тип перерезаемым или содержит закрепления содержимое, пытаясь создать закрепленное GCHandleсодержимое. Если тип не является строкой или считается непреобразуемым, GCHandle.Alloc вызовет ArgumentException.

Типы blittable при отключении маршалинг среды выполнения

При отключении маршаллинга среды выполнения правила, для которых типы являются перерезаемыми, значительно проще. Все типы C# unmanaged и не имеют полей, помеченных как [StructLayout(LayoutKind.Auto)] blittable. Все типы, которые не являются типами C# unmanaged , не являются перерезаемыми. Концепция типов с объемным содержимым, например массивами или строками, не применяется при отключении маршаллинга среды выполнения. Любой тип, который не считается перерезанным в приведенном выше правиле, не поддерживается при отключении маршаллинга среды выполнения.

Эти правила отличаются от встроенной системы в первую очередь в ситуациях, когда bool и char используются. При отключении маршаллинга bool передается как 1-байтовое значение, не нормализовано, а char всегда передается в виде 2-байтового значения. Если маршалирование среды выполнения включено, bool может сопоставляться со значением 1, 2 или 4-байтов и всегда нормализовано, а char также сопоставляется со значением 1 или 2 байтов в зависимости от значения CharSet.

✔️ РЕКОМЕНДУЕТСЯ по возможности сделать свои структуры данных непреобразуемыми.

Дополнительные сведения см. в разделе:

Сохранение управляемых объектов активными

GC.KeepAlive() гарантирует, что объект остается в области видимости, пока не вызовется метод KeepAlive.

HandleRef позволяет маршалеру сохранять объект активным во время выполнения P/Invoke. Его можно использовать вместо IntPtr в сигнатурах метода. SafeHandle фактически заменяет этот класс и должен использоваться вместо него.

GCHandle разрешает закреплять управляемый объект и получать нативный указатель на него. Базовый шаблон приведен ниже:

GCHandle handle = GCHandle.Alloc(obj, GCHandleType.Pinned);
IntPtr ptr = handle.AddrOfPinnedObject();
handle.Free();

Закрепление по умолчанию не предусмотрено для GCHandle. Еще один основной шаблон предназначен для передачи ссылки на управляемый объект через машинный код и обратно на управляемый код, обычно с обратным вызовом. Вот этот шаблон:

GCHandle handle = GCHandle.Alloc(obj);
SomeNativeEnumerator(callbackDelegate, GCHandle.ToIntPtr(handle));

// In the callback
GCHandle handle = GCHandle.FromIntPtr(param);
object managedObject = handle.Target;

// After the last callback
handle.Free();

Не забывайте, что GCHandle необходимо явно освобождать, чтобы избежать утечек памяти.

Общие типы данных Windows

Ниже приведен список типов данных, часто используемых в API Windows, и типов данных C#, используемых при вызове в виде кода Windows.

Указанные ниже типы имеют одинаковый размер в 32-разрядной и 64-разрядной версиях Windows независимо от их имен.

Width Windows C# Альтернатива
32 BOOL int bool
8 BOOLEAN byte [MarshalAs(UnmanagedType.U1)] bool
8 BYTE byte
8 UCHAR byte
8 UINT8 byte
8 CCHAR byte
8 CHAR sbyte
8 CHAR sbyte
8 INT8 sbyte
16 CSHORT short
16 INT16 short
16 SHORT short
16 ATOM ushort
16 UINT16 ushort
16 USHORT ushort
16 WORD ushort
32 INT int
32 INT32 int
32 LONG int Ознакомьтесь с разделами CLong и CULong.
32 LONG32 int
32 CLONG uint Ознакомьтесь с разделами CLong и CULong.
32 DWORD uint Ознакомьтесь с разделами CLong и CULong.
32 DWORD32 uint
32 UINT uint
32 UINT32 uint
32 ULONG uint Ознакомьтесь с разделами CLong и CULong.
32 ULONG32 uint
64 INT64 long
64 LARGE_INTEGER long
64 LONG64 long
64 LONGLONG long
64 QWORD long
64 DWORD64 ulong
64 UINT64 ulong
64 ULONG64 ulong
64 ULONGLONG ulong
64 ULARGE_INTEGER ulong
32 HRESULT int
32 NTSTATUS int

Следующие типы являются указателями и зависят от ширины платформы. Для них используйте IntPtr/UIntPtr.

Типы указателей со знаком (используйте IntPtr) Типы указателей без знака (используйте UIntPtr)
HANDLE WPARAM
HWND UINT_PTR
HINSTANCE ULONG_PTR
LPARAM SIZE_T
LRESULT
LONG_PTR
INT_PTR

Windows PVOID, который является C void*, может быть маршалирован как IntPtr или UIntPtr, но предпочтительнее использовать void*, когда это возможно.

Типы данных Windows

Диапазоны типов данных

Ранее поддерживаемые встроенные типы

Редкие случаи, когда встроенная поддержка типа удаляется.

Поддержка встроенного маршала UnmanagedType.HString и встроенного маршала UnmanagedType.IInspectable была удалена в выпуске .NET 5. Необходимо повторно компилировать двоичные файлы, которые используют этот тип маршалинга и предназначены для предыдущего фреймворка. Этот тип по-прежнему можно маршалировать, но его необходимо маршалировать вручную, как показано в следующем примере кода. Этот код будет работать вперед и также совместим с предыдущими платформами.

public sealed class HStringMarshaler : ICustomMarshaler
{
    public static readonly HStringMarshaler Instance = new HStringMarshaler();

    public static ICustomMarshaler GetInstance(string _) => Instance;

    public void CleanUpManagedData(object ManagedObj) { }

    public void CleanUpNativeData(IntPtr pNativeData)
    {
        if (pNativeData != IntPtr.Zero)
        {
            Marshal.ThrowExceptionForHR(WindowsDeleteString(pNativeData));
        }
    }

    public int GetNativeDataSize() => -1;

    public IntPtr MarshalManagedToNative(object ManagedObj)
    {
        if (ManagedObj is null)
            return IntPtr.Zero;

        var str = (string)ManagedObj;
        Marshal.ThrowExceptionForHR(WindowsCreateString(str, str.Length, out var ptr));
        return ptr;
    }

    public object MarshalNativeToManaged(IntPtr pNativeData)
    {
        if (pNativeData == IntPtr.Zero)
            return null;

        var ptr = WindowsGetStringRawBuffer(pNativeData, out var length);
        if (ptr == IntPtr.Zero)
            return null;

        if (length == 0)
            return string.Empty;

        return Marshal.PtrToStringUni(ptr, length);
    }

    [DllImport("api-ms-win-core-winrt-string-l1-1-0.dll")]
    [DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
    private static extern int WindowsCreateString([MarshalAs(UnmanagedType.LPWStr)] string sourceString, int length, out IntPtr hstring);

    [DllImport("api-ms-win-core-winrt-string-l1-1-0.dll")]
    [DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
    private static extern int WindowsDeleteString(IntPtr hstring);

    [DllImport("api-ms-win-core-winrt-string-l1-1-0.dll")]
    [DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
    private static extern IntPtr WindowsGetStringRawBuffer(IntPtr hstring, out int length);
}

// Example usage:
[DllImport("api-ms-win-core-winrt-l1-1-0.dll", PreserveSig = true)]
internal static extern int RoGetActivationFactory(
    /*[MarshalAs(UnmanagedType.HString)]*/[MarshalAs(UnmanagedType.CustomMarshaler, MarshalTypeRef = typeof(HStringMarshaler))] string activatableClassId,
    [In] ref Guid iid,
    [Out, MarshalAs(UnmanagedType.IUnknown)] out object factory);

Рекомендации по кроссплатформенным типам данных

Существуют типы на языке C/C++, которые имеют широту в том, как они определены. При написании кроссплатформенного взаимодействия могут возникать случаи, когда платформы отличаются и могут вызывать проблемы, если они не рассматриваются.

C/C++ long

C/C++ long и C# long не обязательно одинакового размера.

Тип long в C/C++ определяется как "не менее 32" битов. Это означает, что существует минимальное количество необходимых битов, но платформы могут использовать больше битов при желании. В следующей таблице показаны различия в предоставленных битах для типа данных C/C++ long между платформами.

Платформа 32-разрядное 64-разрядное
Windows 32 32
macOS/*nix 32 64

В отличие от этого, C# long всегда имеет 64-разрядную версию. По этой причине рекомендуется избежать использования C# long для взаимодействия с C/C++ long.

(Эта проблема с C/C++ не существует для C/C++ longchar, shortintи long long так как они имеют 8, 16, 32 и 64 бит соответственно на всех этих платформах.)

В .NET 6 и более поздних версиях используйте типы CLong и CULong для взаимодействия с типами данных C/C++ long и unsigned long. Следующий пример предназначен для CLong, но можно использовать CULong для абстрагирования unsigned long таким же образом.

// Cross platform C function
// long Function(long a);
[DllImport("NativeLib")]
extern static CLong Function(CLong a);

// Usage
nint result = Function(new CLong(10)).Value;

При выборе .NET 5 и более ранних версий следует объявить отдельные подписи Windows и не Windows, чтобы справиться с этой проблемой.

static readonly bool IsWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);

// Cross platform C function
// long Function(long a);

[DllImport("NativeLib", EntryPoint = "Function")]
extern static int FunctionWindows(int a);

[DllImport("NativeLib", EntryPoint = "Function")]
extern static nint FunctionUnix(nint a);

// Usage
nint result;
if (IsWindows)
{
    result = FunctionWindows(10);
}
else
{
    result = FunctionUnix(10);
}

Структуры

Управляемые структуры создаются в стеке и не удаляются, пока метод не вернет значение. Таким образом, по определению, эти структуры "закреплены" (не перемещаются службой сборки мусора). Вы также можете просто взять адрес в блоках неуправляемого кода, если родной код не будет использовать указатель после завершения текущего метода.

Блитблочные структуры более производительны, так как их можно использовать непосредственно на маршалинговом уровне. Попробуйте сделать структуры непреобразуемыми (например, не используйте атрибут bool). См. дополнительные сведения в разделе Непреобразуемые типы данных.

Если структура является непреобразуемой, используйте атрибут sizeof() вместо Marshal.SizeOf<MyStruct>(), чтобы повысить производительность. Как описано выше, чтобы проверить, является ли тип непреобразуемым, попытайтесь создать закрепленный атрибут GCHandle. Если тип не является строкой или считается непреобразуемым, атрибут GCHandle.Alloc вызовет ArgumentException.

Указатели на структуры в определениях необходимо передавать либо через ref, либо использовать unsafe и *.

✔️ НЕОБХОДИМО сопоставить управляемую структуру как можно точнее с формой и именами, которые используются в официальной документации платформы или заголовках.

✔️ РЕКОМЕНДУЕТСЯ использовать C# sizeof() вместо Marshal.SizeOf<MyStruct>() для непреобразуемых структур, чтобы повысить производительность.

❌ Не полагайтесь на внутреннее представление типов структур, предоставляемых библиотеками среды выполнения .NET, если только они явно не задокументированы.

❌ Избегайте использования классов для выражения сложных собственных типов с помощью наследования.

❌ ИЗБЕГАЙТЕ использования полей System.Delegate или System.MulticastDelegate для представления полей с указателями функций в структурах.

Поскольку System.Delegate и System.MulticastDelegate не имеют требуемых сигнатур, они не гарантируют соответствие переданного делегата сигнатуре, которую ожидает код на уровне системы. Кроме того, в платформах .NET Framework и .NET Core маршалирование структуры, содержащей System.Delegate или System.MulticastDelegate, из собственного представления в управляемый объект может дестабилизировать среду выполнения, если значение поля в собственном представлении не является указателем функции, который упаковывает управляемый делегат. В .NET 5 и более поздних версиях маршалирование поля System.Delegate или System.MulticastDelegate из собственного представления в управляемый объект не поддерживается. Используйте вместо System.Delegate или System.MulticastDelegate конкретный тип делегата.

Фиксированные буферы

Массив, как INT_PTR Reserved1[2], должен быть маршалирован в два поля IntPtr, Reserved1a и Reserved1b. Если собственный массив имеет простой тип, используйте ключевое слово fixed, чтобы записать его более точно. Например, SYSTEM_PROCESS_INFORMATION в исходном заголовке выглядит следующим образом:

typedef struct _SYSTEM_PROCESS_INFORMATION {
    ULONG NextEntryOffset;
    ULONG NumberOfThreads;
    BYTE Reserved1[48];
    UNICODE_STRING ImageName;
...
} SYSTEM_PROCESS_INFORMATION;

В C# можно написать такой код:

internal unsafe struct SYSTEM_PROCESS_INFORMATION
{
    internal uint NextEntryOffset;
    internal uint NumberOfThreads;
    private fixed byte Reserved1[48];
    internal Interop.UNICODE_STRING ImageName;
    ...
}

Но с буферами фиксированного размера возникают некоторые ошибки. Буферы неподлежащих прямой проекции типов не маршалируются правильно, поэтому локальный массив следует развернуть в несколько отдельных полей. Кроме того, в .NET Framework и .NET Core версий, предшествующих 3.0, если структура содержит поле буфера фиксированного размера и вложена в преобразуемую структуру, поле буфера фиксированного размера не маршалируется правильно в машинный код.