
Дорогие гости, прошу, присаживайтесь. Сегодня в меню: DllImport по-домашнему, рагу из C# и C++, запечёные в NuGet-пакете x64 и ARM. Также будет предложена закуска в виде истории о безумствах ИИ. Bon appetit!
.NET прекрасен. К сожалению, не все функции операционной системы представлены в стандартной библиотеке классов. А временами без них не обойтись. И разумеется, у .NET есть, что предложить на этот счёт.
Тема довольно обширная, и крохотный её кусочек я затрагивал в своей первой статье на Хабре в 2021 году. Прямо сейчас я занимаюсь следующим релизом своей библиотеки DryWetMIDI, и работа над одним из нововведений — поддержкой Windows на ARM-процессорах — заставила меня пересмотреть ряд моментов. Кроме того, прошлая статья была сосредоточена в основном на NuGet-пакете.
Здесь я хочу собрать все детали пазла воедино и показать картину целиком: как обращаться к нативным функциям, как делать это кроссплатформенно, как упаковать все необходимые файлы в NuGet-пакет и многое другое. Статья будет большой инструкцией, нежели путевыми заметками, коими был прошлый текст.
Оглавление
Терминология
Перед тем, как начать разбирать тему, расскажу про используемые в статье термины. Так текст будет избавлен от ненужных уточнений и сложных оборотов.
В англоязычном мире .NET есть понятия managed code и unmanaged code. Первое — это код под надзором CLR (программа на C#, например). Второе, наоборот, — код за пределами рантайма .NET. Кого-то прямой перевод этих терминов на русский не смущает, но для меня фраза “неуправляемый код” звучит как нечто, вышедшее из-под контроля, так что использовать я её не буду.
Вместо managed code в статье будет что-то вроде “код на C#” или “.NET-код”. Заменой для unmanaged станет слово нативный (native), иногда внешний. Вместо CLR часто будет написано просто рантайм (runtime, среда исполнения). .NET иногда встретится в виде слова фреймворк. Ещё будут фигурировать нативные бинарники — файлы dll, dylib, so и т.д.
Также прошу понять и простить за коллбэк (callback) вместо функции обратного вызова, хэндл (handle) и прочие (редкие) англицизмы.
Переходим к основным блюдам.
DllImport: первые шаги
Так как моя библиотека предназначена для работы с MIDI, то и примеры будут из этой области. Допустим, мы хотим узнать количество входных MIDI-устройств (MIDI-клавиатура, синтезатор, MIDI-контроллер и т.д.), подключённых в данный момент к компьютеру. В Windows есть функция midiInGetNumDevs. Добраться до неё в C# несложно:
[DllImport("winmm.dll")] static extern uint midiInGetNumDevs();
Уверен, данная конструкция многим знакома. Через атрибут DllImport мы говорим: в системной библиотеке winmm.dll есть функция midiInGetNumDevs, хотим использовать её в C#. Например, вот так:
Console.WriteLine($”Input MIDI devices count: {midiInGetNumDevs()}”);
По умолчанию .NET будет искать функцию с тем именем, которое мы указали. Но можно сделать иначе:
[DllImport("winmm.dll", EntryPoint = “midiInGetNumDevs”)] static extern uint GetCount();
Т.е. у себя в коде мы будем использовать имя GetCount, но .NET при этом будет вызывать функцию midiInGetNumDevs из winmm.dll.
Выполнение внешнего кода в .NET описывается словами P/Invoke (Platform Invoke) и маршалинг (marshalling). Первое — это сам механизм вызова нативного кода, атрибуты DllImport и более новый LibraryImport (см. далее в статье), и собственно объявление внешних функций. Главная сложность с P/Invoke это правильный перевод сигнатуры нативной функции в понятную .NET.
Издревле для этого существовал сайт pinvoke.net. Например, нужна вам функция CreateProcess из kernel32.dll. Вы смотрите документацию и некоторое время пребываете в лёгком шоке от количества и непонятности параметров. Придя в себя, вбиваете на сайте в строке поиска имя функции и нажимаете Enter. Открывается страница, где показано, что вы должны написать на C#:
[DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Auto)] static extern bool CreateProcess( string lpApplicationName, string lpCommandLine, ref SECURITY_ATTRIBUTES lpProcessAttributes, ref SECURITY_ATTRIBUTES lpThreadAttributes, bool bInheritHandles, uint dwCreationFlags, IntPtr lpEnvironment, string lpCurrentDirectory, [In] ref STARTUPINFO lpStartupInfo, out PROCESS_INFORMATION lpProcessInformation);
Но это не всё, потребуется ещё объявить несколько структур (SECURITY_ATTRIBUTES, STARTUPINFO, PROCESS_INFORMATION). Завершив этот квест, можно будет, наконец, запустить новый процесс в Windows (а потом обнаружить, что в .NET есть метод Process.Start).
Моё мнение: в наши дни pinvoke.net утратил былую значимость (кстати, вы им ещё пользуетесь?). Задача подготовки декларации для DllImport типовая, и материалов касательно P/Invoke триллионы гигабайтов. К чему это я? Да к тому, что это тот случай, когда можно довериться ИИ-ассистентам.
Поучительная история про искусственный интеллект
Всё чаще в Интернете, в частности, здесь на Хабре появляются статьи в духе “Как я создал космический корабль за 10 минут благодаря ИИ”. И хотя стоит признать, что временами такие ассистенты действительно творят магию (иначе и не назовёшь), принимать их решения на веру и сразу же использовать сгенерированный код — затея рискованная. У меня есть отличный тому пример.
Сразу же извиняюсь, что обрушу на вас слишком много MIDI API, но в противном случае история будет менее понятной.
Для того, чтобы принимать некоторые MIDI-события (system exclusive, sysex), необходимо добавить в драйвер устройства буфер через функцию midiInAddBuffer. Кроме того, буфер нужно подготовить с помощью midiInPrepareHeader.
После того, как MIDI-устройство получит и обработает такое событие, в коллбэк, который мы устанавливаем в начале работы с устройством (midiInOpen), прилетит сообщение об этом. Теперь можно извлечь данные и что-то с ними сделать. При этом буфер из драйвера изымается. Недолго думая, я решил что в таком случае нужно сразу же добавить новый, а для старого вызвать midiInUnprepareHeader.
Итого, процесс такой: извлечь данные → утилизировать старый буфер → создать новый → подготовить его → добавить в драйвер. Всё это выполняется во время коллбэка на MIDI-событие.
Проблема: временами программа наглухо зависает при обработке sysex-событий.
Перед тем, как начать общаться с виртуальным программистом-всезнайкой, я проанализировал дампы процесса, снятые во время зависаний. Всё указывало на вызов midiInAddBuffer в коллбэке. В документации Microsoft ничего полезного обнаружено не было. “Может, искусственный интеллект поможет?” — подумал я. Вот краткий пересказ нашего диалога:
Я: <описываю ситуацию, прикладываю стектрейс из дампа>
ИИ: Хмм, midiInAddBuffer нельзя вызывать из коллбэка, нужно сделать это в другом потоке, вот тебе код.
Я: Сделал, всё равно есть зависания.
ИИ: Хмм, проблема может быть в midiInUnprepareHeader, с ним нужно поступить аналогично, держи код. И давай в C# вызовы этих функций тоже отправим в отдельный поток.
Я: А ты уверен?
ИИ: Да, в коллбэке нельзя вызывать midiInAddBuffer и midiInUnprepareHeader.
Я: Заглянул я в чужие проекты, а там делают вот как: добавляют в драйвер изначально не один, а несколько буферов, а в коллбэке просто повторно добавляют тот буфер, в котором пришли данные, midiInUnprepareHeader при этом не вызывают.
ИИ: Это правильное решение!
Я: ...
Стало работать намного стабильнее, но не идеально. Несколько дней спустя:
Я: Что-то снова вижу зависания, держи дамп, кажется какой-то конфликт midiInAddBuffer и midiInClose.
ИИ: Всё-таки давай код по работе с буфером отправим в отдельный поток.
Я: А может нужно вызовы этих функций поместить в блоки lock в C# и конфликт исчезнет?
ИИ: Это правильное решение!
Я: ......
С маршалингом интереснее. Что вообще значит вызвать нативную функцию из .NET? Рантайму нужно:
преобразовать типы в понятные нативному API (например,
stringвLPWSTR);выделить память вне .NET;
разместить аргументы на стеке или в регистрах для передачи в функцию;
собственно, вызвать функцию;
освободить выделенную ранее память и стек, если нужно.
Это всё задачи маршалинга. Возможно, я что-то упустил, но суть ясна — процесс сложный. Чтобы сохранить последовательность повествования, сейчас я о каких-то его тонкостях говорить не буду. Но обязательно сделаю это позже.
Кстати, а почему примеры нативных функций сплошь из Windows?
Такие разные операционные системы
Есть желание получать аналогичную информацию об устройствах в macOS. Возможно ли это? Да, с помощью функции MIDIGetNumberOfSources (см. статью про CoreMIDI):
[DllImport("/System/Library/Frameworks/CoreMIDI.framework/CoreMIDI")] static extern int MIDIGetNumberOfSources();
Первые выводы:
в macOS используется свой (достаточно жуткий) путь к системной библиотеке;
нам нужно вызывать разные функции в зависимости от операционной системы.
Касательно второго пункта: если ваш проект не использует древние версии фреймворка вроде .NET Framework 4.5, то можно использовать статический класс RuntimeInformation:
var count = 0; if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) count = midiInGetNumDevs(); else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) count = MIDIGetNumberOfSources();
Почему бы не поддержать тогда и Linux? Добавить новую ветку else if не проблема, а вот получить заветное количество входных MIDI-устройств так же просто, как в Windows или macOS, не выйдет.
Предлагаю заглянуть в файл RtMidi.cpp библиотеки RtMidi, а конкретно в методы MidiInAlsa::getPortCount и portInfo. Все функции внутри portInfo, начинающиеся с snd_seq_, должны быть объявлены в C# и вызваны аналогичным образом. Наш замечательный код превращается в месиво из условных конструкций и особых алгоритмов по работе с разными ОС. А хотелось бы написать всего одну инструкцию:
var count = GetInputDevicesCount();
Этой дорогой мы и пойдём, товарищи. Для простоты будем рассматривать только Windows и macOS.
Нативный бэкенд
Что если скрыть использование ОС-специфичных функций за нашим собственным фасадом, предоставив унифицированный API для использования в .NET-коде? Встречайте — нативный бэкенд. Идея следующая:
создаём файлы Native-Windows.cpp и Native-macOS.cpp;
в каждом файле на C++ объявляем одну и ту же функцию
GetInputDevicesCount;в Native-Windows.cpp функция вызывает
midiInGetNumDevs, а в Native-macOS.cpp —MIDIGetNumberOfSources;файл Native-Windows.cpp собираем в бинарник Native.dll, а Native-macOS.cpp — в Native.dylib;
кладём оба файла в выходную директорию (output directory) .NET-проекта;
объявляем единственную внешнюю функцию
GetInputDevicesCount, в атрибутеDllImportуказываем Native в качестве имени нативного бинарника.
Такая вот дорожная карта, если пользоваться режущей ухо калькой с английского roadmap. Разберём сей план подробнее.
Реализация на C++
И так, создаём файл Native-Windows.cpp (для краткости я опущу директивы #include):
extern "C" __declspec(dllexport) int GetInputDevicesCount() { return midiInGetNumDevs(); }
С телом функции всё понятно. Разберём сигнатуру:
extern "C"предотвращает искажение имени. Если данную конструкцию не указать, компилятор C++ может назвать функцию, например,?GetInputDevicesCount@@YAHXZ, и рантайм .NET попросту не найдёт ожидаемуюGetInputDevicesCount.__declspec(dllexport)говорит компилятору, что функция экспортируемая, т.е. должна быть доступна для вызова извне dll. Без этого атрибутаGetInputDevicesCountне будет добавлена в специальную таблицу внутри файла, и рантайм .NET её так же не найдёт.
Аналогичная функция в файле Native-macOS.cpp:
extern "C" __attribute__((visibility("default"))) int GetInputDevicesCount() { return static_cast<int>(MIDIGetNumberOfSources()); }
__attribute__((visibility("default"))) это аналог __declspec(dllexport), предназначен этот атрибут для того же самого.
Стоит поговорить про конвенцию вызова (calling convention aka соглашение о вызове). Говоря по-простому, это правила передачи аргументов и возврата результата, что является частью ABI (application binary interface, двоичный интерфейс приложений). Мы будем пользоваться конвенцией, используемой по умолчанию в C и C++. Можно указать её явно, добавив атрибут __cdecl сразу после типа возвращаемого значения:
extern "C" __declspec(dllexport) int __cdecl GetInputDevicesCount()
Но делать это стоит только тогда, когда планируется запускать нативный код в 32-битных процессах в Windows, потому что там в игру могут вступать и другие соглашения, например, __stdcall. Если же ваши целевые платформы современные 64-битные, то везде фактически используется __cdecl, и в Windows, и в macOS, и в Linux, так что прописывать это явно не требуется.
Что ж, пора собрать плюсовые файлы в бинарники. В прошлой статье я использовал для этих целей gcc и в Windows, и в macOS. Был неправ.
Сборка бинарников
Небольшое лирическое отступление. В этой статье я упоминаю C++, тогда как ранее использовал C. Причина была в том, что C я знаю намного лучше плюсов, он сильно проще. Сейчас же я рекомендую использовать C++, ибо код на C заработает и в плюсах, но при этом многие сторонние API, скорее всего, будет намного проще использовать именно в C++. Для меня триггером перехода на страшный (с точки зрения сложности для меня) язык стала необходимость поддержать новый MIDI API от Microsoft — Windows MIDI Services. Он основан на COM, а использовать сию технологию в C это как выпекать хлеб феном: в теории можно, но зачем?
Возвращаемся к сборке. В 2022-ом году мне в проект прилетело обращение, что DryWetMIDI не работает на новых процессорах Apple (M1, M2, …), которые на архитектуре ARM. Методом отладки на компьютере пользователя удалось выяснить, что правильный набор команд для сборки dylib-файла (аналог dll в macOS) будет такой (для краткости опустил аргументы -framework для линковки с системными библиотеками):
clang -shared -undefined dynamic_lookup -o Native_arm64.dylib NativeApi-macOS.cpp -arch arm64 clang -shared -undefined dynamic_lookup -o Native_x86_64.dylib NativeApi-macOS.cpp -arch x86_64 lipo Native_x86_64.dylib Native_arm64.dylib -output Native.dylib -create
Первой командой clang собираем dylib под архитектуру ARM64. Второй командой clang собираем dylib под архитектуру x86_64 (процессоры Intel, использовавшиеся на компьютерах Apple ранее). Командой lipo сшиваем два полученных выше файла в один dylib. По итогу имеем нативный бинарник, работающий одновременно и на процессорах Intel, и на новых яблочных. Всего три команды!
С Windows дела посложнее. Получить в ОС от Microsoft то, что называется “толстым” бинарником (fat binary), нельзя. Единственный вариант — два отдельных dll-файла: для x64 и для ARM64.
Вооружаемся Visual Studio, создаём проект и настраиваем конфигурации сборки. С x64 всё понятно, а для ARM рекомендуется выбирать вариант ARM64X. Вкратце, нужно создать две конфигурации: ARM64 и ARM64EC, и для второй добавить элемент <BuildAsX>true</BuildAsX> в vcxproj-файле (пример). Получить dll-файл после этого можно хоть в VS, хоть в консоли:
msbuild Native.vcxproj /p:platform="ARM64EC" /p:configuration="Release"
Для x64:
msbuild Native.vcxproj /p:platform="x64" /p:configuration="Release"
Нативные бинарники получены. Нужно их куда-то положить.
Расположение файлов
Нас интересует выходная директория проекта (output directory). Именно туда в итоге должны попасть dll и dylib, дабы .NET смог найти их. Есть несколько вариантов, где рантайм будет искать файл, указанный в DllImport, но нам интересна текущая директория, откуда исполняется код проекта (.NET-библиотеки, приложения). При запуске из IDE она равняется выходной.
Но директория эта, разумеется, вещь непостоянная. Для разных конфигураций (Release или Debug) и целевого фреймворка (net7.0 или net45) при сборке проекта создаётся своя. Не проблема настроить копирование туда нативных бинарников, но компилятору нужно знать, где они находятся.
Я использую такую структуру:
├─ Project └─ Native ├─ win_x64 │ └─ Native.dll ├─ win_arm64 │ └─ Native.dll └─ macos_x64_arm64 └─ Native.dylib
Т.е. рядом с папкой проекта есть директория Native, где в поддиректориях, отвечающих определённым архитектурам, лежат нативные файлы.
Переходим к csproj-файлу проекта. Добавим такие строки:
<ItemGroup> <None Include="$(SolutionDir)Native\win_x64\Native.dll"> <CopyToOutputDirectory>Always</CopyToOutputDirectory> </None> <None Include="$(SolutionDir)Native\win_arm64\Native.dll" Condition="'$(PROCESSOR_ARCHITECTURE)' == 'ARM64' or '$(PROCESSOR_ARCHITEW6432)' == 'ARM64' or $([System.String]::Copy('$(PROCESSOR_IDENTIFIER)').ToLower().Contains('arm'))"> <CopyToOutputDirectory>Always</CopyToOutputDirectory> </None> <None Include="$(SolutionDir)Native\macos_x64_arm64\Native.dylib"> <CopyToOutputDirectory>Always</CopyToOutputDirectory> </None> </ItemGroup>
Компилятор идёт по элементам None в том порядке, в каком они объявлены, выполняя копирование бинарников в выходную директорию, а именно:
Native.dll из папки Native/win_x64;
если у нас ARM-процессор, Native.dll из папки Native/win_arm64 (перезаписывая файл с прошлого шага);
Native.dylib из папки Native/macos_x64_arm64.
В итоге рядом с .NET-библиотекой/приложением окажутся и dll и dylib. Конечно, в Windows dylib не нужен, как и dll в macOS, но пока не будем усложнять себе жизнь, никаких проблем это не создаст. Позже, когда придёт время заняться NuGet-пакетом, мы их разделим. Сейчас же пора вернуться к .NET.
Использование в C#
Выделяем все объявленные ранее внешние функции вместе с if/else по операционным системам, нажимаем клавишу Delete и пишем:
[DllImport("Native", ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)] static extern int GetInputDevicesCount();
Теперь у нас одно объявление нативной функции. При запуске в Windows рантайм будет ожидать Native.dll, а в macOS — Native.dylib. Т.е. расширение файла будет подставлено автоматически в зависимости от операционной системы.
Важный момент: забудьте про точки в имени нативных бинарников. Если основной проект называется, например, MyLibrary, очень хочется, конечно, назвать их MyLibrary.Native.dll и MyLibrary.Native.dylib. К сожалению, рантайм в каких-то случаях (и я с таким сталкивался) может посчитать последнюю точку разделителем имени и расширения, и не найти файл (т.е. в [DllImport(“MyLibrary.Native”)] Native посчитается расширением).
ExactSpelling = true говорит рантайму искать функцию по точному совпадению имени. По умолчанию свойство выставлено в false, позволяя выполнять поиск функции, например, с суффиксами A или W в Windows (см. MessageBoxA и MessageBoxW). Мы же точно знаем имя, поэтому нет смысла делать дополнительную работу.
И хотя, как я и говорил ранее, это не обязательно, указываем конвенцию вызова CallingConvention.Cdecl. Так мы можем быть уверенными, что и вызывающая (C#) и вызываемая (C++) стороны общаются друг с другом на одном “языке”.
Прелести P/Invoke и маршалинга
Предлагаю перейти к более сложным вещам. В реальности у вас, скорее всего, возникнет задача получить доступ к чему-то, потом с этим чем-то что-то сделать, а в конце отпустить его восвояси. Речь про объекты, работа с которыми выливается в ряд вызовов нативных функций.
Давайте попробуем получать от устройства данные (MIDI-события). Не вдаваясь в конкретику, идея следующая:
открыть устройство для работы, передав коллбэк, который будет вызываться при получении событий;
поработать с устройством;
освободить устройство.
Чем-то похоже, например, на работу с файлами в C (fopen, fputs, fclose). MIDI-устройство это тоже ресурс, который сперва нужно взять, а потом отпустить.
C++ код для Windows:
typedef struct { HMIDIIN handle; // ... } InputDeviceHandle; typedef void (*InputDeviceCallback)(int message); extern "C" __declspec(dllexport) OPEN_RESULT OpenInputDevice(int deviceIndex, InputDeviceCallback callback, InputDeviceHandle** handle) { InputDeviceHandle* inputDeviceHandle = new InputDeviceHandle(); // ... MMRESULT result = midiInOpen(&inputDeviceHandle->handle, ...); // ... *handle = inputDeviceHandle; return OPEN_RESULT_OK; }
И аналогичный для macOS:
typedef struct { MIDIPortRef portRef; // ... } InputDeviceHandle; typedef void (*InputDeviceCallback)(int message); extern "C" __attribute__((visibility("default"))) OPEN_RESULT OpenInputDevice(int deviceIndex, InputDeviceCallback callback, InputDeviceHandle** handle) { InputDeviceHandle* inputDeviceHandle = new InputDeviceHandle(); // ... OSStatus status = MIDIInputPortCreate(..., &inputDeviceHandle->portRef); // ... *handle = inputDeviceHandle; return OPEN_RESULT_OK; }
Мы определили функцию OpenInputDevice, с помощью которой будем получать некий объект для управления устройством с индексом deviceIndex. Этот объект — структура InputDeviceHandle. Её внутренности будут отличаться для разных операционных систем: в Windows устройство представляется типом HMIDIIN, а в macOS — MIDIPortRef. Но на уровне C# данная информация не важна.
Касательно InputDeviceCallback: это наш коллбэк, через него устройство будет сообщать о поступивших данных. Технически это указатель на функцию, принимающую int (так для простоты обозначим MIDI-событие) и ничего не возвращающую.
Функция OpenInputDevice возвращает OPEN_RESULT, через который можно понять, как всё прошло: успешно или же возникла ошибка. Да, старые добрые коды возврата. Придётся забыть про плюсовые исключения, ибо P/Invoke с ними работать не умеет, ваше приложение с грохотом упадёт. Я для OPEN_RESULT использую просто набор целочисленных констант:
typedef int OPEN_RESULT; #define OPEN_RESULT_OK 0 #define OPEN_RESULT_ALLOCATED 1 #define OPEN_RESULT_BADDEVICEID 2 // ... #define OPEN_RESULT_INVALIDCLIENT 101 #define OPEN_RESULT_INVALIDPORT 102 // ... #define OPEN_RESULT_UNKNOWNERROR 10000
Со значения 1 идут ошибки для Windows, с 101 — для macOS. У вас, разумеется, может быть своя система нумерации.
Но как мы получаем рукоятку управления устройством (так мы обзовём InputDeviceHandle)?
IntPtr
Последний параметр — handle — рассмотрим отдельно. Его тип (InputDeviceHandle**) представляет собой указатель на указатель на InputDeviceHandle. Это способ вернуть рукоятку через параметр.
Не уходя далеко, сразу опишем функцию OpenInputDevice в C#:
[UnmanagedFunctionPointer(CallingConvention.Cdecl)] public delegate void InputDeviceCallback(int message); [DllImport("Native", CallingConvention = CallingConvention.Cdecl)] static extern OpenResult OpenInputDevice( int deviceIndex, InputDeviceCallback callback, out IntPtr handle);
Ключевые моменты:
OpenResultэтоenum. Значения констант в этом перечислении должны совпадать с объявленными в C++. Учитывая, что там это просто целые числа, в C# можно поставить возвращаемым типомint, если хочется.InputDeviceCallbackдля передачи указателя на коллбэк. Сигнатура должна совпадать с объявленной в плюсах. Для параметров таких делегатов применяются все те же правила P/Invoke, что и для прочих нативных функций.InputDeviceHandle**превратился вout IntPtr. Если вы плотно будете работать с нативными функциями, тоIntPtrстанет вашим верным спутником. Через него идёт работа с плюсовыми (или сишными) указателями.
Со вторым пунктом есть важный нюанс. Например, вы объявляете у себя в .NET-коде метод для коллбэка:
private void HandleMessage(int message) { // ... }
А затем вызываете OpenInputDevice:
OpenInputDevice(0, HandleMessage, out var handle);
Код скомпилируется, но в рантайме вас ждёт бабах. Почему? Потому что, передав HandleMessage, в действительности была передана ссылка на делегат. А что будет с этой ссылкой при выходе из метода, вызывающего OpenInputDevice? Она будет уничтожена сборщиком мусора. Но нативный код-то про это не знает. Так что при обращении к указателю внутри каких-то системных функций случится то, что большинству программистов известно под названием segmentation fault.
В контексте P/Invoke это самый неприятный тип багов, которые временами приходится долго расследовать. Чтобы такого не возникло, нужно зафиксировать передаваемый делегат в памяти, дабы сборщик мусора прошёл со своей метлой мимо. Простейший вариант — сохранить его в поле:
private InputDeviceCallback _callback; // ... _callback = HandleMessage; OpenInputDevice(0, _callback, out var handle);
Кстати, про чёрную магию P/Invoke и маршалинга: а что если в C++ у параметра тип void***?
void Foo(void*** pointerToPointerToPointer)
Как с этим работать в .NET? Дальше out IntPtr мы пойти не сможем. Но расстраиваться не стоит. Вот небольшой кусочек кода на C#, демонстрирующий, что можно сделать:
[DllImport(...)] static extern void Foo(out IntPtr pointerToPointer); // ... // pointerToPointer is IntPtr for void** Foo(out var pointerToPointer); // pointer is IntPtr for void* var pointer = Marshal.ReadIntPtr(pointerToPointer); // If pointer points to int int n = Marshal.ReadInt32(pointer); // or string string str = Marshal.PtrToStringAnsi(pointer);
Код этот нам в статье не пригодится. Я показал его, дабы призвать вас заглянуть в класс Marshal, в котором есть масса интересных методов для работы с нативным кодом. Даже просто ознакомившись с их описаниями можно понять, как сложен и многообразен мир маршалинга. Изучить данный класс будет также полезно для представления, какие бывают проблемы при работе с нативным кодом, и как их решать.
И так, мы поработали с устройством, даже приняли от него MIDI-события в C#, но приходит момент отпустить его. Для этого опишем в C++-файлах функцию CloseInputDevice (для краткости без атрибутов компиляции вроде extern “C”):
CLOSE_RESULT CloseInputDevice(InputDeviceHandle* handle) { // ... delete handle; return CLOSE_RESULT_OK; }
Декларация P/Invoke будет выглядеть так:
[DllImport("Native", CallingConvention = CallingConvention.Cdecl)] static extern CloseResult CloseInputDevice(IntPtr handle);
Передаём полученный ранее хэндл (он же рукоятка), и на этом работа с устройством завершена. Осталось понять, когда вызвать эту функцию.
Возможно, вы захотите предоставить отдельный метод для освобождения устройства. Но что если пользователь забудет его вызвать? Например, в случае старого Windows Multimedia API открыть устройство, не закрытое перед этим, нельзя. Взрыва не случится, но работать с устройством вы не сможете.
Решение — реализовать IDisposable + финализатор:
private IntPtr _handle; private bool _disposed; // ... public void Open() { // ... var result = InputDeviceNativeApi.OpenInputDevice(0, _callback, out _handle); // ... } // ... ~InputDevice() { Dispose(false); } public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } private void Dispose(bool disposing) { if (_disposed) return; if (disposing) { // ... } InputDeviceNativeApi.CloseInputDevice(_handle); _disposed = true; }
Теперь устройство будет корректно освобождено либо при осознанном завершении работы с ним, либо когда сделать это забыли. Но можно пойти дальше.
SafeHandle
Если класс владеет нативным ресурсом (_handle в нашем случае), освобождать его через финализатор нужно в любом случае. Но можно завернуть хэндл в класс-обёртку, который возьмёт задачу финализации на себя, и использовать его в качестве типа поля _handle в классе InputDevice. Финализатор в InputDevice в таком случае не нужен.
Microsoft уже подготовил для нас такую обёртку — SafeHandle. Класс этот абстрактный, так что нужно сделать на его основе свой. Кроме того, при вызове конструктора базового класса придётся сказать, какое значение указателя считать некорректным. Обычно это 0, но иногда ещё и -1. Поэтому в .NET есть класс (тоже абстрактный) для такого случая — SafeHandleZeroOrMinusOneIsInvalid. Его мы и будем использовать:
internal sealed class InputDeviceHandle : SafeHandleZeroOrMinusOneIsInvalid { public InputDeviceHandle() : base(true) { } public InputDeviceHandle(IntPtr handle) : base(true) { SetHandle(handle); } protected override bool ReleaseHandle() { var result = InputDeviceNativeApi.CloseInputDevice(handle); return result == CloseResult.Ok; } }
И тогда освобождение устройства в классе InputDevice будет выглядеть так:
private InputDeviceHandle _handle; private bool _disposed; // ... public void Open() { // ... var result = InputDeviceNativeApi.OpenInputDevice(0, _callback, out var rawHandle); _handle = new InputDeviceHandle(rawHandle); // ... } // ... public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } private void Dispose(bool disposing) { if (_disposed) return; if (disposing) { // ... _handle.Dispose(); } _disposed = true; }
Теперь мы просто вызываем Dispose у InputDeviceHandle (ибо SafeHandle реализует IDisposable). При уничтожении экземпляра класса InputDevice будет вызван (когда-то) финализатор SafeHandle, который позовёт ReleaseHandle, где и завершится работа с устройством.
Но тема использования безопасных рукояток (safe handles) гораздо глубже. Можно определить наши внешние функции вот так:
[DllImport("Native", CallingConvention = CallingConvention.Cdecl)] static extern OpenResult OpenInputDevice( int deviceIndex, InputDeviceHandle callback, out InputDeviceHandle handle); [DllImport("Native", CallingConvention = CallingConvention.Cdecl)] static extern CloseResult CloseInputDevice(InputDeviceHandle handle);
Маршалер будет сам преобразовывать нативный указатель в InputDeviceHandle и обратно. Дополнительным бонусом идёт автоматический инкремент счётчика ссылок на хэндл при вызове функции и декремент при выходе из неё. Это предотвращает попадание указателя в жернова сборщика мусора во время выполнения нативного кода, что может привести к неприятным вещам (см. System.Runtime.InteropServices.SafeHandle class про переиспользование памяти).
Но я рекомендую использовать в P/Invoke только IntPtr. Как же так, когда автопреобразование даёт столько пользы?
Начнём с простого. Уверен, многие уже задумались: но ведь в ReleaseHandle мы вызываем CloseInputDevice, который теперь принимает InputDeviceHandle, не передавать же туда this? Конечно, нет. Сделав так, вы навлечёте на себя в лучшем случае ObjectDisposedException, в худшем — крэш приложения в самых затейливых проявлениях.
Почему так происходит:
во время вызова
ReleaseHandleхэндл уже помечен, как освобождённый;маршалер при вызове
CloseInputDeviceпытается увеличить счётчик ссылок, но сделать это ему не удастся, потому что данная операция запрещена для освобождённого хэндла.
Последствия разнятся в зависимости от ОС и версии .NET, предлагаю поискать информацию по теме самостоятельно.
Что ж, CloseInputDevice будет всё-таки принимать IntPtr. Но давайте сделаем класс InputDevice более реалистичным.
На самом деле, что в Windows, что в macOS получения рукоятки недостаточно для начала приёма каких-либо данных от MIDI-устройства. В ОС от Microsoft вам нужно вызвать midiInStart, а в яблочной системе — MIDIPortConnectSource. Им соответствуют функции midiInStop и MIDIPortDisconnectSource, выполняющие обратную операцию — остановку получения MIDI-событий.
Мы завернём вызовы этих функций в StartInputDevice и StopInputDevice на стороне C++, а в C# определим их так:
[DllImport("Native", CallingConvention = CallingConvention.Cdecl)] static extern StartResult StartInputDevice(InputDeviceHandle handle); [DllImport("Native", CallingConvention = CallingConvention.Cdecl)] static extern StopResult StopInputDevice(InputDeviceHandle handle);
Пользователям класса InputDevice мы предоставим методы Start и Stop:
public void Start() { // ... var result = InputDeviceNativeApi.StartInputDevice(_handle); // ... } public void Stop() { // ... var result = InputDeviceNativeApi.StopInputDevice(_handle); // ... }
Но перед освобождением устройства желательно остановить приём событий от него — будет неприятно закрыть устройство во время получения данных (можно решить через конструкции lock, но опустим это). А пользователь может забыть вызвать метод Stop. Получается, в ReleaseHandle перед вызовом CloseInputDevice нужно ещё вызвать StopInputDevice.
Здесь мы снова приходим к той же проблеме: передать this в нативный метод при освобождении хэндла нельзя. Но в отличие от CloseInputDevice, функция StopInputDevice может быть вызвана пользователем по желанию через метод Stop. Может возникнуть идея сделать два определения StopInputDevice:
[DllImport("Native", CallingConvention = CallingConvention.Cdecl)] static extern StopResult StopInputDevice(InputDeviceHandle handle); [DllImport("Native", CallingConvention = CallingConvention.Cdecl)] static extern StopResult StopInputDevice(IntPtr handle);
В методе Stop мы будем передавать InputDeviceHandle, а внутри ReleaseHandle — IntPtr. Если дублирование нас не устраивает, то и StopInputDevice придётся оставить в варианте с IntPtr.
Получается, половина функций принимает IntPtr, половина — InputDeviceHandle. Хотелось бы привести всё к общему знаменателю. Но тогда вариант только один: IntPtr. Мы всё равно оставляем InputDeviceHandle для нужд освобождения устройства, как мы это сделали в самом начале, но в нативные функции всегда будет передаваться “сырой” указатель (_handle.DangerousGetHandle()).
Помимо полного контроля над происходящим и отсутствия лишней магии маршалера, у такого подхода есть ещё одно преимущество над автоматической трансформацией IntPtr в SafeHandle. В реальности OpenInputDevice будет содержать в себе обработку кодов ошибок, возвращаемых от API операционной системы. Что если, например, midiInOpen завершится неудачей? Какое значение в таком случае будет содержать параметр handle? Ответ: да какое угодно.
Но маршалер про это не знает, так что InputDeviceHandle всё равно будет создан. Казалось бы, ну и ладно, можно проверить код возврата функции и в случае ошибки предпринять меры. Но рано или поздно придёт время сборщику мусора вызвать финализатор InputDeviceHandle, после чего внутри ReleaseHandle в нативные функции отправится хлам вместо реального хэндла устройства. О последствиях говорить, думаю, не стоит.
Поэтому наиболее безопасно и понятно будет работать с нативными функциями исключительно через IntPtr. Особенно, если вся кухня по обращению в нативные бинарники будет скрыта от конечного пользователя. А лично я не вижу причин для обратного. .NET дарит нам замечательный высокоуровневый API, и мы, как разработчики библиотеки, должны предоставлять таковой для своих пользователей.
Хотя в Microsoft могут не согласиться, судя по наличию метода RunImpersonated в WindowsIdentity. Метод принимает SafeAccessTokenHandle, который предлагается получать, та-дам, через P/Invoke! Звучит как бред, но это так. Справедливости ради, я не могу припомнить других таких методов в .NET.
LibraryImport
Всё это время в статье использовался атрибут DllImport. И, возможно, кто-то из вас, заглядывая в материалы по ссылкам, обратил внимание, что в примерах кода фигурирует другой — LibraryImport. Почему же?
В случае с DllImport код, необходимый для маршалинга, генерируется в рантайме. Это значит, что:
trimming и AOT-компиляция могут работать некорректно в отдельных сценариях;
к вызову нативной функции добавляются накладные расходы.
LibraryImport использует генератор исходного кода (source generator), т.е. код маршалинга генерируется во время компиляции и может быть сразу встроен (inlined) в вызовы нативных функций, сглаживая перечисленные выше проблемы.
Теперь поговорим о том, как его использовать. Прежде всего, объявления внешних функций выглядят теперь так:
[LibraryImport("Native")] [UnmanagedCallConv(CallConvs = new[] { typeof(CallConvCdecl) })] static partial OpenResult OpenInputDevice( int deviceIndex, InputDeviceCallback callback, out IntPtr handle);
Отличия от DllImport:
используется
partialвместоextern, а значит и класс, содержащий декларацию выше, должен быть объявлен, какpartial.конвенция вызова указывается отдельным атрибутом
UnmanagedCallConv.
Кроме того, новый атрибут доступен только начиная с 7-й версии .NET. Но что если хочется оставить в библиотеке поддержку старых фреймворков, например, через .NET Standard 2.0? Не проблема, правим csproj:
<TargetFrameworks>netstandard2.0;net7.0</TargetFrameworks>
Кроме того, придётся добавить директивы условной компиляции, продублировав определения нативных функций:
using System.Runtime.InteropServices; #if NET7_0_OR_GREATER using System.Runtime.CompilerServices; #endif // ... #if NET7_0_OR_GREATER // ... [LibraryImport("Native")] [UnmanagedCallConv(CallConvs = new[] { typeof(CallConvCdecl) })] static partial OpenResult OpenInputDevice( int deviceIndex, InputDeviceCallback callback, out IntPtr handle); // ... #else // ... [DllImport("Native", CallingConvention = CallingConvention.Cdecl)] static extern OpenResult OpenInputDevice( int deviceIndex, InputDeviceCallback callback, out IntPtr handle); // ... #endif
Таким образом, для работы данного кода минимально необходим .NET Standard 2.0. Но на .NET 7 и выше будет использоваться новый механизм P/Invoke со всеми его бонусами.
NuGet и нативные бинарники
Нам осталось затронуть последнюю тему: как правильно положить dll- и dylib-файлы в NuGet-пакет. Правильно = при сборке проекта, куда установлен пакет, бинарники будут попадать в выходную директорию.
Этот раздел будет коротким, понадобятся всего лишь два файла. Первый из них — csproj нашей библиотеки. Открываем его и пишем:
<ItemGroup> <None Include="..\Native\win_x64\Native.dll" Pack="true" PackagePath="runtimes/win-x64/native" /> <None Include="..\Native\win_arm64\Native.dll" Pack="true" PackagePath="runtimes/win-arm64/native" /> <None Include="..\Native\macos_x64_arm64\Native.dylib" Pack="true" PackagePath="build\" /> <None Include="NuGet.targets" Pack="true" PackagePath="build\" /> </ItemGroup>
В моей первой статье Создание пакета NuGet для библиотеки с платформозависимым API вы могли видеть эту секцию, однако там PackagePath для всех нативных бинарников был build\. Это самый простой способ, когда у вас нет деления по архитектуре процессора. Сейчас же мы во-первых, имеем два разных dll-файла для Windows x64 и ARM, а во-вторых, почему бы не попробовать современную структуру NuGet-пакета с папками runtimes/rid/native (RID = runtime identifier).
На самом деле решение у нас будет комбинированное. Потому что для macOS нет смысла усложнять, dylib содержит все необходимые архитектуры.
Переходим к файлу NuGet.targets, который фигурирует в csproj выше. По идее, если класть бинарник в папку native NuGet-пакета, он будет автоматически скопирован в выходную директорию проекта, куда установлен пакет. Мой выбор — делать это явно через targets-файл:
<?xml version="1.0" encoding="utf-8"?> <Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> <PropertyGroup> <!-- Prefer explicit RID --> <LibraryRuntime Condition="'$(LibraryRuntime)' == '' and '$(RuntimeIdentifier)' != ''"> $(RuntimeIdentifier) </LibraryRuntime> <!-- Fallback to SDK host RID when no RID is provided --> <LibraryRuntime Condition="'$(LibraryRuntime)' == '' and '$(NETCoreSdkRuntimeIdentifier)' != ''"> $(NETCoreSdkRuntimeIdentifier) </LibraryRuntime> <!-- Windows ARM heuristic when LibraryRuntime still unset --> <LibraryRuntime Condition="'$(LibraryRuntime)' == '' and '$(OS)' == 'Windows_NT' and ( '$(PROCESSOR_ARCHITECTURE)' == 'ARM64' or '$(PROCESSOR_ARCHITEW6432)' == 'ARM64' or $([System.String]::Copy('$(PROCESSOR_IDENTIFIER)').ToLower().Contains('arm')))"> win-arm64 </LibraryRuntime> <!-- Default Windows x64 --> <LibraryRuntime Condition="'$(LibraryRuntime)' == '' and '$(OS)' == 'Windows_NT'"> win-x64 </LibraryRuntime> </PropertyGroup> <Choose> <When Condition="$([System.String]::Copy('$(LibraryRuntime)').StartsWith('win-arm'))"> <ItemGroup> <None Include="$(MSBuildThisFileDirectory)..\runtimes\win-arm64\native\Native.dll"> <Visible>false</Visible> <Link>Native.dll</Link> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </None> </ItemGroup> </When> <When Condition="$([System.String]::Copy('$(LibraryRuntime)').StartsWith('win'))"> <ItemGroup> <None Include="$(MSBuildThisFileDirectory)..\runtimes\win-x64\native\Native.dll"> <Visible>false</Visible> <Link>Native.dll</Link> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </None> </ItemGroup> </When> </Choose> <ItemGroup Condition="$([System.String]::Copy('$(RuntimeIdentifier)').StartsWith('osx')) or $([System.String]::Copy('$(NETCoreSdkRuntimeIdentifier)').StartsWith('osx')) or $([System.String]::Copy('$(RuntimeIdentifier)').StartsWith('maccatalyst')) or $([System.String]::Copy('$(NETCoreSdkRuntimeIdentifier)').StartsWith('maccatalyst'))"> <None Include="$(MSBuildThisFileDirectory)Native.dylib"> <Visible>false</Visible> <Link>Native.dylib</Link> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </None> </ItemGroup> </Project>
build — волшебная папка. Если положить туда targets-файл, элементы из него автоматически добавятся к проекту, куда установлен пакет. Осталось понять, что написано выше.
Прежде всего, мы пытаемся вычислить текущий RID для Windows, сохранив его в элемент LibraryRuntime. Базовое значение берётся из свойства RuntimeIdentifier проекта, которое в норме должно быть во что-то установлено. Если по какой-то причине свойства нет или там пусто, смотрим в другое — NETCoreSdkRuntimeIdentifier. Если же и с ним не заладилось, то для ARM-архитектуры принимаем RID равным win-arm64, иначе — win-x64.
Далее элементом Choose определяем по LibraryRuntime, для какого dll-файла применить нужные нам свойства, в частности CopyToOutputDirectory.
Последний элемент ItemGroup обсуждать нет смысла, ибо сосредоточен он на бинарнике для macOS, с коим всё просто — на этой ОС копировать его нужно всегда, ибо все поддерживаемые архитектуры находятся внутри одного файла.
В итоге, структура NuGet-пакета будет такой:
Package │ ├─ build │ ├─ NuGet.targets │ └─ Native.dylib ├─ lib │ ├─ netstandard2.0 │ │ ├─ MyLibrary.dll │ │ └─ MyLibrary.xml │ └─ net7.0 │ ├─ MyLibrary.dll │ └─ MyLibrary.xml ├─ runtimes │ ├─ win-arm64 │ │ └─ native │ │ └─ Native.dll │ └─ win-x64 │ └─ native │ └─ Native.dll ├─ ... ...
И теперь при сборке проекта, куда установлен пакет, только нужный бинарник будет скопирован в выходную директорию: в macOS — build/Native.dylib, в Windows — runtimes/win-arm64/native/Native.dll на ARM процессоре или runtimes/win-x64/native/Native.dll на Intel/AMD. И не важно, запускаете вы проект в IDE или собираете приложение, например, через dotnet publish с различными флагами вроде SelfContained, PublishSingleFile или IncludeNativeLibrariesForSelfExtract — описанная механика копирования не пошатнётся.
Заключение
Вот и подошло к концу наше гастрономическое приключение по миру нативного кода в .NET. Осветить в рамках одной статьи (да и двух, и трёх) все стороны вопроса невозможно. Тем не менее, я предоставил достаточно материала для старта и постарался затронуть разные аспекты.
Будет здорово, если вы поделитесь своими историями и неочевидными нюансами работы с нативным кодом. Я с радостью дополню статью полезной информацией.
Засим прощаюсь. Уверен, было сытно и, надеюсь, вкусно.
