Построение .NET nanoFramework
Вы можете создать свой образ для MCU с помощью .NET nanoFramework. В большинстве случаев вы будете использовать готовые образы для популярных плат, включая современные устройства ESP32, такие как M5Stack, для которых мы предлагаем специальные образы прошивок и NuGets с пакетами поддержки платы.
Однако бывают ситуации, когда вы захотите собрать образ самостоятельно. Для этого существуют образы Developer Container. Они содержат все цепочки инструментов и необходимые инструменты. Ознакомьтесь со статьей об использовании Dev Container для сборки целей. Если вы предпочитаете не использовать контейнеры разработчиков, можно воспользоваться локальной установкой. Каждый шаг хорошо документирован в статье Building .NET nanoFramework.
Взаимодействие в .NET nanoFramework
.NET nanoFramework поддерживает облегченную версию Interop, которая позволяет добавлять существующий C/C++ код в образ .NET nanoFramework. Это удобно, например, когда вы хотите использовать библиотеку, которая совершенствовалась годами, или когда есть необходимость выполнять процессороемкие операции, которые выгодно выполнять непосредственно центральным процессором.
Архитектура построена таким образом, что «чужой» код остается полностью изолированным от CLR и официальных библиотек и драйверов. Таким образом, вы не нарушаете процесс сборки, и его можно легко включить в процесс сборки без необходимости внесения изменений и выполнения сложных операций слияния.
Поскольку конечной целью является подключение к этому коду из C#, для этого существует два уровня:
- Библиотека C# действует как обертка и предлагает интерфейс к коду C/C++.
- Код C/C++, который вызывается из C#.
Давайте посмотрим, как это работает, создав очень простую библиотеку Interop, которая считывает идентификатор оборудования и выполняет «сложный» расчет.
Создание библиотеки C# (управляемой)
Создайте новый проект .NET nanoFramework в Visual Studio
Это самый первый шаг. Откройте Visual Studio, Файл, Новый проект.
Перейдите в папку C# .NET nanoFramework и выберите тип проекта Class Library.
Для этого примера мы назовем проект «NF.AwesomeLib».
Перейдите в свойства проекта (щелкните значок проекта в проводнике решений и перейдите в окно свойств) и перейдите к представлению свойств конфигурации nanoFramework. Установите опцию «Generate stub files» на YES, а корневое имя на NF.AwesomeLib.
Теперь переименуйте Class1.cs, который Visual Studio добавляет по умолчанию, в Utilities.cs. Убедитесь, что имя класса внутри этого файла тоже переименовано. Добавьте новый класс с именем Math.cs. В обоих случаях убедитесь, что класс является публичным.
Теперь ваш проект должен выглядеть следующим образом.
Добавление методов API и заглушек
Следующим шагом будет добавление методов и/или свойств, которые вы хотите иметь в управляемом API на C#. Это те методы, которые будут вызываться из проекта C#, ссылающегося на вашу библиотеку Interop.
Мы добавим свойство HardwareSerial в класс Utilities и вызовем нативный метод, поддерживающий API на нативном конце. Например, так.
namespace NF.AwesomeLib
{
public class Utilities
{
private static byte[] _hardwareSerial;
/// <summary>
/// Gets the hardware unique serial ID (12 bytes).
/// </summary>
public static byte[] HardwareSerial
{
get
{
if (_hardwareSerial == null)
{
_hardwareSerial = new byte[12];
NativeGetHardwareSerial(_hardwareSerial);
}
return _hardwareSerial;
}
}
#region Stubs
[MethodImpl(MethodImplOptions.InternalCall)]
private static extern void NativeGetHardwareSerial(byte[] data);
#endregion stubs
}
}
Обратите внимание, что, кроме строк, вы можете использовать любые стандартные типы в аргументах методов Interop. Можно использовать и массивы таких типов.
Несколько пояснений к вышесказанному:
- Свойство
HardwareSerial
имеет только геттер, потому что мы только считываем серийник с оборудования. Поскольку это нельзя записать, нет смысла предоставлять сеттер, верно? - Серийный номер хранится в резервном поле, чтобы быть более эффективным. Когда он считывается в первый раз, он будет считывать его из процессора. При последующих обращениях в этом не будет необходимости.
- Обратите внимание на итоговый комментарий к свойству. Visual Studio использует его для создания XML-файла, который заставляет замечательный IntelliSense показывать документацию в проектах, ссылающихся на библиотеку.
- Серийный номер процессора обрабатывается как массив байтов длиной 12. Эти данные были взяты из руководства по эксплуатации устройства.
- Метод-заглушка должен существовать для того, чтобы Visual Studio могла создать заполнитель для C/C++ кода. Поэтому необходимо иметь по одному методу для каждой заглушки, которая требуется.
- Методы-заглушки должны быть реализованы как extern и украшены атрибутом MethodImplAttribute. В противном случае Visual Studio не сможет сделать свою магию.
- Возможно, вы захотите найти рабочую для вас систему именования заглушек и их размещения в классе. Может быть, вы хотите сгруппировать их в регионе, или предпочитаете держать их вдоль вызывающего метода. Это будет работать в любом из этих вариантов, просто подсказка, как все организовать.
Переходим к классу Math. Теперь мы добавим метод API под названием SuperComplicatedCalculation и соответствующую заглушку. Это будет выглядеть следующим образом:
namespace NF.AwesomeLib
{
public class Math
{
/// <summary>
/// Crunches value through a super complicated and secret calculation algorithm.
/// </summary>
/// <param name="value">Value to crunch.</param>
/// <returns></returns>
public double SuperComplicatedCalculation(double value)
{
return NativeSuperComplicatedCalculation(value);
}
#region Stubs
[MethodImpl(MethodImplOptions.InternalCall)]
private static extern double NativeSuperComplicatedCalculation(double value);
#endregion stubs
}
}
И это все, что требуется на управляемой стороне! Соберите проект и посмотрите на папку проекта (например, с помощью VS Code). Вот как она будет выглядеть после успешной сборки:
Сверху вниз вы можете определить в папке bin (debug или release в зависимости от предпочтений сборки) библиотеку .NET, на которую будут ссылаться другие проекты. Обратите внимание, что кроме файла .dll есть файл .xml (тот, который позволяет IntelliSense делать свою магию), файл .pdb и еще один с расширением .pe.
При распространении библиотеки Interop убедитесь, что вы упаковали все четыре файла. Если этого не сделать, Visual Studio будет жаловаться, что проект не может собраться. Вы можете распространять все эти файлы в ZIP или, еще лучше, в виде пакета NuGet.
Работа над C/C++ (родным) кодом
Перейдя в папку Stubs, мы можем найти кучу файлов и файл .cmake. Все это необходимо при сборке образа nanoCLR, который добавит поддержку вашей библиотеки Interop.
Посмотрите на имена файлов: они соответствуют именованию пространств имен и классов в проекте Visual Studio.
Очень, очень важно: не переименовывайте и не изменяйте содержимое этих файлов. Если вы это сделаете, вы рискуете, что сборка образа завершится неудачей, или вы также можете оказаться в ситуации, когда библиотека Interop ничего не делает. Это может быть очень неприятно и сложно для отладки. Поэтому, повторюсь, НЕ возитесь с этими файлами!
Единственным исключением, конечно, будут те, которые включают заглушки для кода на C/C++, который мы будем добавлять. Это файлы .cpp, которые заканчиваются именем класса.
В нашем примере это: NF_AwesomeLib_NF_AwesomeLib_Math.cpp
и NF_AwesomeLib_NF_AwesomeLib_Utilities.cpp
.
Вы, вероятно, также заметили, что есть еще несколько файлов с похожим именем, но заканчивающимся на _mshl. Это файлы маршалинга, которые отвечают за валидацию, проверку на вменяемость и маршалинг интерфейса между управляемым и родным кодом. Их следует оставить в покое. И снова НЕ изменяйте их!
Давайте посмотрим на файл-заглушку для класса Utilities. Это тот, который будет считывать серийный номер процессора.
void Utilities::NativeGetHardwareSerial( CLR_RT_TypedArray_UINT8 param0, HRESULT &hr )
{
}
Это пустая функция C++, названная в честь класса и метода-заглушки, которые вы разместили в проекте C#.
Давайте немного разберемся, что мы здесь имеем.
- Возвращаемое значение функции C++ совпадает с типом метода-заглушки C#. В данном случае это void.
- Первый аргумент имеет тип, который сопоставляется между типом C# и эквивалентным типом C++. В данном случае это массив байтов.
- Последний аргумент — тип HRESULT, назначение которого — сообщить результат выполнения кода. Мы еще вернемся к этому, так что пока не беспокойтесь об этом. Просто поймите, для чего это нужно.
Согласно руководству по программированию, устройства STM32F4 имеют 96-битный (12 байт) уникальный серийный номер, который хранится, начиная с адреса 0x1FFF7A10. Для STM32F7 этот адрес 0x1FF0F420. В других сериях STM32 идентификатор может располагаться по другому адресу. Теперь, когда мы знаем, где он хранится, мы можем добавить код для его чтения. Сначала я начну с кода, а затем пройдусь по нему.
void Utilities::NativeGetHardwareSerial( CLR_RT_TypedArray_UINT8 param0, HRESULT &hr )
{
if (param0.GetSize() < 12)
{
hr=CLR_E_BUFFER_TOO_SMALL;
return;
}
memcpy((void*)param0.GetBuffer(), (const void*)0x1FFF7A10, 12);
}
Первый оператор if — это проверка на вменяемость, чтобы убедиться, что в массиве достаточно места для хранения байтов серийного номера. Почему это важно?
Помните, что здесь мы уже не в мире C#, где CRL и Visual Studio заботятся о сложном за нас. В C++ все совсем по-другому! В данном конкретном примере, если бы вызывающая сторона не зарезервировала необходимые 12 байт в памяти для хранения полного последовательного массива при записи в него, то 12 байт из последовательного массива могли бы перезаписать что-то, что хранится в памяти перед адресом аргумента. Для типов, отличных от указателей, таких как байты, целые числа и двойки, эта проверка не требуется.
Еще в операторе if видно, что если места недостаточно, то мы не можем продолжать. Перед возвратом кода мы устанавливаем hr в CLR_E_BUFFER_TOO_SMALL (это аргумент, в котором хранится результат выполнения, помните?). Это сигнализирует о том, что что-то пошло не так, и дает некоторую подсказку, что это может быть. Об этом аргументе результата еще многое можно сказать, поэтому мы еще вернемся к нему.
В следующем фрагменте кода мы, наконец, считываем серийный номер устройства.
Поскольку серийный номер доступен в адресе памяти, мы можем просто использовать memcpy
для копирования его из памяти в аргумент.
Несколько замечаний о типе аргумента (CLR_RT_TypedArray_UINT8
). Он действует как обертка для блока памяти, в котором хранится массив (или указатель, если хотите). Класс для этого типа предоставляет функцию GetBuffer()
, которая возвращает фактический указатель, обеспечивающий прямой доступ к нему. Нам это нужно, потому что мы должны передать указатель при вызове memcpy
. Это может показаться немного сложным, конечно. Если вам интересны детали реализации или вы хотите узнать, как это работает, я предлагаю вам углубиться в код интерпретатора .NET nanoFramework в нашем репозитории GitHub.
И это все! Когда эта функция вернется, серийный номер процессора будет находиться в указателе аргумента и в конечном итоге появится в управляемом коде C# в этом аргументе с тем же именем.
Для класса Math
не будет никаких обращений к аппаратуре или других причудливых вещей, просто сложное и секретное вычисление, чтобы проиллюстрировать использование Interop для простого выполнения кода.
Visual Studio уже сгенерировала красивую заглушку, которую нам предстоит заполнить кодом. Вот оригинальная заглушка:
double Math::NativeSuperComplicatedCalculation( double param0, HRESULT &hr )
{
double retVal = 0;
return retVal;
}
Обратите внимание, что функция-заглушка, опять же, соответствует объявлению своего управляемого аналога на C# и, опять же, имеет аргумент hr
для возврата результата выполнения.
Visual Studio была достаточно добра, чтобы добавить туда код для возвращаемого значения, так что мы можем начать кодировать поверх этого. Это необходимо, иначе этот код даже не скомпилируется 😉.
Где находится супер сложный и секретный алгоритм:
double Math::NativeSuperComplicatedCalculation( double param0, HRESULT &hr )
{
double retVal = 0;
retVal = param0 + 1;
return retVal;
}
И на этом мы завершаем нативную «низкоуровневую» реализацию нашей библиотеки Interop.
Добавление библиотеки Interop в образ nanoCLR
Последний шаг, которого не хватает, это добавление файлов исходного кода Interop в сборку образа nanoCLR.
Вы можете разместить файлы кода практически в любом месте. Либо в том же дереве исходников, либо в другом месте. В репозитории интерпретатора nanoFramework есть папка Interop
, которую вы можете использовать именно для этого: хранить папки сборок Interop, которые у вас есть. Любые изменения внутри этой папки не будут подхвачены Git’ом.
Для простоты мы последуем этому и просто скопируем то, что находится в папке Stubs, в новую папку InteropAssembliesNF.AwesomeLib
.
Следующий файл, который привлечет наше внимание — FindINTEROP-NF.AwesomeLib.cmake
. .NET nanoFramework использует CMake в качестве системы сборки. Пропуская технические подробности, достаточно сказать, что для CMake сборка Interop будет рассматриваться как модуль CMake, и поэтому имя файла, чтобы он был правильно включен в сборку, должно называться FindINTEROP-NF.AwesomeLib.cmake и должно быть помещено в папку CMakeModules.
Внутри этого файла единственное, что требует вашего внимания, это первое утверждение, где объявляется местоположение папки с исходным кодом.
(...)
# native code directory
set(BASE_PATH_FOR_THIS_MODULE "${BASE_PATH_FOR_CLASS_LIBRARIES_MODULES}/NF.AwesomeLib")
(...)
Если вы размещаете его внутри папки Interop
, необходимые изменения будут следующими:
(...)
# native code directory
set(BASE_PATH_FOR_THIS_MODULE "${PROJECT_SOURCE_DIR}/InteropAssemblies/NF.AwesomeLib")
(...)
Вот и все! Теперь перейдем к сборке.
Пожалуйста, обратитесь к документации, упомянутой выше, об использовании Dev Containers или настройке локального инструментария для сборки образа прошивки .NET nanoFramework.
Предположим, что вы используете модуль CMake Tools для сборки внутри VS Code. Вам нужно объявить, что вы хотите добавить эту сборку Interop в сборку. Для этого откройте файл CMakeUserPresets.json и перейдите к настройкам цели, к которой вы хотите добавить сборку.
Там вам нужно добавить следующий параметр сборки CMake:
"cacheVariables": {
"TARGET_BOARD": {
"type": "STRING",
"value": "${presetName}"
},
"NF_INTEROP_ASSEMBLIES": [ "NF.AwesomeLib" ],
}
Несколько замечаний по этому поводу:
Опция NF_INTEROP_ASSEMBLIES
ожидает коллекцию. Это связано с тем, что вы можете добавить столько библиотек Interop, сколько хотите, в образ прошивки nanoCLR.
Имя сборки должно точно совпадать с именем класса. Точки включены. Если вы ошибетесь, вы сразу заметите это в сборке.
Следующая задача — запуск сборки образа. Будем надеяться, что ошибок не будет… 😉.
Сначала проверьте вывод подготовки CMake, вы должны увидеть библиотеку Interop в списке:
После успешного завершения сборки вы должны увидеть что-то похожее на это:
Теперь у нас есть образ прошивки nanoCLR, готовый к прошивке на реальной плате!
Следующей проверкой после загрузки мишени с образом прошивки nanoCLR, включающим библиотеку Interop, будет появление ее в списке Native Assemblies. После загрузки мишень появится в списке Visual Studio Device Explorer, а после нажатия на кнопку Device Capabilities вы увидите её в окне вывода, как показано ниже:
Использование библиотеки Interop
Это работает так же, как и любая другая библиотека .NET, которую вы используете каждый день. В Visual Studio откройте диалог Add reference и найдите файл NF.AwesomeLib.dll, который был выходным результатом сборки Interop Project (вы найдете его в папке bin). В процессе поиска обратите внимание на сопутствующий XML-файл с тем же именем. В этом файле вы увидите комментарии к документации, отображаемые IntelliSense по мере написания кода.
Это код для тестирования библиотеки Interop. В первой части мы считываем серийный номер процессора и выводим его в виде строки в шестнадцатеричном формате. Во второй части мы вызываем метод, который обрабатывает входное значение.
public static void Main()
{
// testing cpu serial number
string serialNumber = "";
foreach (byte b in Utilities.HardwareSerial)
{
serialNumber += b.ToString("X2");
}
Debug.WriteLine("cpu serial number: " + serialNumber);
// test complicated calculation
NF.AwesomeLib.Math math = new NF.AwesomeLib.Math();
double result = math.SuperComplicatedCalculation(11.12);
Debug.WriteLine("calculation result: " + result);
Thread.Sleep(Timeout.Infinite);
}
Вот скриншот Visual Studio, запускающего тестовое приложение. Обратите внимание на серийный номер и результат вычислений в окне Output (зеленым цветом). Кроме того, DLL указана в списке ссылок проекта (желтым цветом).
Подведение итогов
Вот и все! Мы увидели, насколько мощной является эта функция Interop, и, надеюсь, проиллюстрировали, как можно (повторно) использовать код C/C++ в приложении .NET nanoFramework C#!
Приведенный выше материал описывает ключевые аспекты и шаги для обеспечения работы Interop. Это сложная функция, и вы можете прочитать более тщательное и подробное описание всего этого в этой статье блога.
Кроме того, вы можете найти код, связанный с Interop в .NET nanoFramework, в нашем репозитории образцов.
Наслаждайтесь и получайте удовольствие от кодирования на .NET C# для микроконтроллеров!