Introduction
Have you ever faced the situation of needing to add support for a specific hardware? Or to perform some computing intensive task that would be more efficiently executed in C/C++ rather than with managed C# code?
This is possible with the support that .NET nanoFramework has to plug "code extensions". It's called Interop.
What exactly does this? Allows you to add C/C++ code (any code, really!) along with the correspondent C# API.
The C/C++ code of the Interop library is added to a nanoFramework image along with the rest of the nanoCLR.
As for the C# API: that one is compiled into a nice .NET nanoFramework library that you can reference in Visual Studio, just like you usually do.
The fact that this is treated as an extension of the core is intended and, in fact, very positive and convenient. A couple of reasons:
- Doesn't require any changes in the main core code (which can be broken or may prove difficult to merge with changes from the main repository).
- Keeps your code completely isolated from the rest. Meaning that you can manage and change it as needed without breaking anyone's stuff.
How cool is this? :)
For the purpose of this post, we are going to create an Interop project that includes two features:
- Hardware related: reads the serial number of the CPU (this will only work on ST parts)
- Software only related: implementing a super complicated and secret algorithm to crunch a number
Requirements
It's presumed that you have properly setup your build environment and toolchain and are able to build a working nanoFramework image. If you don't, I suggest that you take a look at the documentation about it here and here.
Before We Start
Before we start coding, there are a few aspects that you might want to consider before actually starting the project.
Consider the naming of the namespace(s) and class(es) that you'll be adding. Those should have meaningful names. You'll see latter on that these names will be used by Visual Studio to generate code and other bits of the Interop project. If you start with something and keep changing it, you might find yourself in trouble because your version control system will find differences. Not to mention that other users of your Interop library (or even you) might start getting breaking changes in the API that you are providing them. (You don't like when others do that to you, do you? So... be a pal and pay attention to this OK?)
Creating the C# (Managed) Library
Create a new .NET nanoFramework project in Visual Studio.
This is the very first step. Open Visual Studio, File, New Project.
Navigate to C# nanoFramework folder and select a Class Library project type.
For this example, we'll call the project "NF.AwesomeLib
".
Open the Project properties and navigate to the nanoFramework configuration properties view.
Set the "Generate stub files" option to YES and the root name to NF.AwesomeLib.
Now rename the Class1.cs that Visual Studio adds by default to Utilities.cs.
Make sure that the class name inside that file gets renamed too. Add a new class named Math.cs.
On both make sure that the class is public
. Your project should now look like this:
Adding the API Methods and the Stubs
The next step will be adding the methods and/or properties that you want to expose on the C# managed API. These are the ones that will be called on a C# project referring your Interop library. We'll add a HardwareSerial
property to Utilities
class and call to the native method that supports the API at the native end. Like this:
using System;
using System.Runtime.CompilerServices;
namespace NF.AwesomeLib
{
public class Utilities
{
private static byte[] _hardwareSerial;
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
}
}
A few explanations on the above:
- The property
HardwareSerial
has a only a getter because we are only reading the serial from the processor. As that can't be written, it doesn't make sense providing a setter, right? - The serial number is being stored in a backing field to be more efficient. When it's read the first time, it will go and read it from the processor. On subsequent accesses, that won't be necessary.
- Note the summary comment on the property. Visual Studio uses that to generate an XML file that makes the awesome IntelliSense show that documentation on the projects referencing the library.
- The serial number of the processor is handled as an array of bytes with length of 12. This was taken from the device manual.
- A stub method must exist to enable Visual Studio to create the placeholder for the C/C++ code. So you need to have one for each stub that is required.
- The stub methods must be implemented as
extern
and be decorated with the MethodImplAttribute
attribute. Otherwise, Visual Studio won't be able to do its magic. - You may want to find a working system for you regarding the stub naming and where you place them in the class. Maybe you want to group them in a region, or you prefer to keep them along the caller method. It will work on any of those ways, just a hint on keeping things organized.
Moving on to the Math
class. We'll now add an API method called SuperComplicatedCalculation
and the respective stub. It will look like this:
using System;
using System.Runtime.CompilerServices;
namespace NF.AwesomeLib
{
public class Math
{
public double SuperComplicatedCalculation(double value)
{
return NativeSuperComplicatedCalculation(value);
}
#region Stubs
[MethodImpl(MethodImplOptions.InternalCall)]
private static extern double NativeSuperComplicatedCalculation(double value);
#endregion stubs
}
}
And this is all that's required on the managed side.
Build the project and look at the project folder using VS Code for example.
This is how it looks like after a successful build:
From the top, you can see in the bin folder (debug or release flavor) the .NET library that should be referenced in other projects. Please note that besides the .dll file, there is the .xml file (the one that will allow IntelliSense to its thing), the .pdb file and another one with a .pe extension. When distributing the Interop library, make sure that you supply all four files. Failing to do so will make Visual Studio complain that the project can't build. You can add all those in a ZIP or even better, as a Nuget package.
Working on the C/C++ (native) Code
Moving to the Stubs folder, we find a bunch of files and a .cmake file. All those are required when building the nanoCLR image that will add support for your Interop library.
Look at the file names: they follow the namespace and classes naming in the Visual Studio project.
Something very, very important: don't even think on renaming or messing with the content of those files. If you do that, you risk that the image build will fail or you can also end up with the Interop library not doing anything. This can be very frustrating and very hard to debug. So, again, DO NOT mess around with those!
The only exception to that will be, of course, the ones that include the stubs for the C/C++ code that we need to add. Those are the .cpp files that end with the class name. In our example, those are NF_AwesomeLib_NF_AwesomeLib_Math.cpp and NF_AwesomeLib_NF_AwesomeLib_Utilities.cpp.
You've probably noted that there are a couple of other files with a similar name but ending with _mshl. Those are to be left alone. Again DO NOT change them.
Let's look at the stub file for the Utilities
class. That's the one that will read the processor serial number.
void Utilities::NativeGetHardwareSerial( CLR_RT_TypedArray_UINT8 param0, HRESULT &hr )
{
}
This is an empty C++ function named after the class and the stub method that you've placed in the C# project.
Let's take a moment to understand what we have here.
- The return value of the C++ function matches the type of the C# stub method. Which is
void
in this case. - The first argument has a type that is mapping between the C# type and the equivalent C++ type. A array of bytes in this case.
- The last argument is an
HRESULT
type whose purpose is to report the result of the code execution. We'll get back to this so don't worry about it for now. Just understand what's the purpose of it.
According to the programming manual, STM32F4 devices have a 96 bits (12 bytes) unique serial number that is stored starting at address 0x1FFF7A10. For STM32F7, that address is 0x1FF0F420. In other STM32 series, the ID may be located in a different address. Now that we know where it is stored, we can add code to read it. I'll start with the code first and then walk through it.
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);
}
The first if
statement is a sanity check to be sure that there is enough room in the array to hold the serial number bytes. Why is this important? Remember that here, we are not in the C# world anymore where the CRL and Visual Studio take care of the hard stuff for us. In C++, things are very different!
On this particular example, if the caller wouldn't have reserved the required 12 bytes in memory to hold the serial array, when writing onto there, the 12 bytes from the serial could be overwriting something that is stored in the memory space ahead of the argument address. For types other than pointers such as bytes, integers and doubles, this check is not required.
Still on the if
statement, you can see that if there is not enough room, we can't continue. Before the code returns, we are setting hr
to CLR_E_BUFFER_TOO_SMALL
(that's the argument that holds the execution result, remember?). This is to signal that something went wrong and give some clue on what that might be. There is still more to say about this result argument, so we'll get back to it.
In the next piece of code is where - finally - we are reading the serial from the device. As the serial number is accessible in a memory address, we can simply use a memcpy
to copy it from its memory location to the argument.
A few comments about the argument type (CLR_RT_TypedArray_UINT8
). It acts like a wrapper for the memory block that holds the array (or a pointer if you prefer). The class for that type provides a function - called GetBuffer()
- that returns the actual pointer that allows direct access to it. We need that because we have to pass a pointer when calling memcpy
. This may sound a bit complicated, I agree. If you have curiosity on the implementation details or want to know how it works, I suggest that you delve into the nanoFramework repo code and take a look at all this.
And that's it! When this function returns, the CPU serial number will be in the argument pointer and will eventually pop up in the C# managed code in that argument with the same name.
For the Math
class, there won't be any calls to hardware or any other fancy stuff, just a complicated and secret calculation to illustrate the use of Interop for simple code execution.
Visual Studio has already generated a nice stub for us to fill in with code. Here's the original stub:
double Math::NativeSuperComplicatedCalculation( double param0, HRESULT &hr )
{
double retVal = 0; return retVal;
}
Note that the stub function, again, matches the declaration of its C# managed counterpart and, again, has that hr
argument to return the execution result. Visual Studio was kind enough to add there the code for the return value so we can start coding on that. Actually, that has to be exactly there, otherwise this code wouldn't even compile. ;)
Where is the super complicated and secret algorithm:
double Math::NativeSuperComplicatedCalculation( double param0, HRESULT &hr )
{
double retVal = 0;
retVal = param0 + 1;
return retVal;
}
And with this, we complete the "low level" implementation of our Interop library.
Adding the Interop Library to a nanoCLR Image
The last step that is missing is actually adding the Interop source code files to the build of a nanoCLR image.
You can place the code files pretty much anywhere you want it... the repo has a folder named Interop that you can use for exactly this: holding the folders of the Interop assemblies that you have. Any changes inside that folder won't be picked up by Git. To make it simple, we'll follow that and we just copy what is in the Stubs folder into a new folder InteropAssemblies\NF_AwesomeLib\.
The next file to get our attention is FindINTEROP-NF.AwesomeLib.cmake. nanoFramework uses CMake
to generate the build files. Skipping the technical details suffice that you know that as far as CMake
is concerned, the Interop assembly is treated as a CMake
module and, because of that, the file name to have it properly included in the build it has to be named FindINTEROP-NF.AwesomeLib.cmake and be placed inside the CMake\Modules folder.
Inside that file, the only thing that requires your attention is the first statement where the location of the source code folder is declared.
(...)
# native code directory set(BASE_PATH_FOR_THIS_MODULE
"${BASE_PATH_FOR_CLASS_LIBRARIES_MODULES}/NF.AwesomeLib")
(...)
If you are placing it inside that Interop folder, the required changes are:
(...)
# native code directory set(BASE_PATH_FOR_THIS_MODULE
"${PROJECT_SOURCE_DIR}/InteropAssemblies/NF.AwesomeLib")
(...)
And this is it! Now to the build.
If you are using the CMake Tools module to build inside VS Code, you need to declare that you want this Interop assembly added to the build. Do so by opening the cmake-variants.json file and navigate to the settings for the image you want it added. There, you need to add the following CMake
option (in case you don't already have it there.
"NF_INTEROP_ASSEMBLIES" : [ "NF.AwesomeLib" ],
A couple of notes about this:
- The
NF_INTEROP_ASSEMBLIES
option expects a collection. This is because you can have as many Interop assemblies as you need to the nanoCLR image. - The name of the assembly must exactly match the class name. Dots included. If you screw up this, you'll notice it in the build.
In case you are calling CMake directly from the command prompt, you have to add this option to the call like this:
-DNF_INTEROP_ASSEMBLIES=["NF.AwesomeLib"]
It's important to stress this: make sure you strictly follow the above.
Mistakes such as: failing to add the CMake
find module file to the modules folder; having it named something else; having the sources files in a directory other that the one that was declared; will lead to errors or the library wont' be included in the image. This can lead very quickly to frustration. So, please, be very thorough with this part.
The following task is launching the image build. It's assumed that you have properly setup your build/toolchain so go ahead and launch that build!
Fingers crossed that you won't get any errors... ;)
First check is on the CMake preparation output you should see the Interop library listed:
A successful CMake preparation stage (that includes the Interop assembly as listed above) will end with:
After the build completes successfully, you should be seeing something similar to this:
Reaching this step is truly exciting, isn't it?! :)
Now go and load the image on a real board!
The next check after loading a target with the nanoCLR image that includes the Interop library is seeing it listed in the Native Assemblies listing. After booting, the target is listed in Visual Studio Device Explorer list and after you click on the Device Capabilities button, you'll see it in the output window like this:
Congratulations, you did it! :D Let's go now and start using the Interop library.
Using an Interop Library
This works just like any other .NET library that you use everyday. In Visual Studio, open the Add reference dialog and search for the NF.AwesomeLib.dll file that was the output result of building the Interop Project. You'll find it in the bin folder. As you are going through that, note the companion XML file with the same name. With that file there, you'll see the documentation comments showing in IntelliSense as you code.
This is the code to test the Interop library. In the first part, we read the CPU serial number and output it as an hexadecimal formatted string. In the second, we call the method that crunches the input value.
using System;
using System.Threading;
using NF.AwesomeLib;
namespace Test.Interop
{
public class Program
{
public static void Main()
{
string serialNumber = "";
foreach (byte b in Utilities.HardwareSerial)
{
serialNumber += b.ToString("X2");
}
Console.WriteLine("cpu serial number: " + serialNumber);
NF.AwesomeLib.Math math = new NF.AwesomeLib.Math();
double result = math.SuperComplicatedCalculation(11.12);
Console.WriteLine("calculation result: " + result);
Thread.Sleep(Timeout.Infinite);
}
}
}
Here's a screen shot of Visual Studio running the test app.
Note the serial number and the calculation result in the Output window (in green). Also, the DLL listed in the project references (in yellow).
Supported Types in Interop Method Calls
Except for string
s, you're free to use any of the standard types in the arguments of the Interop methods. It's OK to use arrays of those too.
As for the return data, in case you need it, you are better using arguments passed by reference and update those in C/C++. Just know that arrays as returns types or by reference parameters are not supported.
Follows a table of the supported types and correspondence between platforms/languages.
CLR Type | C/C++ type | C/C++ Ref Type (C# ref) | C/C++ Array Type |
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 |
Final Notes
To wrap this up, I would like to point out some hints and warnings that can help you further when dealing with this Interop library stuff.
- Not all CLR types are supported as arguments or return values for the Interop stubs in the C# project. If the project doesn't build and shows you an enigmatic error message, that's probably the reason.
- Every time the Interop C# project is built, the stub files are generated again. Because of this, you may want to keep on a separate location the ones that you've been adding code to. Using a version control system and a proper diff tool will help you merge any changes that are added because of changes in the C# code. Those can be renames, adding new methods, classes, etc.
- When Visual Studio builds the Interop C# project, a fingerprint of the library is calculated and included in the native code. You can check this in the NF_AwesomeLib.cpp file (in the stub folder). Look for the Assembly name and an hexadecimal number right below. This is what .NET nanoFramework uses to check if the native counterpart for that particular assembly is available in the device before it deploys an application. And when I say that, I mean it. If you change anything that might break the interface (such a method name or an argument), it will. On the "client" project, Visual Studio will complain that the application can't be deployed. Those changes include the project version in the C# Interop project too, so you can use this as you do with any project version number.
- The
hr
(return parameter) is set to S_OK
by default, so if nothing goes wrong in the code you don't have to change it. When there are errors, you can set it to an appropriate value that will surface in C# as an exception. You may want to check the src\CLR\Include\nf_errors_exceptions.h file in the nanoFramework repo. - Feel free to mix managed code to your C# Interop project too. If you have a piece of C# code that helps you achieve the library goal, just add it there. As long as it builds, anything is valid either before or after the calls to the C/C++ stubs. If it helps and makes sense to be in the library, just add it there. You can even get crazy and call as many C/C++ stubs as you want inside a C# method.
And that's all! You can find all the code related with this blog post in nanoFramework samples repo.
With all this, I expect I was able to guide you through this very cool (and handy!) feature of .NET nanoFramework. Enjoy it!