Интероперабельность с нативным кодом через платформу .NET

e667591087c6046c4d51f31d4209dcc4.png

Привет, Хабр!

Часто некоторые проекты требуют от нас все более новых подходов к решению задач. Одна из таких задач — эффективное взаимодействие управляемого кода .NET с нативным кодом, которое позволяет по максимуму использовать ресурсы ОС и другого ПО, написанного не на .NET.

Интероперабельность необходима для использования уже существующих библиотек, написанных на C, C++ или других языках, которые выполняют важные или высокопроизводительные функции. Таким образом открывается возможность интеграции .NET-приложений с различными системными компонентами и устройствами, доступ к которым возможен только через нативные API.

В основе интероперабельности лежит взаимодействие управляемого кода. Управляемый код исполняется под управлением CLR — виртуальной машины .NET, которая обеспечивает такие возможности, как сборка мусора, безопасность типов и другие виды абстракции. А вот нативный код компилируется напрямую в машинный код, специфичный для конкретной платформы, и исполняется ОС без промежуточных слоев, что обеспечивает высокую производительность и прямой доступ к ресурсам системы…

Основные техники вызова нативных функций

Platform Invoke (P/Invoke)

Platform Invoke — это механизм, который позволяет коду на .NET вызывать функции, находящиеся в нативных библиотеках DLL. Его юзают для использования уже существующих библиотек на C/C++, не переписывая их на .NET. Для реализации P/Invoke необходимо использовать пространства имен System и System.Runtime.InteropServices.

Для вызова нативной функции через P/Invoke, необходимо:

  1. Импортировать функцию с помощью атрибута DllImport, который указывает, в какой DLL находится функция. Этот атрибут также позволяет задать способ кодирования символов (CharSet), а также другие параметры вызова.

  2. Определить сигнатуру функции в соответствии с её нативным объявлением.

Пример использования P/Invoke для вызова функции MessageBox из библиотеки user32.dll, которая отображает окно сообщения:

using System;
using System.Runtime.InteropServices;

public class Win32 {
    [DllImport("user32.dll", CharSet=CharSet.Auto)]
    public static extern IntPtr MessageBox(int hWnd, String text, String caption, uint type);
}

public class HelloWorld {
    public static void Main() {
        Win32.MessageBox(0, "Hello World", "Platform Invoke Sample", 0);
    }
}

MessageBox вызывается с параметрами, которые указывают, что окно сообщения не имеет родительского окна и содержит текст «Hello World» с заголовком «Platform Invoke Sample».

P/Invoke автоматически обрабатывает множество стандартных типов данных, , но для корректной работы с ними важно правильно объявить параметры функции. Для строк и структур, которые требуют особого внимания при передаче между управляемым и нативным кодом, юзать спец. атрибуты маршалинга данных.

Несмотря на свою полезность, P/Invoke имеет ряд ограничений и потенциальных проблем:

  • Необходимость точного соответствия типов данных и сигнатур функций.

  • Возможные ошибки во время выполнения из-за несоответствия данных или неправильного использования ресурсов.

C++/CLI

C++/CLI — это расширение C++, предназначенное для создания и управления управляемым кодом в рамках .NET Framework и .NET Core. Оно позволяет объединять управляемый и нативный код в одном приложении, облегчая работу с существующими C++ библиотеками и .NET сборками.

Для начала работы с C++/CLI надо установить компонент поддержки C++/CLI через установщик Visual Studio. После установки Visual Studio и выбора рабочей нагрузки C++, компонент C++/CLI можно добавить через меню Modify в установщике Visual Studio. Для Visual Studio 2019 и новее выбирается поддержка для инструментов сборки v142 и выше.

Проекты C++/CLI можно создать, используя шаблоны проектов Visual Studio. Например, можно создать CLR Console App, который уже включает в себя необходимые ссылки на сборки и файлы исходного кода. Это самый идеальный способ для начинающих изучать программирование без необходимости разбираться с графическим интерфейсом пользователя.

В C++/CLI создаются управляемые классы с ключевым словом ref class, что позволяет классам быть частью управляемой кучи .NET. Такие классы могут содержать как управляемые, так и нативные типы данных, что делает C++/CLI мощным инструментом для межъязыковой интеграции.

Пример создания обёртки для нативного класса на C++/CLI:

#pragma once
using namespace System;

namespace CLI {
    template
    public ref class ManagedObject {
    protected:
        T* m_Instance;

    public:
        ManagedObject(T* instance) : m_Instance(instance) { }

        virtual ~ManagedObject() {
            if (m_Instance != nullptr) {
                delete m_Instance;
            }
        }

        !ManagedObject() {
            if (m_Instance != nullptr) {
                delete m_Instance;
            }
        }

        T* GetInstance() {
            return m_Instance;
        }
    };
}

C++/CLI позволяет использовать Windows Forms и WPF для создания графического интерфейса пользователя в приложениях .NET. Для этого необходимо добавить в проект файл .vcxproj ссылки на соответствующие библиотеки Windows Desktop, такие как Microsoft.WindowsDesktop.App.WindowsForms или Microsoft.WindowsDesktop.App.WPF.

Управление памятью при взаимодействии с нативным кодом в .NET

SafeHandle

SafeHandle — это абстрактный базовый класс в пространстве имен System.Runtime.InteropServices, который предоставляет обёртку для работы с ресурсами операционной системы, такими как файлы, реестр или сетевые соединения. Основная цель SafeHandle — обеспечить безопасное управление такими ресурсами, автоматом закрывая их, когда они больше не нужны.

Пример класса, который использует SafeHandle для безопасной работы с файлами:

using System;
using System.Runtime.InteropServices;
using Microsoft.Win32.SafeHandles;
using System.IO;

public class MyFileReader : IDisposable
{
    private MySafeFileHandle _handle;

    public MyFileReader(string fileName)
    {
        _handle = NativeMethods.CreateFile(
            fileName,
            FileAccess.Read,
            FileShare.Read,
            IntPtr.Zero,
            FileMode.Open,
            0,
            IntPtr.Zero
        );

        if (_handle.IsInvalid)
            throw new System.ComponentModel.Win32Exception();
    }

    public void Dispose()
    {
        _handle.Dispose();
    }
}

internal static class NativeMethods
{
    [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)]
    public static extern MySafeFileHandle CreateFile(
        string fileName,
        FileAccess access,
        FileShare shareMode,
        IntPtr securityAttributes,
        FileMode creationDisposition,
        int flagsAndAttributes,
        IntPtr templateFile
    );
}

public class MySafeFileHandle : SafeHandleZeroOrMinusOneIsInvalid
{
    public MySafeFileHandle() : base(true) {}

    protected override bool ReleaseHandle()
    {
        return CloseHandle(handle);
    }

    [DllImport("kernel32.dll")]
    private static extern bool CloseHandle(IntPtr handle);
}

MySafeFileHandle — производный класс от SafeHandle, который определяет как освобождать ресурс. В конструкторе MyFileReader создается файловый хэндл, который затем управляется SafeHandle, обеспечивая его корректное закрытие.

GCHandle

GCHandle предоставляет способ управления управляемыми объектами из нативного кода. В основном его юзают для предотвращения сборки мусора объекта, пока он используется в нативном коде, а также позволяет «закрепить» объект в памяти, чтобы предотвратить его перемещение сборщиком мусора.

Пример использования GCHandle для закрепления объекта и предотвращения его перемещения в памяти:

using System;
using System.Runtime.InteropServices;

public class App
{
    public static void Main()
    {
        Run();
    }

    public static void Run()
    {
        byte[] data = new byte[1024];
        GCHandle handle = GCHandle.Alloc(data, GCHandleType.Pinned);
        try
        {
            IntPtr ptr = handle.AddrOfPinnedObject();
            // Передача ptr в нативный метод
        }
        finally
        {
            if (handle.IsAllocated)
                handle.Free();
        }
    }
}

Массив data закрепляется в памяти с помощью GCHandle.Alloc. Это гарантирует, что массив не будет перемещен или удален сборщиком мусора, пока на него ссылается нативный код. Обратите внимание на использование блока try/finally для обеспечения освобождения GCHandle после завершения работы с ним, что предотвращает утечки памяти.

Blittable и Non-Blittable типы

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

Примеры blittable типов включают примитивы, такие как int, double, float, а также System.IntPtr и System.UIntPtr. Также blittable считаются одномерные массивы этих примитивных типов.

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

К non-blittable типам относятся string, bool, char, а также все классы, объектные типы и делегаты. Например, тип System.String в управляемом коде представляет строку в формате Unicode, и его необходимо преобразовать в ANSI или другой формат строки в неуправляемом коде перед передачей.

Для управления поведением маршалинга non-blittable типов можно использовать атрибуты, такие как MarshalAs, чтобы указать, как должен быть преобразован тип данных при передаче. Например, для строк можно указать, должны ли они быть маршалированы как LPStr (ANSI), LPWStr (Unicode), или BStr (используется в COM).

Поработаем с Blittable и Non-Blittable типами в .NET:

using System;
using System.Runtime.InteropServices;

class Program
{
    // Blittable структура
    [StructLayout(LayoutKind.Sequential)]
    struct Point
    {
        public int x;
        public int y;
    }

    // Non-Blittable класс
    class Data
    {
        public int value;
        public string message;
    }

    // метод для маршалинга Blittable структуры
    static void MarshalBlittable()
    {
        Point point = new Point { x = 10, y = 20 };
        IntPtr ptr = Marshal.AllocHGlobal(Marshal.SizeOf());
        Marshal.StructureToPtr(point, ptr, false);

        // для демонстрации выводим координаты из неуправляемой памяти
        Point marshaledPoint = Marshal.PtrToStructure(ptr);
        Console.WriteLine($"Marshalled Point: ({marshaledPoint.x}, {marshaledPoint.y})");

        Marshal.FreeHGlobal(ptr);
    }

    // метод для маршалинга Non-Blittable класса
    static void MarshalNonBlittable()
    {
        Data data = new Data { value = 42, message = "Hello, World!" };
        IntPtr ptr = Marshal.AllocHGlobal(Marshal.SizeOf());

        // код маршалинга Non-Blittable класса будет сложнее,
        // поскольку требуется маршалинг для каждого его поля
        Marshal.WriteInt32(ptr, data.value);
        IntPtr messagePtr = Marshal.StringToHGlobalAnsi(data.message);
        Marshal.WriteIntPtr(ptr + Marshal.OffsetOf("message").ToInt32(), messagePtr);

        // для демонстрации выводим данные из неуправляемой памяти
        int value = Marshal.ReadInt32(ptr);
        string message = Marshal.PtrToStringAnsi(Marshal.ReadIntPtr(ptr + Marshal.OffsetOf("message").ToInt32()));
        Console.WriteLine($"Marshalled Data: value = {value}, message = \"{message}\"");

        Marshal.FreeHGlobal(ptr);
        Marshal.FreeHGlobal(messagePtr);
    }

    static void Main()
    {
        Console.WriteLine("Marshalling Blittable Types:");
        MarshalBlittable();
        Console.WriteLine();

        Console.WriteLine("Marshalling Non-Blittable Types:");
        MarshalNonBlittable();
    }
}

Создали Blittable структуру Point и Non-Blittable класс Data, а затем демонстрируем маршалинг этих типов в неуправляемой памяти с помощью методов Marshal.StructureToPtr и Marshal.WriteInt32, соответственно. Также показано, как освобождать выделенную память с помощью Marshal.FreeHGlobal.

Мы рассмотрели различные техники вызова нативных функций, такие как P/Invoke и C++/CLI, а также методы управления памятью. Прогресс не стоит на месте, и будущие версии .NET могут предложить ещё более усовершенствованные средства для работы с нативным кодом.

Напоследок хочу порекомендовать вам бесплатные уроки курса C# Developer. Регистрация доступна по ссылкам ниже:

© Habrahabr.ru