Взаимодействие C# и C++ кроссплатформенно
Вам приходилось сталкиваться с необходимостью взаимодействия кода на C# и native-C++ (или скорее С)? Причины могли быть разными: библиотека уже есть, на С/С++ написать проще, разработка частей приложения ведётся разными командами, _______________ (нужное вписать).
Известно, что языки базируются на совершенно разных наборах аксиом.
В С# (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-коде. К сожалению, какого-то способа минимизировать количество вариантов, кроме как кодогенерацией, я не придумал. Но если получится сделать что-то более элегантное, я обязательно напишу.
Я обещал указать ещё на одну проблему этого кода. Она не относится напрямую к обсуждаемой теме, но я всё равно хочу её упомянуть. Но под спойлером.
Чтобы такого не происходило, нужно увеличить время жизни делегата, либо сохранив его как поле в объекте, либо каким-то иным образом, в зависимости от обстоятельств.
А ещё в этом случае следует подумать о том, как unmanaged-поток останавливать.
У меня на сегодня всё. Надеюсь, кому-то это будет полезно.