Привет, Хабр!
Часто некоторые проекты требуют от нас все более новых подходов к решению задач. Одна из таких задач - эффективное взаимодействие управляемого кода .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, необходимо:
Импортировать функцию с помощью атрибута
DllImport
, который указывает, в какой DLL находится функция. Этот атрибут также позволяет задать способ кодирования символов (CharSet), а также другие параметры вызова.Определить сигнатуру функции в соответствии с её нативным объявлением.
Пример использования 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<class T>
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<Point>());
Marshal.StructureToPtr(point, ptr, false);
// для демонстрации выводим координаты из неуправляемой памяти
Point marshaledPoint = Marshal.PtrToStructure<Point>(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<Data>());
// код маршалинга Non-Blittable класса будет сложнее,
// поскольку требуется маршалинг для каждого его поля
Marshal.WriteInt32(ptr, data.value);
IntPtr messagePtr = Marshal.StringToHGlobalAnsi(data.message);
Marshal.WriteIntPtr(ptr + Marshal.OffsetOf<Data>("message").ToInt32(), messagePtr);
// для демонстрации выводим данные из неуправляемой памяти
int value = Marshal.ReadInt32(ptr);
string message = Marshal.PtrToStringAnsi(Marshal.ReadIntPtr(ptr + Marshal.OffsetOf<Data>("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. Регистрация доступна по ссылкам ниже: