// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using Internal.TypeSystem;
using Internal.TypeSystem.Ecma;
namespace ILCompiler.PortableCallHelpers
{
/// <summary>
/// Emits the static P/Invoke resolution table and the native-to-interpreter reverse thunks.
/// </summary>
internal sealed class PInvokeTableGenerator(InteropLogger log)
{
public void EmitPInvokeTable(TextWriter w, IEnumerable<string> pinvokeModules, List<PInvokeInfo> pinvokes)
{
// Modules an unresolved P/Invoke has already been reported for, so each is logged once.
// Only the logging is suppressed: a module a later P/Invoke does resolve - through
// [WasmImportLinkage], say - still has to make it into the table.
var skippedModules = new HashSet<string>(StringComparer.Ordinal);
var modules = new SortedSet<string>(StringComparer.Ordinal);
foreach (string module in pinvokeModules)
modules.Add(module);
// What actually gets linked in, captured before the scan below starts adding to modules.
// The lib-prefix fallback has to resolve against this rather than against modules, or an
// alias could be derived from another alias, or from a module that is only imported for
// [WasmImportLinkage] and has no archive behind it at all.
var linkedModules = new HashSet<string>(modules, StringComparer.Ordinal);
foreach (PInvokeInfo pinvoke in pinvokes)
{
if (modules.Contains(pinvoke.Module))
continue;
// A static archive is named libFoo.a, so the module list - built from the file names
// of what gets linked in - carries "libFoo", while the managed side spells the
// [DllImport] "Foo", the name it would use on Windows. That is also the name the
// runtime resolver looks up, so accept it as naming the same module.
if (linkedModules.Contains($"lib{pinvoke.Module}"))
{
modules.Add(pinvoke.Module);
log.Verbose($"Adding module {pinvoke.Module} for statically linked lib{pinvoke.Module}");
continue;
}
// Handle special modules, and add them to the list of modules otherwise, skip them
// and throw an exception at runtime if they are called.
if (pinvoke.WasmLinkage)
{
// WasmLinkage means we need to import the module
modules.Add(pinvoke.Module);
log.Verbose($"Adding module {pinvoke.Module} for WasmImportLinkage");
}
else if (pinvoke.Module == "*")
{
// Special case for * module to indicate static linking without specifying the module
modules.Add(pinvoke.Module);
log.Verbose($"Adding module {pinvoke.Module} for static linking");
}
else if (pinvoke.Module != "QCall")
{
// Unresolved module: not statically linked, [WasmImportLinkage], "*" or QCall.
// Skip it and throw at runtime if it is ever called, which is what Mono does too.
// Deliberately not a warning: assemblies routinely carry P/Invokes for platforms they
// are not running on - a NuGet package with Windows and Linux entry points, say - and
// those are never reached on wasm. P/Invoke resolution failure is a runtime condition,
// so reporting it at build time produces false positives that have to be suppressed.
if (skippedModules.Add(pinvoke.Module))
log.Verbose($"Skipping unresolved PInvoke module '{pinvoke.Module}' for method '{pinvoke.Method.OwningType}::{pinvoke.Method.Name.ToString()}' (not statically linked on wasm; will throw if called).");
}
}
w.WriteLine(
"""
//
// GENERATED FILE, DON'T EDIT
// Generated by coreclr callhelpers generator
//
#include <callhelpers.hpp>
#include <minipal/entrypoints.h>
extern "C" {
""");
var pinvokesGroupedByEntryPoint = pinvokes
.Where(l => modules.Contains(l.Module))
.OrderBy(l => l.EntryPoint, StringComparer.Ordinal)
.GroupBy(CEntryPoint, StringComparer.Ordinal);
foreach (IGrouping<string, PInvokeInfo> group in pinvokesGroupedByEntryPoint)
{
PInvokeInfo[] candidates = group.Distinct().ToArray();
PInvokeInfo first = candidates[0];
if (ShouldTreatAsVariadic(candidates))
{
string imports = string.Join(Environment.NewLine,
candidates.Select(
p => $" {p.Method} (in [{((EcmaAssembly)p.Method.Module).GetName().Name}] {p.Method.OwningType})"));
log.Warning("WASM0001", $"Found a native function ({first.EntryPoint}) with varargs in {first.Module}." +
" Calling such functions is not supported, and will fail at runtime." +
$" Managed DllImports: {Environment.NewLine}{imports}");
foreach (PInvokeInfo candidate in candidates)
candidate.Skip = true;
continue;
}
var decls = new HashSet<string>();
foreach (PInvokeInfo candidate in candidates)
{
string decl = GenPInvokeDecl(candidate);
if (decls.Add(decl))
w.WriteLine(decl);
}
}
w.Write(
"""
} // extern "C"
""");
var moduleImports = new Dictionary<string, List<string>>();
foreach (string module in modules)
{
// the order here is not important, because we use hash tables, we want it to be stable though
List<string> imports = pinvokes
.Where(l => l.Module == module && !l.Skip)
.OrderBy(l => l.EntryPoint, StringComparer.Ordinal)
.GroupBy(d => d.EntryPoint, StringComparer.Ordinal)
.Select(l =>
{
PInvokeInfo p = l.First();
// Runtime resolver looks up by managed EntryPoint.
// [WasmImportLinkage] mangles the C symbol per module,
// so emit the entry-point string explicitly rather than
// stringifying the mangled name via DllImportEntry.
if (p.WasmLinkage)
return $" {{ \"{EscapeLiteral(p.EntryPoint)}\", (void*)&{CEntryPoint(p)} }}, // {ListRefs(l)}{w.NewLine}";
return $" DllImportEntry({CEntryPoint(p)}) // {ListRefs(l)}{w.NewLine}";
})
.ToList();
moduleImports[module] = imports;
w.Write(
$$"""
static const Entry s_{{FixupSymbolName(module)}} [] = {
{{string.Concat(imports)}}};
""");
}
w.Write(
$$"""
typedef struct PInvokeTable {
const char* LibraryName;
const Entry* Entries;
size_t EntryCount;
} PInvokeTable;
static PInvokeTable s_PInvokeTables[] = {
{{string.Join($",{w.NewLine} ", modules.Select(m => $"{{\"{EscapeLiteral(m)}\", s_{FixupSymbolName(m)}, {moduleImports[m].Count}}}"))}}
};
const size_t s_PInvokeTablesCount = sizeof(s_PInvokeTables) / sizeof(s_PInvokeTables[0]);
const void* callhelpers_pinvoke_override(const char* library_name, const char* entry_point_name)
{
for (size_t i = 0; i < s_PInvokeTablesCount; i++)
{
if (strcmp(library_name, s_PInvokeTables[i].LibraryName) == 0)
{
LOG((LF_INTEROP, LL_INFO1000, "Wasm callhelpers PInvoke override for: lib: %s, entry: %s \n", library_name, entry_point_name));
return minipal_resolve_dllimport(s_PInvokeTables[i].Entries, s_PInvokeTables[i].EntryCount, entry_point_name);
}
}
return nullptr;
}
""");
static bool ShouldTreatAsVariadic(PInvokeInfo[] candidates)
{
if (candidates.Length < 2)
return false;
// Detect possible vararg entrypoint usage, where the same entrypoint is used with
// different numbers of arguments.
int firstNumArgs = candidates[0].Method.Signature.Length;
for (int i = 1; i < candidates.Length; i++)
{
if (candidates[i].Method.Signature.Length != firstNumArgs)
return true;
}
return false;
}
static string ListRefs(IGrouping<string, PInvokeInfo> l)
=> string.Join(", ", l.Select(c => ((EcmaAssembly)c.Method.Module).GetName().Name).Distinct().OrderBy(n => n, StringComparer.Ordinal));
}
public void EmitNativeToInterp(TextWriter w, List<PInvokeCallback> callbacks)
{
// Generate the native->interpreter entry functions. Native code calls these directly, so
// each one carries the native signature its caller expects, taken from the
// [UnmanagedCallersOnly] method it wraps. Only blittable parameter and return types are
// supported.
//
// Each wrapper caches the MethodDesc it dispatches to in a static and hands the arguments to
// ExecuteInterpretedMethodFromUnmanaged. The g_ReverseThunks table emitted at the end maps
// the runtime's key for a method to its wrapper, and the runtime fills that static in as it
// hands the wrapper out. An export is not handed out that way, so it resolves the static
// itself, by name, on the first call.
w.Write(
"""
//
// GENERATED FILE, DON'T EDIT
// Generated by coreclr callhelpers generator
//
#include <callhelpers.hpp>
// WASM-TODO: The method lookup would ideally be fully qualified assembly and then methodDef token.
// The current approach has limitations with overloaded methods.
extern "C" void LookupUnmanagedCallersOnlyMethodByName(const char* fullQualifiedTypeName, const char* methodName, MethodDesc** ppMD);
extern "C" void ExecuteInterpretedMethodFromUnmanaged(MethodDesc* pMD, int8_t* args, size_t argSize, int8_t* ret, PCODE callerIp);
""");
var callbackNames = new HashSet<string>();
var keys = new HashSet<string>();
callbacks.Sort(new PInvokeCallbackComparer());
foreach (PInvokeCallback cb in callbacks)
{
cb.EntrySymbol = FixedSymbolName(cb);
if (!callbackNames.Add(cb.EntrySymbol))
throw new LogAsErrorException($"Two callbacks with the same symbol '{cb.EntrySymbol}' are not supported.");
if (!keys.Add(cb.Key))
throw new LogAsErrorException($"Two callbacks with the same Name and number of arguments '{cb.Key}' are not supported.");
// That check only catches overloads of the same arity, which collide outright. Different
// arities produce distinct keys and distinct symbols, yet the export wrapper resolves its
// MethodDesc through LookupUnmanagedCallersOnlyMethodByName, which matches on the
// declaring type and the method name alone. Overloads are indistinguishable to it, so an
// export sharing its name with another callback would be handed whichever MethodDesc the
// walk reached first and would then call it with its own arguments.
//
// This is a stopgap for a lookup that cannot express what it means to ask. If the runtime
// ever resolves these unambiguously, this rejection should go away with it.
if (cb.IsExport)
RejectAmbiguousExport(cb);
int parameterCount = cb.Parameters.Length;
string argsArgs = parameterCount > 0 ? "(int8_t*)args, sizeof(args)" : "nullptr, 0";
string[] parameterCTypes = ParameterTypes(cb.Parameters).Select(MapType).ToArray();
// A cast converts a float numerically, while the slot has to carry its bits, so copy
// those instead. Every other type this emits reaches the slot unchanged through a cast.
bool CarriesBits(int i) => parameterCTypes[i] is "float" or "double";
string argsDeclaration = parameterCount > 0
? $"\n int64_t args[{parameterCount}] = {{ {string.Join(", ", Enumerable.Range(0, parameterCount).Select(i => CarriesBits(i) ? "0" : $"(int64_t)arg{i}"))} }};\n"
+ string.Concat(Enumerable.Range(0, parameterCount).Where(CarriesBits).Select(i => $" memcpy(&args[{i}], &arg{i}, sizeof(arg{i}));\n"))
: string.Empty;
string parametersDeclaration = string.Join(", ", parameterCTypes.Select((p, i) => $"{p} arg{i}"));
string arguments = string.Join(", ", Enumerable.Range(0, parameterCount).Select(i => $"arg{i}"));
string exportFunction = cb.IsExport ?
$$"""
extern "C" {{MapType(cb.ReturnType)}} {{cb.EntryPoint}}({{parametersDeclaration}})
{
{{(cb.IsVoid ? "" : "return ")}}Call_{{cb.EntrySymbol}}({{arguments}});
}
""" : string.Empty;
w.Write(
$$"""
static MethodDesc* MD_{{cb.EntrySymbol}} = nullptr;
static {{
MapType(cb.ReturnType)}} Call_{{cb.EntrySymbol}}({{parametersDeclaration}})
{{{argsDeclaration}}
// Lazy lookup of MethodDesc for the function export scenario.
if (!MD_{{cb.EntrySymbol}})
{
LookupUnmanagedCallersOnlyMethodByName("{{cb.TypeFullName}}, {{cb.AssemblyName}}", "{{cb.MethodName}}", &MD_{{cb.EntrySymbol}});
}{{
(!cb.IsVoid ? $"{w.NewLine}{w.NewLine} {MapType(cb.ReturnType)} result;" : "")}}
ExecuteInterpretedMethodFromUnmanaged(MD_{{cb.EntrySymbol}}, {{argsArgs}}, {{(cb.IsVoid ? "nullptr" : "(int8_t*)&result")}}, (PCODE)&Call_{{cb.EntrySymbol}});{{
(!cb.IsVoid ? $"{w.NewLine} return result;" : "")}}
}{{exportFunction}}
""");
}
w.Write(
$$"""
const ReverseThunkMapEntry g_ReverseThunks[] =
{
{{string.Join($",{w.NewLine}", callbacks.Select(ThunkMapEntryLine))}}
};
const size_t g_ReverseThunksCount = sizeof(g_ReverseThunks) / sizeof(g_ReverseThunks[0]);
""");
// The runtime walks every [UnmanagedCallersOnly] method the type declares, so match that.
static void RejectAmbiguousExport(PInvokeCallback cb)
{
List<string> ambiguous = [];
foreach (MethodDesc candidate in cb.Method.OwningType.GetMethods())
{
if (candidate != cb.Method
&& candidate.Name.StringEquals(cb.MethodName)
&& candidate.HasCustomAttribute("System.Runtime.InteropServices", "UnmanagedCallersOnlyAttribute"))
{
ambiguous.Add(candidate.ToString());
}
}
if (ambiguous.Count == 0)
return;
ambiguous.Add(cb.Method.ToString());
ambiguous.Sort(StringComparer.Ordinal);
throw new LogAsErrorException(
$"Exported callback '{cb.EntryPoint}' cannot be resolved at run time: '{cb.TypeFullName}' declares more than one [UnmanagedCallersOnly] method named '{cb.MethodName}', and the runtime looks them up by name alone. Give them distinct names: {string.Join(", ", ambiguous)}");
}
}
private string CEntryPoint(PInvokeInfo pinvoke)
{
if (pinvoke.WasmLinkage)
{
// We mangle the name to avoid collisions with symbols in other modules
string namespaceName = TypeNames.GetNamespace(pinvoke.Method.OwningType);
return FixupSymbolName($"{namespaceName}#{pinvoke.Module}#{pinvoke.EntryPoint}");
}
return FixupSymbolName(pinvoke.EntryPoint);
}
private string GenPInvokeDecl(PInvokeInfo pinvoke)
{
MethodSignature signature = pinvoke.Method.Signature;
TypeDesc returnType = signature.ReturnType;
List<string> parameterTypes = [];
foreach (TypeDesc parameter in ParameterTypes(signature))
parameterTypes.Add(MapType(parameter));
if (IsPassedByReference(returnType))
{
returnType = pinvoke.Method.Context.GetWellKnownType(WellKnownType.Void);
parameterTypes.Insert(0, "void *");
}
string importAttributes = pinvoke.WasmLinkage
? $"__attribute__((import_module(\"{EscapeLiteral(pinvoke.Module)}\"),import_name(\"{EscapeLiteral(pinvoke.EntryPoint)}\"))) "
: "";
string externKeyword = pinvoke.WasmLinkage ? "extern " : "";
return $" {importAttributes}{externKeyword}{MapType(returnType)} {CEntryPoint(pinvoke)} ({string.Join(", ", parameterTypes)});";
}
private string FixedSymbolName(PInvokeCallback cb)
{
string paramTypes = cb.Parameters.Length > 0
? string.Join("_", ParameterTypes(cb.Parameters).Select(TypeToNameType))
: "Void";
return FixupSymbolName($"{cb.EntryName}_{paramTypes}_Ret{TypeToNameType(cb.ReturnType)}");
}
private string ThunkMapEntryLine(PInvokeCallback cb)
=> $" {{ {HashString(cb.Key)}, \"{EscapeLiteral(cb.Key)}\", {{ &MD_{FixedSymbolName(cb)}, (void*)&Call_{cb.EntrySymbol} }} }}";
/// <summary>
/// Whether the wasm ABI moves a struct by reference instead of as a bare value. Padding,
/// several fields, or a type too wide for one slot all force the hidden-pointer form, so the
/// C declaration has to say <c>void *</c> rather than unwrap to the field's type.
/// </summary>
private static bool IsPassedByReference(TypeDesc type)
{
if (!type.IsValueType || type.IsPrimitive || type.IsEnum || type is FunctionPointerType)
return false;
return InteropSignature.GetAbiToken(type)[0] is 'S' or 'A';
}
private static string TypeToNameType(TypeDesc type)
{
if (!type.IsValueType || type.IsPointer || type.IsByRef || type is FunctionPointerType)
return "I32";
if (type.IsEnum)
return TypeToNameType(type.UnderlyingType);
return InteropSignature.TokenToNameType(InteropSignature.GetAbiToken(type));
}
private static string MapType(TypeDesc type) => type.Category switch
{
TypeFlags.Void => "void",
TypeFlags.Double => "double",
TypeFlags.Single => "float",
TypeFlags.Int64 => "int64_t",
TypeFlags.UInt64 => "uint64_t",
TypeFlags.Int32 or TypeFlags.Int16 or TypeFlags.Char or TypeFlags.Boolean or TypeFlags.SByte => "int32_t",
TypeFlags.UInt32 or TypeFlags.UInt16 or TypeFlags.Byte => "uint32_t",
TypeFlags.IntPtr or TypeFlags.UIntPtr => "void *",
_ => PickCTypeNameForUnknownType(type)
};
private static string PickCTypeNameForUnknownType(TypeDesc type)
{
// Pass objects by-reference (their address by-value), and pointers and function pointers
// by-value.
if (!type.IsValueType || type.IsPointer || type is FunctionPointerType)
return "void *";
if (type.IsEnum)
return MapType(type.UnderlyingType);
// The wasm C ABI hands a struct over as a bare scalar only when it recursively contains a
// single scalar that fills it. Padding or extra fields make it travel by reference, so ask
// the same lowering the runtime encodes into the signature instead of unwrapping blindly:
// otherwise a `[StructLayout(Size = 16)] struct { long V; }` parameter is declared int64_t
// while the caller passes a pointer.
if (IsPassedByReference(type))
return "void *";
// https://github.com/WebAssembly/tool-conventions/blob/main/BasicCABI.md#function-signatures
// Any struct or union that recursively (including through nested structs, unions, and arrays)
// contains just a single scalar value and is not specified to have greater than natural alignment.
// FIXME: Handle the scenario where there are fields of struct types that contain no members
FieldDesc singleField = null;
foreach (FieldDesc field in ((MetadataType)type).GetFields())
{
if (field.IsStatic)
continue;
if (singleField is not null)
return "void *";
singleField = field;
}
return singleField is not null ? MapType(singleField.FieldType) : "void *";
}
private static readonly char[] s_charsToReplace = ['.', '-', '+', '<', '>'];
/// <summary><see cref="MethodSignature"/> is indexable but not enumerable; this makes it LINQ-friendly.</summary>
private static IEnumerable<TypeDesc> ParameterTypes(MethodSignature signature)
{
for (int i = 0; i < signature.Length; i++)
yield return signature[i];
}
/// <summary>
/// Rewrites a name into something that can be used as a C identifier, reversibly enough that
/// two different names cannot collide.
/// </summary>
private static string FixupSymbolName(string name)
{
var sb = new StringBuilder();
foreach (byte b in Encoding.UTF8.GetBytes(name))
{
if (b is (>= (byte)'0' and <= (byte)'9') or (>= (byte)'a' and <= (byte)'z') or (>= (byte)'A' and <= (byte)'Z') or (byte)'_')
sb.Append((char)b);
else if (Array.IndexOf(s_charsToReplace, (char)b) >= 0)
sb.Append('_');
else
sb.Append(CultureInfo.InvariantCulture, $"_{b:X}_");
}
return sb.ToString();
}
private static string EscapeLiteral(string input)
{
if (input is null)
return string.Empty;
var sb = new StringBuilder();
for (int i = 0; i < input.Length; i++)
{
char c = input[i];
sb.Append(c switch
{
'\\' => "\\\\",
'\"' => "\\\"",
'\n' => "\\n",
'\r' => "\\r",
'\t' => "\\t",
// take special care with surrogate pairs to avoid
// potential decoding issues in generated C literals
_ when char.IsHighSurrogate(c) && i + 1 < input.Length && char.IsLowSurrogate(input[i + 1])
=> $"\\U{char.ConvertToUtf32(c, input[++i]):X8}",
_ when char.IsControl(c) || c > 127
=> $"\\u{(int)c:X4}",
_ => c.ToString()
});
}
return sb.ToString();
}
/// <summary>
/// Equivalent to <c>ULONG HashString(LPCWSTR szStr)</c> in the CoreCLR runtime,
/// src/coreclr/inc/utilcode.h.
/// </summary>
private static uint HashString(string str)
{
uint hash = 5381;
foreach (char c in str)
hash = ((hash << 5) + hash) ^ c;
return hash;
}
}
}