Interop in .NET nanoFramework





5.00/5 (1投票)
使用Interop从C#(托管)库调用.NET nanoFramework中的原生(C/C++)代码
引言
你是否曾遇到需要添加对特定硬件支持的情况?或者需要执行一些计算密集型任务,而这些任务用C/C++执行比用托管C#代码更有效率?
这在.NET nanoFramework 支持插件“代码扩展”的情况下是可能的。这被称为Interop。
这到底是什么作用?它允许你添加C/C++代码(实际上是任何代码!)以及相应的C# API。
Interop库的C/C++代码会与nanoCLR的其他部分一起添加到nanoFramework映像中。
至于C# API:它会被编译成一个.NET nanoFramework库,你可以在Visual Studio中像平常一样引用它。
这是一个核心扩展的事实是故意的,而且实际上是非常积极和方便的。有几个原因:
- 不需要对主核心代码进行任何更改(这些代码可能已损坏或与主存储库的更改合并困难)。
- 使你的代码完全独立于其他代码。这意味着你可以根据需要管理和更改它,而不会破坏任何人的东西。
这有多酷?:)
为了本帖的目的,我们将创建一个包含两个功能的Interop项目:
- 与硬件相关:读取CPU序列号(这仅适用于ST系列芯片)
- 仅与软件相关:实现一个超级复杂且秘密的算法来处理数字
要求
假设你已经正确设置了构建环境和工具链,并且能够构建一个工作的nanoFramework映像。如果不行,我建议你查看有关此内容的文档,请参阅此处和此处。
开始之前
在开始编码之前,有几个方面可能需要在实际开始项目之前考虑。
考虑要添加的命名空间和类的命名。这些名称应该有意义。稍后你会看到,Visual Studio会使用这些名称来生成Interop项目的代码和其他部分。如果你一开始就用一个名字,然后不断更改它,你可能会发现自己陷入困境,因为你的版本控制系统会发现差异。更不用说你的Interop库的其他用户(甚至你自己)可能会收到你提供的API中的重大更改。(你也不喜欢别人这样做,对吧?所以...做一个朋友,注意这一点,好吗?)
创建C#(托管)库
在Visual Studio中创建一个新的.NET nanoFramework项目。
这是第一步。打开Visual Studio,文件,新建项目。
导航到C# nanoFramework 文件夹并选择“类库”项目类型。
在这个例子中,我们将项目命名为“NF.AwesomeLib
”。
打开项目属性并导航到nanoFramework配置属性视图。
将“生成存根文件”选项设置为是,并将根名称设置为NF.AwesomeLib。
现在将Visual Studio默认添加的Class1.cs重命名为Utilities.cs。
确保该文件中的类名也得到重命名。添加一个名为Math.cs的新类。
在这两者中,都要确保类是public
的。你的项目现在应该看起来像这样:
添加API方法和存根
下一步是添加你希望在C#托管API中公开的方法和/或属性。这些是将在引用你的Interop库的C#项目中调用的方法。我们将向Utilities
类添加一个HardwareSerial
属性,并调用原生端支持该API的原生方法。像这样:
using System;
using System.Runtime.CompilerServices;
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
}
}
对以上内容的一些解释:
- 属性
HardwareSerial
只有一个getter,因为我们只读取处理器中的序列号。由于无法写入,提供setter没有意义,对吧? - 序列号存储在后备字段中以提高效率。当它第一次被读取时,它将从处理器读取。在后续访问时,这将不是必需的。
- 注意属性上的摘要注释。Visual Studio使用它来生成一个XML文件,该文件使强大的IntelliSense在引用该库的项目中显示该文档。
- 处理器的序列号是一个长度为12的字节数组。这是从设备手册中获取的。
- 必须存在一个存根方法才能使Visual Studio创建C/C++代码的占位符。因此,你需要为每个所需的存根都准备一个。
- 存根方法必须实现为
extern
并用MethodImplAttribute
属性进行修饰。否则,Visual Studio将无法发挥其魔力。 - 你可能想找到一个适合你的存根命名和在类中放置它们的系统。也许你想将它们分组到一个区域,或者你更喜欢将它们放在调用方法旁边。它们在这两种方式下都可以工作,只是保持事物有序的提示。
继续Math
类。现在我们将添加一个名为SuperComplicatedCalculation
的API方法和相应的存根。它看起来像这样:
using System;
using System.Runtime.CompilerServices;
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系列中,ID可能位于不同的地址。现在我们知道了它存储在哪里,我们可以添加代码来读取它。我将先给出代码,然后进行讲解。
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*)0x1FF0F420, 12);
}
第一个if
语句是一个健全性检查,以确保数组中有足够的空间来容纳序列号字节。为什么这很重要?记住,我们现在不在C#世界了,在那里CRL和Visual Studio会为我们处理棘手的事情。在C++中,情况非常不同!
在这个特定的例子中,如果调用者没有为保存序列数组分配足够的12字节内存,在写入时,来自序列的12个字节可能会覆盖参数地址前面内存空间中存储的内容。对于字节、整数和双精度等指针以外的类型,不需要此检查。
仍然在if
语句中,你可以看到,如果没有足够的空间,我们就无法继续。在代码返回之前,我们将hr
设置为CLR_E_BUFFER_TOO_SMALL
(这是保存执行结果的参数,还记得吗?)。这是为了指示发生了错误,并提供一些关于错误的线索。关于这个结果参数还有更多要说的,所以我们会回过头来。
在下一段代码中,我们终于读取了设备中的序列号。由于序列号可以通过内存地址访问,我们可以简单地使用memcpy
将其从其内存位置复制到参数中。
关于参数类型(CLR_RT_TypedArray_UINT8
)的一些注释。它就像一个内存块的包装器,其中包含数组(或者如果你愿意,可以看作是指针)。该类型的类提供了一个名为GetBuffer()
的函数,该函数返回实际的指针,允许直接访问它。我们需要它,因为在调用memcpy
时必须传递一个指针。这听起来可能有点复杂,我同意。如果你对实现细节感到好奇或想知道它是如何工作的,我建议你深入研究nanoFramework repo代码,看看所有这些。
就是这样!当这个函数返回时,CPU序列号将在参数指针中,并最终出现在C#托管代码的同名参数中。
对于Math
类,不会有任何硬件调用或其他花哨的东西,只是一个复杂而秘密的计算,以说明Interop在简单代码执行中的使用。
Visual Studio已经为我们生成了一个很好的存根来填充代码。这是原始存根:
double Math::NativeSuperComplicatedCalculation( double param0, HRESULT &hr )
{
double retVal = 0; return retVal;
}
请注意,存根函数再次与C#托管对应物的声明匹配,并且再次带有hr
参数来返回执行结果。Visual Studio很贴心地在那里添加了返回值代码,所以我们可以开始编写代码了。实际上,必须 exactly 放在那里,否则这段代码甚至无法编译。 ;)
超级复杂而秘密的算法在哪里?
double Math::NativeSuperComplicatedCalculation( double param0, HRESULT &hr )
{
double retVal = 0;
retVal = param0 + 1;
return retVal;
}
至此,我们完成了Interop库的“底层”实现。
将Interop库添加到nanoCLR映像
最后一个步骤是将Interop源代码文件添加到nanoCLR映像的构建中。
你可以将代码文件放在你想要的任何地方...存储库有一个名为Interop的文件夹,你可以确切地用于此目的:保存你的Interop程序集的文件夹。该文件夹内的任何更改都不会被Git拾取。为了简单起见,我们将遵循这一点,并将Stubs文件夹中的内容复制到一个新文件夹InteropAssemblies\NF_AwesomeLib\。
接下来需要引起我们注意的是FindINTEROP-NF.AwesomeLib.cmake文件。nanoFramework使用CMake
来生成构建文件。跳过技术细节,只需知道就CMake
而言,Interop程序集被视为一个CMake
模块,因此,要将其正确包含在构建中,文件名必须是FindINTEROP-NF.AwesomeLib.cmake,并放在CMake\Modules文件夹内。
在该文件内部,唯一需要你注意的是第一个语句,其中声明了源代码文件夹的位置。
(...)
# 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")
(...)
就是这样!现在进行构建。
如果你正在使用CMake Tools模块在VS Code内部进行构建,你需要声明你想将这个Interop程序集添加到构建中。这样做的方法是打开cmake-variants.json文件,并导航到你想添加它的映像的设置。在那里,你需要添加以下CMake
选项(以防你还没有)。
"NF_INTEROP_ASSEMBLIES" : [ "NF.AwesomeLib" ],
关于此的一些说明:
NF_INTEROP_ASSEMBLIES
选项期望一个集合。这是因为你可以根据需要向nanoCLR映像添加任意数量的Interop程序集。- 程序集的名称必须与类名完全匹配。包括点。如果你搞错了这一点,你会在构建中注意到。
如果你直接从命令行调用CMake,你必须将此选项添加到调用中,如下所示:
-DNF_INTEROP_ASSEMBLIES=["NF.AwesomeLib"]
强调这一点很重要:请确保你严格遵循以上说明。
诸如:未将CMake
查找模块文件添加到modules文件夹;将其命名为其他名称;将源文件放在声明的目录之外的目录中;等错误将导致错误,或者库将不包含在映像中。这很快就会导致沮丧。所以,请非常仔细地处理这部分。
接下来是启动映像构建。假设你已经正确设置了构建/工具链,所以继续并启动构建!
希望你不会遇到任何错误... ;)
第一个检查是CMake准备输出,你应该看到Interop库被列出。
成功的CMake准备阶段(包括上面列出的Interop程序集)将以以下内容结束:
构建成功完成后,你应该会看到类似如下的内容:
达到这一步真是令人兴奋,不是吗?:)
现在去将映像加载到真实的开发板上!
在加载包含Interop库的nanoCLR映像的目标板之后,下一个检查是查看它是否列在“本机程序集”列表中。启动后,目标板将显示在Visual Studio设备资源管理器列表中,点击“设备功能”按钮后,你将在输出窗口中看到类似以下内容:
恭喜你,你做到了!:D 现在让我们开始使用Interop库。
使用Interop库
这就像使用你日常使用的任何其他.NET库一样。在Visual Studio中,打开“添加引用”对话框,然后搜索构建Interop项目的结果文件NF.AwesomeLib.dll。你可以在bin文件夹中找到它。在你进行此操作时,请注意同名的伴随XML文件。有了这个文件,你将在编码时看到文档注释在IntelliSense中显示。
这是用于测试Interop库的代码。第一部分,我们读取CPU序列号并将其作为十六进制格式的字符串输出。第二部分,我们调用处理输入值的函数。
using System;
using System.Threading;
using NF.AwesomeLib;
namespace Test.Interop
{
public class Program
{
public static void Main()
{
// testing cpu serial number
string serialNumber = "";
foreach (byte b in Utilities.HardwareSerial)
{
serialNumber += b.ToString("X2");
}
Console.WriteLine("cpu serial number: " + serialNumber);
// test complicated calculation
NF.AwesomeLib.Math math = new NF.AwesomeLib.Math();
double result = math.SuperComplicatedCalculation(11.12);
Console.WriteLine("calculation result: " + result);
Thread.Sleep(Timeout.Infinite);
}
}
}
这是Visual Studio运行测试应用程序的截图。
注意输出窗口中的序列号和计算结果(绿色)。此外,项目引用中的DLL(黄色)。
Interop方法调用中支持的类型
除了string
之外,你可以在Interop方法的参数中使用任何标准类型。数组也支持。
至于返回数据,如果你需要的话,最好使用引用传递的参数并在C/C++中更新它们。请注意,数组作为返回类型或通过引用传递的参数不受支持。
下面是一个支持类型以及平台/语言之间对应关系的表格:
CLR类型 | C/C++类型 | C/C++引用类型(C# ref) | C/C++数组类型 |
System.Byte | uint8_t | UINT8* | CLR_RT_TypedArray_UINT8 |
System.UInt16 | uint16_t | UINT16* | CLR_RT_TypedArray_UINT16 |
System.UInt32 | uint32_t | UINT32* | CLR_RT_TypedArray_UINT32 |
System.UInt64 | uint64_t | UINT64* | CLR_RT_TypedArray_UINT64 |
System.SByte | int8_t | Char* | CLR_RT_TypedArray_INT8 |
System.Int16 | int16_t | INT16* | CLR_RT_TypedArray_INT16 |
System.Int32 | int32_t | INT32* | CLR_RT_TypedArray_INT32 |
System.Int64 | int64_t | INT64* | CLR_RT_TypedArray_INT64 |
System.Single | float | float* | CLR_RT_TypedArray_float |
System.Double | double | double* | CLR_RT_TypedArray_double |
最终注释
总结一下,我想指出一些可以帮助你进一步处理这些Interop库的技巧和警告。
- 并非所有CLR类型都支持作为Interop C#项目中的存根的参数或返回值。如果项目构建失败并显示晦涩的错误消息,那很可能是原因。
- 每次构建Interop C#项目时,都会重新生成存根文件。因此,你可能希望将已添加代码的文件保存在单独的位置。使用版本控制系统和适当的diff工具将有助于你合并由于C#代码更改而添加的任何更改。这些更改可能包括重命名、添加新方法、类等。
- 当Visual Studio构建Interop C#项目时,会计算一个库的指纹并将其包含在原生代码中。你可以在stub文件夹中的NF_AwesomeLib.cpp文件中查看此信息。查找程序集名称以及其下方的十六进制数字。这就是.NET nanoFramework在部署应用程序之前用来检查设备上是否有该特定程序集的原生对应物的。当我说该时,我是认真的。如果你更改了任何可能破坏接口的内容(如方法名称或参数),它就会生效。在“客户端”项目中,Visual Studio会抱怨应用程序无法部署。这些更改包括C# Interop项目中的项目版本,因此你可以像处理任何项目版本号一样使用它。
hr
(返回参数)默认为S_OK
,所以如果代码中没有发生任何问题,你就不必更改它。当发生错误时,你可以将其设置为适当的值,该值将作为异常出现在C#中。你可能想查看nanoFramework repo中的src\CLR\Include\nf_errors_exceptions.h文件。- 随时可以在你的C# Interop项目中混入托管代码。如果你有一段C#代码可以帮助你实现库的目标,只需将其添加进去即可。只要能构建,在调用C/C++存根之前或之后,任何东西都是有效的。如果它有帮助且有意义,可以将其添加到库中。你甚至可以疯狂到在一个C#方法中调用任意多的C/C++存根。
就是这样!你可以在nanoFramework samples repo中找到与此博文相关的所有代码。
通过这一切,我希望我能够引导你了解.NET nanoFramework这个非常酷(且实用!)的功能。尽情享用吧!