Native Contract Hash
This document describes what the nanoFramework MetadataProcessor computes as the native contract hash, which is stored in the AssemblyHeader of each PE file of the v2 format (NFMRK2), and how the native slots of the native methods are assigned.
The v1 format (
NFMRK1) has a native methods checksum in the same place of the header, computed with a different algorithm. The CLR never mixes both: a v2 runtime only loads v2 PE files. The native contract hash replaces the native methods checksum and the manually maintainedAssemblyNativeVersionattribute.
Purpose
A managed assembly with native methods is compiled together with a native counterpart (the stubs in nf-interpreter). Both have to agree on everything that the native code relies on:
- Which methods are implemented in native code and how they are identified.
- How the fields of the classes and structs are laid out, because native code accesses fields by index (the
FIELD__*andFIELD_STATIC__*constants in the stub header files).
The native contract hash is a single value that characterizes all of the above. It's computed by the MetadataProcessor from the managed assembly and stored in the PE file. It's also computed from the very same managed assembly to generate the stubs, and it's declared in the native assembly declaration (CLR_RT_NativeAssemblyData), so it's part of the firmware. The CLR doesn't compute it: it only compares the value in the PE with the one in the native declaration and refuses to load the assembly if they differ.
The hash is exact: any change on the native surface changes it, and nothing else does. In particular, it doesn't change when managed code that has no relation with the native side changes.
What is part of the contract
- The native methods of the assembly, with their native signature, in native slot order.
- The field layout of all the classes and structs of the assembly that go through stub generation: for each field, its name, its index and its type.
What is not part of the contract
- Managed methods, method bodies and the order of the methods in the PE file.
- The order of the types in the PE file and how types are used by other types, with one exception: the order of the types determines the assembly-wide static field indexes (see Field constants), so a change that moves types with static fields changes the hash.
- Enums, delegates and interfaces: only classes and structs go through stub generation. Enums shared with native code should be considered as append only: never renumber them.
- Types excluded from the PE file: types marked with the
ExcludeTypeattribute and the attribute types that the MetadataProcessor ignores. - Types generated by the compiler: closures and display classes, lambda and method group caches, iterator and async state machines, anonymous types, collection expression and inline array helpers. These are identified by attribute only: the compiler marks everything it synthesizes with
CompilerGeneratedAttribute, with the exception of inline arrays, which carryInlineArrayAttribute. Both attributes are defined in the CoreLibrary and removed from the PE file by the MetadataProcessor. A type that is nested in one of these is excluded too.
Native slots
A method that is implemented in native code is a method with no body that is not abstract (e.g. declared extern with MethodImplOptions.InternalCall).
Native methods are numbered with a native slot:
- Slots are dense: 0 to N-1, N being the number of native methods of the assembly.
- Slots are assigned by sorting the native methods (ordinal comparison) by safe class name and then by safe method name. They don't depend on the order of the types or the methods in the PE file.
- Two native methods with the same safe class name and safe method name are an error.
In the PE file, the method definition of a native method has the MD_Native flag (0x00008000) set and its RVA field holds the native slot (instead of an offset into the byte code table). See MethodDefTableEntry.
In the native declaration, the methods lookup table (method_lookup) has exactly N entries, in slot order and without any padding. The slot is the index in that table. There's no information about managed methods in it, so it doesn't grow when managed methods are added to the assembly.
Hash algorithm
The hash is a CRC32 (IEEE 802, the same implementation used for the CRCs in the PE file) with an initial value of 0, computed over a sequence of strings. Each string is converted to UTF-8 bytes followed by a NUL byte and fed to the CRC.
The strings, in this exact order, are:
- The version tag of the algorithm:
nfNativeContract/3. - The assembly name.
methods:<N>, being<N>the number of native methods.- For each native method, in slot order, two strings: the safe class name and the safe method name.
types:<M>, being<M>the number of types with field constants (see below).- For each type with field constants, sorted by safe class name (ordinal comparison):
- The safe class name.
- One string per static field, in field order:
FIELD_STATIC__<name>=<index>:<type>. - One string per instance field, in field order:
FIELD__<name>=<index>:<type>.
If the assembly has no native methods, there is no native contract and the hash is 0. That's the value stored in the PE file and the reason why such assemblies don't require a native counterpart in the firmware.
Only the types that go through stub generation are visited (see what is not part of the contract), and only the ones that have at least one field constant are counted in <M> and listed.
When the way the hash is composed changes, the version tag is bumped. This guarantees that PE files and native declarations produced by different versions of the algorithm never match by accident.
Safe class name
A safe class name is built from:
- the declaring type chain (for nested types),
- the namespace,
- the type name,
joined with underscores, and then normalized:
.becomes_- leading
_is trimmed - generic notation is removed/normalized:
- backticks (e.g.
Type`1) are replaced with_ - any
<...>generic parameter list text is removed
- backticks (e.g.
Safe method name
A safe method name is built from:
- the method name,
- a static/instance marker:
- static methods add
___STATIC__ - instance methods add
___
- static methods add
- the signature tokens, joined with
__:- return type first
- then each parameter type in order
The signature tokens are derived from a nanoCLR type encoding (e.g., primitive DATATYPE_* names with the DATATYPE_ prefix removed), with special handling for:
- single-dimension arrays (
SZARRAY_<elementType>) - byref parameters (
BYREF_<elementType>) - generic parameters (
GENERICTYPE) - pointers to primitive element types (encoded as
<primitive>ptr)
Finally, . is replaced with _ and / is removed, and generic notation is cleaned as described above.
For example, static extern int Abs(int value) in System.MathInternal becomes Library_corlib_native_System_MathInternal::Abs___STATIC__I4__I4 in the stubs.
Field constants
The stub header file of an assembly has the field constants for each class and struct:
struct Library_corlib_native_System_Exception
{
static const int FIELD___message = 1;
static const int FIELD___innerException = 2;
static const int FIELD___stackTrace = 3;
static const int FIELD__HResult = 4;
...
};
- Instance fields (
FIELD__*) are numbered starting at 1 (index 0 is the object header). The count continues from the instance fields of the base types, including a base type that lives in another assembly. Literal (constant) fields are not part of it. - Static fields (
FIELD_STATIC__*) are numbered starting at 0 and the index is assembly-wide: it continues across all the types of the assembly in the order of the types in the PE file. Static fields of types that don't have field constants (compiler-generated types, the helper type the compiler uses for embedded static data) occupy indexes too. That means that a new static field, or code that makes the compiler add a static cache, before a type may shift the indexes of the types after it. That is a real change of the native contract and the hash reflects it. - The type that goes in the hash is the field type, encoded like the types in native method signatures (e.g.
I4,STRING,OBJECT,SZARRAY_U1).
Field names
The name of the constant is the name of the field, except when that's not a valid C++ identifier. That happens with the fields generated by the compiler for the auto-properties and the primary constructor parameters:
| Field name in the managed assembly | Constant name |
|---|---|
<Name>k__BackingField |
Name |
<Probe.IFoo.Bar>k__BackingField (explicit interface implementation) |
IFoo_Bar |
<Probe.IGen<System.Int32>.Item>k__BackingField |
IGen_of_Int32_Item |
<Probe.IDict<System.String,System.Int32>.Item>k__BackingField |
IDict_of_String_Int32_Item |
<x>P (primary constructor parameter) |
x |
The rules are:
- For an auto-property backing field, the name is the property name. For an explicit interface implementation, the namespace of the interface is dropped and only the interface simple name and the member are kept, joined with
_. - Generic arguments are written as
_of_<arguments>, being nested generics written the same way. - Any other character that isn't valid in an identifier is replaced by
_, without doubling or trailing underscores. - If two static fields, or two instance fields, of the same class end up with the same constant name, the MetadataProcessor fails with an error. There's no automatic suffix, as the names are part of the contract.
Changes and the hash
| Change in the managed assembly | Hash |
|---|---|
| Add, remove or change a managed method or its body, add a private helper, add a class | unchanged |
| Reorder methods | unchanged |
| Reorder types, or change which types a method body uses (the types are ordered by usage) | unchanged, unless it changes the static field indexes (see Field constants) |
| Add or remove a lambda, async method or iterator (compiler-generated types) | unchanged, but see the note about static indexes in Field constants |
| Add, remove or change the signature of a native method | changes |
| Add, remove, reorder or change the type of a field of any class or struct | changes |
| Add a static field before a type with static field constants | changes |
| Bug fix or improvement in the native code, with the same signatures and fields | unchanged |
The hash doesn't see what the native code does, only its signatures and the fields. That's why the native code is not allowed to break the contract that the managed code was built for: bug fixes and improvements are fine, breaking changes are not. A breaking change has to start from the C# API call, with a new native method of its own, and the hash changes by itself because of it. See the versioning policy.
Types in different assemblies
The index of an instance field includes the fields inherited from the base types. A native type that derives from a type in another assembly (e.g. SocketException in System.Net derives from System.Exception in mscorlib) has the field count of its base hard coded in the stubs. If the contract hash of the assembly of the base type changes, the stubs of the assemblies with types that derive from it have to be regenerated in the same change.
Where the hash goes
In the
NativeContractHashfield of the PE AssemblyHeader.In the native declaration of the assembly (generated in the stubs):
const CLR_RT_NativeAssemblyData g_CLR_AssemblyNative_nanoFramework_System_IO_Hashing = { "nanoFramework.System.IO.Hashing", 0x639F123B, method_lookup, ARRAYSIZE(method_lookup) };In the MSBuild output
NativeContractHashof the metadata processor task. In Azure Pipelines and GitHub Actions the build also exports it as theNF_NATIVE_CONTRACT_HASHvariable, which is used to add it to the NuGet package description and tags.In the list of native assemblies that the runtime reports to the debugger tools (name and hash), and in the
native_assemblies.jsonfile that is published with each firmware package.
Notes
- This algorithm is an implementation detail shared between the tool that emits the PE file (writing the
NativeContractHash) and the tool that generates the native stub declarations and the native methods table. As long as both sides use the same algorithm, the CLR only needs to compare values. - The tools that deploy a PE file to a device (Visual Studio, Visual Studio Code and the test platform) read the hash from the header and compare it with the one reported by the runtime before the deployment starts. See Packaging and deployment.