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

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

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

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

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

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

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

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

Параметр По умолчанию. Рекомендация Сведения
PreserveSig true Сохранить значение по умолчанию Если для параметра явно задано значение false, в результате сбоя запроса HRESULT возвращаются значения, которые вызывают исключения (и в результате возвращаемое значение в месте определения становится пустым).
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].

Руководство по dllImport

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

✔️ Рекомендуется StringBuilder избегать параметров. Во время маршалинга 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. Если возвращаемое значение меньше, чем количество переданных символов, значит, вызов завершился успешно. В таком случае значение — это количество символов без NULL в конце. В противном случае это требуемый размер буфера, включая нуль-символ.

  • Передайте 5, получите 4: строка длиной 4 символов с конечным значением NULL.
  • Передайте 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. Если сигнатура метода содержит ссылочный параметр, поместите ref ключевое слово или [MarshalAs(UnmanagedType.LPStruct)] атрибут в объявление параметра GUID.

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

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

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

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

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

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

  • byte, sbyte, shortushortintuintlongulongsingledouble
  • структуры с фиксированным макетом, которые имеют только типы значений, допускающих перерезку для полей экземпляра
    • чтобы обеспечить фиксированную структуру, необходимо указать [StructLayout(LayoutKind.Sequential)] или [StructLayout(LayoutKind.Explicit)];
    • Структуры по LayoutKind.Sequential умолчанию

Типы с содержимым, в который можно врезать содержимое:

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

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

  • bool

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

  • char

Типы с содержимым blittable:

  • string

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

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

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

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

  • 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*, может быть маршалирован как или IntPtrUIntPtr, но предпочитать 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>() для непреобразуемых структур, чтобы повысить производительность.

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

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

Поскольку System.Delegate и System.MulticastDelegate не имеют требуемых сигнатур, они не гарантируют согласованность переданного делегата с сигнатурой, которую ожидает машинный код. Кроме того, в платформа .NET Framework и .NET Core маршалирование структуры, содержащей System.DelegateSystem.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, если структура содержит поле буфера фиксированного размера и вложена в преобразуемую структуру, поле буфера фиксированного размера не маршалируется правильно в машинный код.