Известно, что языки базируются на совершенно разных наборах аксиом.
В С# (CLR, если точнее) вы имеете дело с типами фиксированных размеров (за редкими оговорками), код может быть скомпилирован JIT-компилятором под любую из поддерживаемых целевых платформ (если явно не оговорено иное).
В мире C++ всё совсем иначе: одни и те же типы могут иметь разные размеры при компиляции на разные платформы (привет, size_t), код генерируется по-разному для разных платформ, операционных систем и прочих прелестей.
Под катом будем пробовать их подружить с учётом указанных особенностей.
Для взаимодействия управляемого (managed) с неуправляемым (native, unmanaged) кода, при котором в managed-приложение подключаются unmanaged-библиотеки, существует механизм Platform Invoke (p/Invoke). Такое взаимодействие классифицируется как внутрипроцессное.
Оно имеет следующие ограничения:
- Возможно вызвать только unmanaged-функции, но нельзя обратиться к экспортируемым переменным;
- Импортируемые функции становятся статическими методами классов;
- Импортируемые функции объявляются как extern и маркируются специальным атрибутом DllImport, который указывает компилятору на необходимость генерации специального кода маршализации вызовов;
- В процессе вызова unmanaged-кода поток, который его выполняет, не может быть прерван, в отличие от кода на C#. Так, если на нём вызвать Abort или Interrupt, то подъём исключений будет отложен до возвращения в управляемый контекст;
Список, конечно, неполный, но даёт представление о том, что происходит.
Мы не будем рассматривать все аспекты работы с p/Invoke, а сосредоточимся только на том, как для p/Invoke решить проблему вызова на разных архитектурах (на примере x86 и x64), и не будем касаться других архитектур и операционных систем, однако того, что будет описано в статье, теоретически достаточно чтобы развить мысль дальше. Будем считать это домашним заданием для тех, кому это нужно.
Итак, давайте раскручивать клубок.
Нам нужно импортировать некоторый набор функций из unmanaged-библиотеки на C++ для вызова их из кода на C#, при этом поддерживать нужно одновременно две архитектуры: x86 и x64, выбирая их в зависимости от того, на какой из платформ работает хост-приложение на C#.
Я использую MS Visual Studio 2015 Community Edition для примера, но всё должно работать и при разработке с использованием других средств. CMake и прочими прелестями (пока) не заморачиваемся.
Исходный код с процессом эволюции доступен на гитхабе по ссылке.
После создания решения с двумя проектам (CrossPlatformInterop типа Console Application на C# и CrossPlatformLibrary типа Win32 Project / DLL) сконфигурируем их так, чтобы выходной каталог был $(SolutionDir)Output\$(Configuration)\, а для C++-проекта имя собираемого файла — $(ProjectName)-$(PlatformShortName).dll для того, чтобы на x86 и x64 получались разные файлы.
Результаты конфигурации можно посмотреть в ветке project-setup в репозитории.
Реализуем простенькую функцию на С++, которая принимает 2 числа и имитирует бурную деятельность в виде форматирования какой-то строки и передачи её в managed-код через функцию обратного вызова:
// header
typedef void(__stdcall* Notification)(const char*);
int32_t CROSSPLATFORMLIBRARY_API __stdcall ProcessData(int32_t start, int32_t count, Notification notification);
// source
int32_t __stdcall ProcessData(int32_t start, int32_t count, Notification notification)
{
if (notification == nullptr)
{
return 0;
}
int32_t result = 0;
for (int32_t i = 0; i < count; ++i)
{
char buffer[64];
result += sprintf_s(buffer, "Notification %d from C++", i + start);
notification(buffer);
Sleep(rand() % 500 + 500);
}
return result;
}
Обратите внимание, что здесь явно указаны размеры типов данных и конвенции вызовов. Поскольку мы взаимодействуем с другим языком, нам приходится это знать, и правила написания портируемого кода на С++ здесь не работают. Зато, в отличие от типов вроде size_t, мы всегда знаем, какому типу на C# фиксированного размера он соответствует.
Здесь есть одна тонкость: указатель, который в C++ выглядит как void* или T*, имеет разный размер для разных платформ, но при этом со стороны C# он транслируется в специальный тип IntPtr, который также имеет переменный размер. Так что с маршалингом указателей нам помогает сам компилятор.
Когда компилятор оперирует именами, он их преобразует, кодируя в них типы объектов, аргументов, возвращаемых значений, конвенции вызова и много чего ещё. Эта операция называется декорированием (decoration, mangling). Так, имя функции компилятором от Microsoft преобразуется к виду ?ProcessData@@YGHHHP6GXPBD@Z@Z или ?ProcessData@@YAHHHP6AXPEBD@Z@Z (найдите одно отличие — оно зависит от размера указателя). Вы ведь видели что-то подобное, когда ругался линковщик в С++-проектах?
Работать с такими именами неудобно, поэтому мы попросим компилятор во внешнем программном интерфейсе привести их к более читаемому виду, добавив в объявление функции
extern "C"
. Если использовать конвенцию вызова __cdecl, то вопросов нет, но если использовать __stdcall, то имя всё равно не станет «нормальным», а будет иметь вид _ProcessData@12 для x86 (после собаки указано количество занятых на стеке байтов). Можно, конечно, сделать def-файл в проекте и указать там список функций для экспорта, но мы так не будем делать.Будем работать с __stdcall, потому как в Windows принято использовать эту конвенцию при работе с библиотеками.
Дальше для того, чтобы импортировать эту функцию, достаточно было бы написать следующий код:
public class LibraryImport
{
[UnmanagedFunctionPointer(CallingConvention.StdCall, CharSet=CharSet.Ansi)]
public delegate void Notification(string value);
[DllImport("CrossPlatformLibrary-x86", CallingConvention=CallingConvention.StdCall)]
public static extern int ProcessData(int start, int count, Notification notification);
}
Использование могло бы выглядеть как:
LibraryImport.ProcessData(1, 10, s => Console.WriteLine(s));
Но если у нас код выполняется в 64-битной среде, то при загрузке класса будет поднято исключение BadImageFormatException, то есть попытка загрузить образ библиотеки несовместимого формата. Надеюсь, пояснять, почему образы несовместимы, не нужно. При импорте 64-битной библиотеки из 32-битной среды будет та же проблема.
Конечно, можно было бы сказать, что мы стремительно завершаем второе десятилетие XXI века, и пора хоронить 32-битные системы, но я бы не стал торопиться хотя бы потому, что у меня есть планшет на винде с 32-битной системой, а ещё есть старый парк железа на работе, где тоже 32-битки вертятся. И вообще, подход будем справедлив и переходе на другие архитектуры процессоров (мы же доживём до того счастливого момента, когда ARM-ы и прочие Байкалы будут поддерживаться в дотнере полном объёме?).
В этом коде есть ещё одна проблема, но мы её разберём позже.
Теперь давайте займёмся полноценным импортом. Возьмём на заметку тот факт, что загрузка типов в .NET ленивая, то есть пока какой-то класс не понадобится среде выполнения, он не будет разобран и скомпилирован. То есть если мы не обращаемся к типу, там может быть импорт некорректной библиотеки.
Первое, что нужно сделать — это спрятать импортируемые методы от постороннего взгляда. Вообще, отдавать что-то из внутренней кухни, слишком торчащее наружу — плохо. Импортируемые методы будем делать приватными, а наружу предоставим методы-обёртки. А список методов вынесем в интерфейс.
[UnmanagedFunctionPointer(CallingConvention.StdCall, CharSet = CharSet.Ansi)]
public delegate void Notification(string value);
public interface ILibraryImport
{
int ProcessData(int start, int count, Notification notification);
}
internal class LibraryImport_x86 : ILibraryImport
{
[DllImport("CrossPlatformLibrary-x86", CallingConvention = CallingConvention.StdCall, ExactSpelling = false, EntryPoint = "_ProcessData@12")]
private static extern int ProcessDataInternal(int start, int count, Notification notification);
public int ProcessData(int start, int count, Notification notification)
{
return ProcessDataInternal(start, count, notification);
}
}
internal class LibraryImport_x64 : ILibraryImport
{
[DllImport("CrossPlatformLibrary-x64", CallingConvention = CallingConvention.StdCall, ExactSpelling = false, EntryPoint = "ProcessData")]
private static extern int ProcessDataInternal(int start, int count, Notification notification);
public int ProcessData(int start, int count, Notification notification)
{
return ProcessDataInternal(start, count, notification);
}
}
Обратите внимание, что классы объявлены как внутренние, а интерфейс — публичным. Зря я, конечно, не сделал библиотеку-обёртку отдельно от приложения, ну да ладно: идея должна быть понятной.
Дальше нужно сделать загрузчик, который отдаст экземпляр нужного класса в зависимости от битности текущей среды выполнения.
public static class LibraryImport
{
public static ILibraryImport Select()
{
if (IntPtr.Size == 4) // 32-bit application
{
return new LibraryImport_x86();
}
else // 64-bit application
{
return new LibraryImport_x64();
}
}
}
Здесь сделано предположение о том, что у нас выбор всего из двух вариантов. В общем случае, именно здесь принимается решение о том, какие классы использовать для какой реализации. А ещё здесь же можно делать много других странных вещей.
Использование уже достаточно простое:
class Program
{
static void Main(string[] args)
{
ILibraryImport import = LibraryImport.Select();
import.ProcessData(1, 10, s => Console.WriteLine(s));
}
}
Предложенное решение имеет большой недостаток: достаточно большой объём дублирующегося стереотипного кода: сигнатура каждой импортируемой функции присутствует в пяти местах, и используется тремя разными способами. И ещё пара мест в native-коде. К сожалению, какого-то способа минимизировать количество вариантов, кроме как кодогенерацией, я не придумал. Но если получится сделать что-то более элегантное, я обязательно напишу.
Я обещал указать ещё на одну проблему этого кода. Она не относится напрямую к обсуждаемой теме, но я всё равно хочу её упомянуть. Но под спойлером.
<spoiler title=«Проблема висящих указателей>Предположим, что вызов у нас асинхронный, например, при нажатии на кнопку у нас будет выполняться в фоне некоторый код, который генерирует протокол работы и ещё что-нибудь полезное, но обработчик кнопки завершил работу, и, следовательно, все локальные объекты могут быть собраны сборщиком мусора. А у нас есть такой объект и очень важный: делегат, инкапсулирующий функцию обратного вызова. Через произвольный промежуток времени код просто упадёт с непонятной ошибкой обращения либо к нулевому указателю, либо, что ещё хуже, к произвольной области памяти. А всё потому, что указатель на функцию в unmanaged-коде ещё живой, а делегат, на который он ссылается, уже нет, скорее всего, его память очищена и теперь у нас есть висящий указатель.
Чтобы такого не происходило, нужно увеличить время жизни делегата, либо сохранив его как поле в объекте, либо каким-то иным образом, в зависимости от обстоятельств.
А ещё в этом случае следует подумать о том, как unmanaged-поток останавливать.
У меня на сегодня всё. Надеюсь, кому-то это будет полезно.
Комментарии (27)
kekekeks
23.01.2018 16:23И вообще, подход будем справедлив и переходе на другие архитектуры процессоров (мы же доживём до того счастливого момента, когда ARM-ы и прочие Байкалы будут поддерживаться в дотнере полном объёме?).
Не будет, сейчас принято писать имя библиотеки как есть и класть в nuget-пакет версии оной под поддерживаемые платформы. В дальнейшем загрузчик CoreCLR и
dotnet publish
сами разбираются, какую версию использовать.
Если так хочется это разруливать в рантайме — дёргайте вручную
LoadLibrary
/GetProcAddress
/dlopen
/dlsym
иMarshal.GetDelegateFromFunctionPointer
вместо ваших тонн копипасты.a-tk Автор
23.01.2018 16:26Осталось дождаться только, когда на них можно будет создавать полноценные UI для desktop-приложений.
a-tk Автор
23.01.2018 16:28Если так хочется это разруливать в рантайме — дёргайте вручную LoadLibrary/GetProcAddress/dlopen/dlsym и Marshal.GetDelegateFromFunctionPointer вместо ваших тонн копипасты.
Копипаста появится в другом месте, и читабельности явно не добавится.kekekeks
23.01.2018 16:30https://github.com/AvaloniaUI/Avalonia/blob/master/src/Gtk/Avalonia.Gtk3/Interop/Native.cs#L23 — покажите мне тут прибавившуюся копипасту.
a-tk Автор
23.01.2018 16:36Разве я что-то говорил про копипасту в коллбэках?
kekekeks
23.01.2018 16:37А вы где-то видите колбеки?
a-tk Автор
23.01.2018 16:40Тупанул.
Загрузка полей класса Native через рефлексию идёт?kekekeks
23.01.2018 16:42https://github.com/AvaloniaUI/Avalonia/blob/master/src/Gtk/Avalonia.Gtk3/Interop/Resolver.cs#L123
https://github.com/AvaloniaUI/Avalonia/blob/master/src/Gtk/Avalonia.Gtk3/Interop/DynLoader.cs
Всё под MIT-лицензией, можно забирать и использовать.
kekekeks
23.01.2018 16:29Чтобы такого не происходило, нужно увеличить время жизни делегата, либо сохранив его как поле в объекте, либо каким-то иным образом, в зависимости от обстоятельств.
Чтобы такого не происходило, в сишных библиотеках используется паттерн вида:
typedef void(* callback_func)(void*); void foo(callback_func* func, void* user_data)
При использовании оных из C# callback_func является статической функцией, делегат к которой аллоцируется в единственном экземпляре. В user_data в дальнейшем передаётся свежеаллоцированый GCHandle, который в этой статической функции разименуется. Это даёт возможность чёткого контроля за временем жизни объекта в нативной среде.
a-tk Автор
23.01.2018 16:33В целом — согласен, но представим себе, что библиотека уже существует и ковырять её дорого и/или геморройно.
Flaksirus
23.01.2018 18:36Я на дотнет коре похожую проблему решал:
вот проектик github.com/flaksirus/CrossPlatformLibraryLoader
huhen
24.01.2018 09:31Чтобы такого не происходило, нужно увеличить время жизни делегата, либо сохранив его как поле в объекте, либо каким-то иным образом, в зависимости от обстоятельств.
Мне попадалась информация, что сохранение как поле в объекте не гарантирует что он(делегат) не будет перемещен в памяти.
Я делал так:
private GCHandle gch_ppp_output; internal void SetupCallbacksPPP(ppp_output_d ppp_output) { CleanUpPPP(); gch_ppp_output = GCHandle.Alloc(ppp_output); var p_ppp_output = Marshal.GetFunctionPointerForDelegate(ppp_output); //здесь можно передать нативной функции значение p_ppp_output для callback } //не забываем освободить internal void CleanUpPPP() { if (gch_ppp_output != null && gch_ppp_output.IsAllocated) gch_ppp_output.Free(); }
mayorovp
24.01.2018 10:30Простой
GCHandle.Alloc
тоже не защищает от перемещения в памяти; его имеет смысл использовать только если вы полностью передаете владение управляемым объектом неуправляемому коду. В противном случае он ничем не лучше статического поля.
Но в данном случае это не требуется. Ведь перемещается же делегат в управляемой куче — а исполнение кода в ней запрещено. Следовательно, при маршалинге для делегата генерируется код-заглушка в совершенно другой области памяти, где нет никакого сборщика мусора и перемещения объектов. Все что требуется от управляемого кода — удержать в памяти сам делегат достаточно долго.
anikavoi
24.01.2018 20:42Где ж ты в декабре был!
Я чуть не рехнулся, пока с++ dll прикрутил к C#!
На самом деле, после разборок с вызовом функции из dll наступает неприятность с передачей структур. Вот об этом еще напиши.mayorovp
24.01.2018 21:00А что не так с передачей структур? Там самое нудное — объявлять одинаковые структуры в двух языках, но не сказал бы что это прямо уж так сложно...
Передаются структуры как
ref
илиout
, можно еще какIntPtr
передавать (с ручным копированием).a-tk Автор
25.01.2018 16:08Со структурами проблем нет, надо маппить правильно и не забывать обвешивать атрибутом StructLayout со стороны .NET-а, а ещё учитывать выравнивание (или бороться с ним) со стороны Си.
kekekeks
Для
size_t
используйте в шарповых декларацияхIntPtr
. Он соответствует размеру указателя на целевой платформе.mayorovp
Лучше
UIntPtr
. АIntPtr
подойдет дляptrdiff_t
илиintptr_t
.