Platform Invocation в .NET Core

от
Прочие языки   c#, c++, platform invocation, pinvoke, net core, clr

В данной статье описываются приёмы вызова нативного кода из управляемых приложений на платформе .NET Core. Основные рассматриваемые в статье языки программирования – C# для .NET и C/C++ для нативного кода.


1. Основные концепции работы среды CLRСреда CLR (Common Language Runtime) – составная часть программных платформ семейства .NET от компании Microsoft, предназначенная для исполнения байт-кода CIL (Common Intermediate Language). Работа в среде CLR вводит ряд абстракций и механизмов, не присущих компилируемым в машинный код языкам, которые описываются в данном разделе.


1.1. СборкиСборки (Assemblies) – абстракция среды CLR, представляющая собой пакет исполняемого кода и/или ресурсов, и обладающая определённым набором метаинформации, такой, как имя сборки, версия сборки, контрольные суммы и т.д. Сборки обычно упаковываются в файлы формата PE (Portal Executable, MIME-тип – application/vnd.microsoft.portable-executable) и имеют расширение *.dll или *.exe. Загружаются в память CLR, полностью или частично, для исполнения содержащегося в них кода или использования ресурсов; одна и та же сборка может использоваться одновременно несколькими приложениями среды CLR для экономии ресурсов. Любое приложение для платформ семейства .NET представляет собой одну или несколько сборок, загружаемых в среду CLR, и исполняемых, начиная с экспортируемой точки входа.
Часть возможностей сборок при работе в нативной среде представляют собой динамические библиотеки (dynamic-link library в Windows и shared objects в *nix) – в они так же хранят исполняемый код, который можно загрузить для последующего исполнения специальными вызовами ядра ОС. Динамические библиотеки, так же, как и сборки в CLR, предоставляют некоторые метаданные о содержащемся в них коде, но в меньшем объёме.
Среда исполнения CLR позволяет использовать для подключения исполняемых модулей как сборки, так и нативные динамические библиотеки, скомпилированные под текущую платформу.


1.2. Исполнение байт-кода CILВиртуальная машина среды CLR имеет достаточно высокую производительность относительно интерпретаторов скриптовых языков за счёт того, что не интерпретирует исходный код, а компилирует промежуточный байт-код CIL (Common Intermediate Language), генерируемый компиляторами конкретных языков программирования (например, C# или Visual Basic). Это намного эффективнее по причине того, что байт-код по своей структуре близок к машинному коду, что повышает скорость его трансляции, избавляя от некоторых сложных этапов трансляции человекочитаемых языков, таких как парсинг и построение синтаксических деревьев, которые были выполнены при компиляции исходного кода в байт-код. Этот же механизм позволяет запускать один и тот исполняемый файл приложения среды .NET вне зависимости от операционной системы компьютера: вызовы API среды .NET транслируются в системные вызовы ОС, на которой запущена виртуальная машина. Также работа с байт-кодом на этапе выполнения даёт приложению возможность работать с метаданными и генерацией кода во времени исполнения. Подробное описание работы с байткодом CIL в актуальных версиях CLR для .NET Core можно найти в [1].
Конечно же, несмотря на высокую скорость трансляции байт-кода в машинный код, этот механизм несколько снижает производительность работы приложений. В некоторых критичных по производительности задачах может быть оправданным применение приложений или модулей, скомпилированных в машинный код.


1.3. Управление памятью и сборка мусораОдним из ключевых механизмов виртуальной машины CLR является система управления памятью, основанная на применении сборщика мусора. Как и нативные приложения, приложения среды CLR имеют доступ к двум видам памяти: стеку и управляемой куче.
Первый вид, стек, представляет собой область памяти, связанную с текущим потоком управления программы: при передаче управления очередному методу вызывающий его код размечает новый кадр сегмента данных памяти процесса для передачи параметров и будущего хранения локальных переменных и переставляет указатель стека на новую верхушку. При выходе из метода указатель стека смещается на кадр назад, помечая данные завершённого метода как неиспользуемые. Следовательно, время жизни объектов и переменных, выделенных в стеке, ограничено областью видимости функции.
Второй вид памяти, которую можно использовать для хранения данных, куча, представляет собой память, запрашиваемую средой CLR или библиотекой управления памяти нативного приложения у операционной системы по мере необходимости. Время жизни данных в этой памяти ограничивается в среде CLR и в нативных приложениях по-разному. Среда CLR использует механизм подсчёта ссылок на объекты, расположенные в куче, и использует сборщик мусора, который эффективным образом организует уничтожение объектов, на которые не осталось активных ссылок. Кроме того, среда CLR может производить перемещение объектов в управляемых ей областях кучи для повышения эффективности работы с учётом фрагментации памяти, кеширования и т.д. Напротив, при реализации нативных приложений организация работы с кучей переходит в ответственность разработчика приложения: стандартные библиотеки C/C++ предлагают функции правления памятью, которые позволяют выделять и высвобождать области кучи по мере необходимости.
Кроме различий в принципах работы с кучей, C++ и CLR-языки придерживаются различных концепций управления памятью при работе с объектами: объекты в C++, вне зависимости от их типа, могут создаваться как в стеке, так и в куче, по решению программиста, таким образом мы можем иметь в C++ два одинаковых объекта, расположенных в разных видах памяти; в CLR-языках же расположение объектов зависит от их типа: экземпляры классов всегда располагаются в куче, а расположением экземпляров структур зависит от различных условий, таких, как их размер, и является деталью реализации CLR – для повышения быстродействия структуры малого размера зачастую располагаются CLR на стеке.


2. Platform Invocation в среде .NET CoreВиртуальная машина CLR, входящая в состав платформ .NET, позволяет загружать нативный код и передавать ему управление, этот механизм носит название Platform Invocation (сокращённо – p/invoke). Конкретные механизмы p/invoke могут несколько различаться в зависимости от конкретной платформы: так, .NET Core поддерживает загрузку нативных библиотек для операционных систем Windows, Linux и MacOS, в том числе для обеспечения работы построенных на них библиотек, работающих в среде .NET Standard и .NET Core, в то время как .NET Framework поддерживает загрузку нативных библиотек Windows (в том числе COM-компонент) и построенных с их использованием библиотек сред .NET Standard и .NET Framework. Кроме того, семейство платформ .NET располагает редакцией Mono, имеющей свои особенности работы с нативным кодом.


2.1. Импорт и экспорт функцийИмпорт и экспорт функций – концепция языка C, поддерживаемая большим количеством языков программирования и программных сред. Эта концепция заключается в предоставлении динамической библиотекой таблиц импорта и экспорта, в которых описываются функции с указанием имён и размещения в коде библиотеки. Библиотека или приложение, декларирующее импорт функции получает возможность вызова экспортируемой функции. Эта концепция применима и для языка C++, но оперирование экспортируемыми методами C++ не кроссплатформенно – при экспорте к именам C++-методов применяет преобразование, «вплетающее» название класса и типы аргументов в имя экспортируемой функции (name mangling), реализация которого не описана в стандарте и зависит от используемого компилятора.
Импорт и экспорт функций поддерживается большинством актуальных компиляторов языков C/C++, но является не частью стандартов этих языков, а их расширениями, поэтому синтаксис может различаться в зависимости от используемого компилятора. Наиболее популярные компиляторы – GCC для *nix и MSVC для Windows используют для пометки функций экспортируемыми конструкцию __declspec(dllexport); для повышения переносимости также можно использовать шаблон описанный в GNU wiki.
Язык C# и платформа .NET Core поддерживают импорт функций путём декларации сигнатуры импортируемой функции (без тела) с применением атрибута DllImport:
  1. [DllImport("dllname", CallingConvention = CallingConvention.Cdecl)]
  2. static ReturnType FunctionName(ArgumentType1 arg);
Следует отметить, что для кроссмплатформенной работы имя библиотеки (в примере – dllname) следует указывать без расширения файла, предоставляя поиск подходящего файла среде исполнения .NET Core.
Загруженная таким образом функция может принимать как аргументы примитивных (int, long, float, double), так и более сложных типов, таких как строки и другие экземпляры объектов. Однако, при передаче сложных объектов в нативный код, среда .NET Core производит процедуру маршалинга переданного объекта, выделяя память под его копию в неуправляемой куче или в стеке вызываемой функции. Срок жизни копии объекта ограничен моментом возврата из функции: таким образом, импортированная функция не должна сохранять себе указатели и ссылки на принятые аргументы (про работу с функциями, которые сохраняют ссылки на свои аргументы см. раздел «Явная работа с памятью»). Маршалинг аргументов функции можно настроить с помощью атрибута System.Runtime.InteropServices.MarshalAsAttribute. Он принимает такие признаки, как нативный тип аргумента (в том числе некоторые нетривиальные типы – null-terminated строки с кодировками ANSI, UTF-8 и UTF-16, и др.), флаг и размер массива, флаг передачи по ссылке и др.
Из соображений переносимости следует принимать во внимание размеры типов данных в используемой библиотеке; так, тип boolean, не являясь стандартным в C, может занимать, в зависимости от компилятора, как 1, так и 4 байта. Также из соображений переносимости атрибут импорта снабжается указанием CallingConvention – соглашения о порядке и способе выставления аргументов и управления стеком при вызове нативного метода. Наиболее универсальным вариантом для работы является конвенция Cdecl, поддерживаемая большинством компиляторов на различных платформах.


2.2. Передача объектовПри передаче объекта в импортированную функцию, объект маршалится (с учётом указаных атрибутов) и данные передаются в функцию в виде структуры языка C. При этом среда CLR не принимает во внимание список аргументов, ожидаемый функцией; таким образом, разработчик должен следить за соответствием структуры своих типов структурам используемой нативной библиотеки. При маршалинге объектов среда CLR не производит проверки типов импортированной функции (по причине того, что C не экспортирует типы аргументов и структуру самих типов).
Структуры, объявляемые в библиотеке, должны помечаться директивой компилятора #pragma pack N, где N – число байт шага упаковки (либо установка значения должна производиться для всей библиотеки сразу флагами компилятора). Соответствующие структуры, предназначенные для маршалинга и передачи в импортированную функцию должны помечаться следующим атрибутом для указания их нативного представления и шага упаковки:
  1. [StructLayout(LayoutKind.Sequential, Pack = N)]
  2. public struct PinvokeStruct { /*...*/ }


2.3. Явная работа с памятьюПри работе с нативными библиотеками вполне может появиться необходимость передачи указателя на объект, и, возможно, его последующего хранения в куче с доступом из нативной библиотеки. Платформа .NET Core располагает возможностями по выделению и высвобождению памяти в куче, отведённой для работы нативного кода. Для этого используется класс System.Runtime.InteropServices.Marshal и его методы AllocHGlobal, FreeHGlobal, PtrToXXX, XXXToPtr и некоторые другие. Например, для передачи в нативную библиотеку ссылки на массив arr типа T нужно исполнить такой код (C#):
  1. IntPtr ptr = Marshal.AllocHGlobal(Marshal.SizeOf<T>() * arr.Length);
  2. Marshal.Copy(arr, arr.Length, ptr);
  3. SavePointerNative(ptr); // <- вызов нативной функции
Созданный объект не находится под контролем сборщика мусора, и после использования его необходимо удалить вручную вызовом Marshal.FreeHGlobal.
Кроме указанного пакета, платформа CLR и поддерживаемые ей языки предлагают ещё один способ работы с памятью – это написание методов, помеченных ключевым словом unsafe: в теле такого метода возможно выделение памяти в неуправляемой сборщиком мусора куче и создание и использование указателей и указателей на указатели, но у данного подхода есть некоторые ограничения: позволяется выделение только объектов структур и классов, не имеющих вложенных полей со ссылками на другие классы; это объясняется необходимостью на запрет использования ссылок на управляемые объекты внутри неуправляемых, что может привести к нарушению целостности данных объекта: так как сам объект не управляется сборщиком мусора и ссылки на другие объекты в нём не считаются, вложенный объект будет удалён, когда будут удалены все ссылки на него в управляемом коде. Подводя итог, написание unsafe-методов позволяет производить операции с памятью на достаточно низком уровне, что иногда позволяет повысить производительность некоторых участков кода, но практически неприменимо при обмене данными с нативными модулями.


2.4. Передача функций обратного вызоваНередки случаи, когда появляется необходимость передать в нативную библиотеку указатель на функцию обратного вызова. Этот механизм реализуется в .NET Core через объявление делегата и последующую его передачу в нативную функцию, например (код на C#):
  1. [DllImport("dllname", CallingConvention = CallingConvention.Cdecl)]
  2. static ReturnType SetCallback(FuncPtr callback);
  3. [UnmanagedFunctionPointer(CallingConvention.StdCall)]
  4. public delegate bool FuncPtr(int value);
  5.  
  6. SetCallback(SomeFunc); // < SomeFunc – функция с FuncPtr-сигнатурой
Так же, как и при объявлении импорта функции, объявление делегата предполагает указание конвенции вызова и поддерживает атрибут MarshalAs для аргументов.
Но у приведённого механизма есть небольшая деталь – если передать в нативную функцию анонимный делегат, например, лямбда-функцию, то после возврата из нативной функции нигде в управляемом CLR коде не останется ссылок на переданный делегат, и он будет собран сборщиком мусора, «ломая» сохранённую ссылку в нативном коде; следовательно, в таких ситуациях делегат нужно явно сохранять в переменную для предотвращения его удаления.


3. Другие применения Platform InvocationМеханизм p/invoke может быть полезен не только для разработки высокопроизводительных нативных модулей для использования в приложениях .NET Core, но и при решении других задач, некоторые из которых кратко описаны в этом разделе.


3.1. Использование сторонних нативных библиотек в .NET Core приложенияхНесмотря на популярность и достаточно широкое распространение .NET Core, существует ряд задач, для решения которых ещё не разработано подключаемых библиотек .NET Standard и .NET Core, но существуют решения в виде библиотек языка C. В таком случае можно использовать такую библиотеку, написав необходимую p/invoke-прослойку для работы в среде .NET Core. Степень кроссплатформенности такого решения будет целиком зависеть от выбранной библиотеки: одни библиотеки на языках C/C++ являются полностью кроссплатформенными, другие могут иметь несколько реализаций для разных операционных систем и общее API.
Известным в мире .NET Core примером такого применения механизмов p/invoke является открытая кроссплатформенная библиотека Avalonia, предоставляющая возможности создания пользовательского графического интерфейса для .NET Core, .NET Framework и Mono приложений: библиотека имеет WPF-подобное API, внутренняя реализация которая оборачивает нативные библиотеки графических окружений различных ОС с применением p/invoke.


3.2. Реализация платформозависимых функций в .NET Core приложенияхНекоторые .NET Core приложения могут требовать использования платформозависимых API и возможностей, которые не входят в .NET Core API, но предоставляются в том или ином виде и объёме целевыми системами. Например, в .NET Core API работы с файловой системой нет функций по работе со ссылками файловой системы, несмотря на то, что и Windows, и *nix-системы такие возможности предоставляют. Выходом может стать написание небольшой обёртки над платформозависимым API и включение его в состав .NET Core приложения с использованием средств p/invoke. Кроме того, такие функции могут быть реализованы в виде необязательной части приложения и быть доступными только на поддерживающих их платформах.


Литература
1. Джепикс Ф., Язык CIL и роль динамических сборок – Джепикс Ф, Troelsen : Pro C# 7 With .NET and .NET Core. – Apress, 2017., 978-1-4842-3018-3
+6   6   0
477