Appeler une DLL C# Native AOT depuis C/C++

· Mis à jour le: · · C#, .NET, Native AOT, C++, Développement Windows, Interopérabilité native

Dans l’article précédent, Pourquoi un wrapper C++/CLI est un choix efficace pour utiliser des DLL natives depuis C#, nous avions fait le point sur la frontière lorsqu’on appelle du C++ depuis C#. Cette fois, nous inversons le sens : il s’agit d’appeler du C# depuis C/C++.

Il arrive qu’on veuille appeler depuis une application C/C++ existante un traitement écrit en C#, sans que P/Invoke ne convienne — il va dans l’autre sens — ni que cela justifie de faire intervenir C++/CLI ou COM. C’est particulièrement le cas quand on veut conserver l’application native telle quelle, en ne déplaçant vers C# que des éléments comme la logique de décision, le traitement de chaînes, l’interprétation de la configuration ou les règles de calcul.

COM peut aussi servir de pont, mais nous adoptons ici une approche plus in-process, plus proche d’une véritable DLL. Avec Native AOT de .NET, vous pouvez publier une bibliothèque de classes comme bibliothèque partagée native, et exposer les méthodes marquées UnmanagedCallersOnly comme points d’entrée C. Autrement dit, vous pouvez utiliser C# comme « la DLL native appelée ».

Cela dit, tout ne peut pas franchir la frontière tel quel. Laisser fuir string, List<T>, des exceptions ou la propriété d’objets à la frontière fait rapidement dégénérer l’ambiance. Dans cet article, à l’aide d’un exemple minimal Windows + C++, nous examinons dans quels cas cette configuration est particulièrement efficace et quelles formes d’API restent robustes. Le raisonnement est quasiment le même sous Linux / macOS, mais les exemples de code partent du principe d’une DLL Windows.

Par ailleurs, tout le code présenté dans cet article est publié sur GitHub sous la forme d’un ensemble d’exemples buildable et exécutable (la bibliothèque C# publiée avec Native AOT, un exemple d’appel en C++ et des tests unitaires).

csharp-native-aot-native-dll-from-c-cpp - komurasoft-blog-samples (GitHub)

Table des matières

  1. D’abord la conclusion (en une ligne)
  2. Choisir le bon pont
  3. Schéma d’architecture
  4. Configuration minimale
    • 4.1. Le projet C#
    • 4.2. Le code C# exporté
    • 4.3. La commande de publication
    • 4.4. L’appel côté C++
  5. Des formes d’API résistantes
    • 5.1. Se rapprocher de l’ABI C
    • 5.2. Traiter les chaînes comme pointeur + longueur + capacité de buffer
    • 5.3. Ne jamais laisser les exceptions franchir la frontière
    • 5.4. Fixer la convention d’appel
    • 5.5. Garder les méthodes exportées minces et séparer le corps de la logique
  6. Les cas où cela convient
  7. Les cas où cela ne convient toujours pas
  8. Pièges
  9. Résumé
  10. Références

1. D’abord la conclusion (en une ligne)

  • Si vous voulez appeler un traitement C# depuis C/C++ en in-process, Native AOT + UnmanagedCallersOnly est une option très solide.
  • Cependant, ce qui est exporté n’est jamais qu’un point d’entrée de fonction C. Ce n’est pas un monde où l’on expose directement string ou List<T>.
  • En pratique, il est plus stable de ramener les choses à une API C plate du type create / destroy / operate, en explicitant la gestion de la durée de vie et les codes d’erreur.
  • Si vous voulez manipuler naturellement des classes C++ et la STL, C++/CLI est plus adapté ; si vous avez besoin d’enregistrement, d’automatisation ou d’appels inter-processus, COM est le meilleur choix.

En résumé : vous pouvez utiliser C# comme contenu d’une DLL native, mais la frontière doit être conçue comme une ABI C, et non comme du .NET. Si vous acceptez ce compromis, cela devient une arme vraiment intéressante.

2. Choisir le bon pont

Ce que vous voulez faire Candidat solide Pourquoi
Appeler un ensemble de fonctions C depuis C# P/Invoke Le sens est direct, c’est l’approche la plus naturelle
Manipuler naturellement une bibliothèque C++ depuis C# C++/CLI Les types C++, la propriété, les exceptions, std::wstring, etc. sont faciles à absorber côté C++
Franchir la frontière 32 bits / 64 bits ou une frontière de processus COM / IPC Une DLL in-process seule ne peut pas franchir ces frontières
Appeler une logique C# depuis C/C++ comme une DLL native Native AOT + UnmanagedCallersOnly Vous pouvez exporter vous-même vos propres points d’entrée C

Cette configuration est particulièrement efficace dans les scénarios où « le côté natif est le protagoniste, et C# n’est appelé que comme un composant ». C’est exactement l’inverse de P/Invoke ou de C++/CLI.

3. Schéma d’architecture

appels de fonction cdeclApplication C / C++DLL C# publiée avec Native AOTExports marqués UnmanagedCallersOnlyLogique métier C#Table de handles / gestion d'état

L’apparence est simple. Ce qui compte, c’est d’aligner la frontière sur des fonctions C. Peu importe que l’implémentation interne côté C# utilise des classes, des collections ou LINQ : la surface exposée à l’extérieur reste plate.

4. Configuration minimale

Ici, nous prenons un exemple minimal où le côté C++ crée un « accumulateur », y ajoute des valeurs, puis récupère le total à la fin. En pratique, il pourrait tout aussi bien s’agir d’un moteur de décision, de l’interprétation d’une configuration ou d’un analyseur simple. Considérez-le comme le schéma où le côté natif détient un handle et appelle les fonctions d’opération dans l’ordre.

4.1. Le projet C#

Commençons par préparer une bibliothèque de classes.

<!-- NativeAotSample.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <PublishAot>true</PublishAot>
    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
  </PropertyGroup>
</Project>

Il y a deux points clés.

  • Activer la publication Native AOT
  • Autoriser unsafe, puisque nous utilisons des arguments de type pointeur

Les exemples de cet article partent du principe de net8.0, mais le raisonnement reste le même pour .NET 9 / 10.

4.2. Le code C# exporté

Les méthodes marquées UnmanagedCallersOnly deviennent les points d’entrée visibles depuis le côté natif. Ici, nous distribuons des handles sous forme d’entiers, et l’état interne est géré dans un dictionnaire côté C#.

// NativeExports.cs
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;

namespace KomuraSoft.NativeAotSample;

internal static class NativeStatus
{
    public const int Ok = 0;
    public const int InvalidArgument = -1;
    public const int InvalidHandle = -2;
    public const int UnexpectedError = -3;
}

internal sealed class Accumulator
{
    public long Total { get; private set; }

    public void Add(int value)
    {
        Total += value;
    }
}

internal static class AccumulatorStore
{
    private static readonly object s_gate = new();
    private static readonly Dictionary<nint, Accumulator> s_instances = new();
    private static long s_nextHandle = 0;

    public static int Create(out nint handle)
    {
        try
        {
            var instance = new Accumulator();
            handle = (nint)System.Threading.Interlocked.Increment(ref s_nextHandle);

            lock (s_gate)
            {
                s_instances.Add(handle, instance);
            }

            return NativeStatus.Ok;
        }
        catch
        {
            handle = 0;
            return NativeStatus.UnexpectedError;
        }
    }

    public static int Add(nint handle, int value)
    {
        try
        {
            lock (s_gate)
            {
                if (!s_instances.TryGetValue(handle, out var instance))
                {
                    return NativeStatus.InvalidHandle;
                }

                instance.Add(value);
                return NativeStatus.Ok;
            }
        }
        catch
        {
            return NativeStatus.UnexpectedError;
        }
    }

    public static int GetTotal(nint handle, out long total)
    {
        try
        {
            lock (s_gate)
            {
                if (!s_instances.TryGetValue(handle, out var instance))
                {
                    total = 0;
                    return NativeStatus.InvalidHandle;
                }

                total = instance.Total;
                return NativeStatus.Ok;
            }
        }
        catch
        {
            total = 0;
            return NativeStatus.UnexpectedError;
        }
    }

    public static int Destroy(nint handle)
    {
        try
        {
            lock (s_gate)
            {
                return s_instances.Remove(handle)
                    ? NativeStatus.Ok
                    : NativeStatus.InvalidHandle;
            }
        }
        catch
        {
            return NativeStatus.UnexpectedError;
        }
    }
}

public static unsafe class NativeExports
{
    [UnmanagedCallersOnly(
        EntryPoint = "km_accumulator_create",
        CallConvs = new[] { typeof(CallConvCdecl) })]
    public static int AccumulatorCreate(nint* outHandle)
    {
        if (outHandle == null)
        {
            return NativeStatus.InvalidArgument;
        }

        var status = AccumulatorStore.Create(out var handle);
        *outHandle = handle;
        return status;
    }

    [UnmanagedCallersOnly(
        EntryPoint = "km_accumulator_add",
        CallConvs = new[] { typeof(CallConvCdecl) })]
    public static int AccumulatorAdd(nint handle, int value)
    {
        return AccumulatorStore.Add(handle, value);
    }

    [UnmanagedCallersOnly(
        EntryPoint = "km_accumulator_get_total",
        CallConvs = new[] { typeof(CallConvCdecl) })]
    public static int AccumulatorGetTotal(nint handle, long* outTotal)
    {
        if (outTotal == null)
        {
            return NativeStatus.InvalidArgument;
        }

        var status = AccumulatorStore.GetTotal(handle, out var total);
        *outTotal = total;
        return status;
    }

    [UnmanagedCallersOnly(
        EntryPoint = "km_accumulator_destroy",
        CallConvs = new[] { typeof(CallConvCdecl) })]
    public static int AccumulatorDestroy(nint handle)
    {
        return AccumulatorStore.Destroy(handle);
    }
}

Ce qui est fait est plutôt simple.

  • Seul un handle intptr_t est montré au côté natif
  • L’état réel est détenu côté C#
  • create / add / get / destroy sont décomposés en fonctions plates
  • Les valeurs de retour sont des codes d’erreur, et les valeurs de sortie reviennent via des arguments pointeurs

Avec cette forme, même si vous remplacez ensuite l’implémentation interne côté C#, l’ABI côté C reste remarquablement stable.

4.3. La commande de publication

Publions-la comme bibliothèque partagée.

dotnet publish -r win-x64 -c Release /p:NativeLib=Shared

Cela produit une DLL native sous bin/Release/net8.0/win-x64/publish/. Pour Windows, c’est un .dll ; pour Linux, un .so ; pour macOS, un .dylib.

Ce qui compte, c’est de publier par RID. Un binaire produit pour win-x64 ne peut pas être utilisé comme s’il était pour win-arm64, et la bitness de l’appelant et de la DLL doivent également correspondre.

4.4. L’appel côté C++

Pour l’instant, laissons de côté la question des bibliothèques d’import, et appelons directement via LoadLibrary / GetProcAddress. Sous cette forme, il est facile de voir ce qui est exporté et avec quelle signature il faut le recevoir.

/* native_api.h */
#pragma once
#include <stdint.h>

enum km_status
{
    KM_STATUS_OK = 0,
    KM_STATUS_INVALID_ARGUMENT = -1,
    KM_STATUS_INVALID_HANDLE = -2,
    KM_STATUS_UNEXPECTED_ERROR = -3
};

typedef int (__cdecl *km_accumulator_create_fn)(intptr_t* out_handle);
typedef int (__cdecl *km_accumulator_add_fn)(intptr_t handle, int value);
typedef int (__cdecl *km_accumulator_get_total_fn)(intptr_t handle, int64_t* out_total);
typedef int (__cdecl *km_accumulator_destroy_fn)(intptr_t handle);
// main.cpp
#include <cstdint>
#include <cstdlib>
#include <iostream>
#include <windows.h>

#include "native_api.h"

template <typename T>
T LoadSymbol(HMODULE module, const char* name)
{
    FARPROC proc = ::GetProcAddress(module, name);
    if (proc == nullptr)
    {
        std::cerr << "GetProcAddress failed: " << name << '\n';
        std::exit(EXIT_FAILURE);
    }

    return reinterpret_cast<T>(proc);
}

int main()
{
    HMODULE module = ::LoadLibraryW(L"NativeAotSample.dll");
    if (module == nullptr)
    {
        std::cerr << "LoadLibraryW failed" << '\n';
        return EXIT_FAILURE;
    }

    auto create = LoadSymbol<km_accumulator_create_fn>(module, "km_accumulator_create");
    auto add = LoadSymbol<km_accumulator_add_fn>(module, "km_accumulator_add");
    auto getTotal = LoadSymbol<km_accumulator_get_total_fn>(module, "km_accumulator_get_total");
    auto destroy = LoadSymbol<km_accumulator_destroy_fn>(module, "km_accumulator_destroy");

    intptr_t handle = 0;
    if (create(&handle) != KM_STATUS_OK)
    {
        std::cerr << "create failed" << '\n';
        return EXIT_FAILURE;
    }

    if (add(handle, 10) != KM_STATUS_OK)
    {
        std::cerr << "add(10) failed" << '\n';
        return EXIT_FAILURE;
    }

    if (add(handle, 20) != KM_STATUS_OK)
    {
        std::cerr << "add(20) failed" << '\n';
        return EXIT_FAILURE;
    }

    std::int64_t total = 0;
    if (getTotal(handle, &total) != KM_STATUS_OK)
    {
        std::cerr << "get_total failed" << '\n';
        return EXIT_FAILURE;
    }

    std::cout << "total = " << total << '\n';

    if (destroy(handle) != KM_STATUS_OK)
    {
        std::cerr << "destroy failed" << '\n';
        return EXIT_FAILURE;
    }

    handle = 0;

    // Ne pas utiliser de bibliothèque partagée Native AOT en prévoyant de la décharger.
    // FreeLibrary(module);

    return EXIT_SUCCESS;
}

Dans cet exemple, tout ce que voit le côté C++ est « une API C appelable via des pointeurs de fonction ». Le fait que l’intérieur soit écrit en C# n’a presque pas besoin d’être pris en compte.

5. Des formes d’API résistantes

Pouvoir exporter avec Native AOT est amusant, mais en pratique, ce que l’on choisit de ne pas exporter compte davantage.

5.1. Se rapprocher de l’ABI C

Il est plus serein de restreindre dès le départ les types exposés à la frontière à peu près à ceci.

  • Des types primitifs comme int32_t / int64_t / double
  • Des structs à layout fixe
  • Des handles équivalents à intptr_t / void*
  • uint8_t* accompagné d’une longueur

À l’inverse, voici ce qu’il ne faut surtout pas laisser fuir dès le départ.

  • string
  • object
  • List<T>
  • Task
  • Span<T>
  • Les classes C++, std::vector, std::wstring

Essayer de faire franchir la frontière à ces éléments tels quels trouble rapidement la surface de la frontière. L’important est de ne pas laisser fuir les contraintes de C# vers C++, et de ne pas non plus laisser fuir excessivement les contraintes de C++ vers C#.

5.2. Traiter les chaînes comme pointeur + longueur + capacité de buffer

Dès que l’on veut échanger des chaînes, on a envie d’exposer directement string, mais il vaut mieux résister à cette tentation. À la frontière d’une bibliothèque, il est plus clair de ramener cela à une forme comme la suivante, par exemple.

int km_parse_utf8(const uint8_t* text, int32_t text_len, int32_t* out_value);
int km_format_utf8(int32_t value, uint8_t* buffer, int32_t buffer_len, int32_t* out_written);

Autrement dit, décidez à l’avance de l’encodage des caractères, de la longueur, et de qui alloue le buffer. Comme il s’agit de Windows, se rapprocher d’UTF-16 est une option, mais si vous envisagez d’autres langages, UTF-8 est souvent plus facile à manipuler.

5.3. Ne jamais laisser les exceptions franchir la frontière

Une frontière de fonction native n’est pas un moyen très accueillant pour représenter des exceptions. Il est au moins plus sûr de ne pas concevoir les choses de façon à laisser une exception managée fuir directement vers l’appelant.

En pratique, il est plus facile à gérer de procéder ainsi :

  • La valeur de retour est un code de statut
  • Les données réelles reviennent via des buffers de sortie ou des arguments pointeurs
  • Si nécessaire, récupérer des informations supplémentaires via une fonction de type get_last_error

Ce n’est pas spectaculaire, mais ce genre de conception discrète paie plus tard. Autrement dit, ne commencez pas soudainement un combat au corps à corps à la frontière.

5.4. Fixer la convention d’appel

Dans l’exemple, CallConvCdecl a été spécifié explicitement. Si vous l’omettez, vous obtenez la convention d’appel par défaut de la plateforme, mais si vous voulez fixer l’en-tête et les types de pointeurs de fonction, il est moins accidentogène de le déclarer vous-même explicitement.

En particulier s’il existe une possibilité de traiter avec x86, laisser ce point ambigu deviendra pénible plus tard. Même si cela se manifeste rarement en x64, mieux vaut fixer la règle dès le départ.

5.5. Garder les méthodes exportées minces et séparer le corps de la logique

Les méthodes marquées UnmanagedCallersOnly ne sont pas censées être appelées directement depuis du code managé ordinaire. Donc si vous commencez à y écrire toute votre logique métier, les tests deviennent aussi difficiles.

Dans l’exemple aussi, la gestion réelle de l’état se trouve dans AccumulatorStore, et le NativeExports exporté n’est qu’une entrée mince. C’est un point assez important.

  • Méthodes exportées : le guichet de l’ABI
  • Classes internes : la logique C# ordinaire

Avec cette répartition des rôles, vous pouvez réfléchir séparément à la frontière avec C++ et au code principal en C#.

6. Les cas où cela convient

Cette configuration s’intègre particulièrement bien dans des scénarios comme ceux-ci.

  • Vous voulez conserver l’application C/C++ existante telle quelle, en ne déplaçant vers C# qu’une partie de la logique métier
  • Vous ne voulez pas que la préinstallation du runtime .NET soit un prérequis de distribution
  • Vous pouvez garder petite la surface des fonctions exportées
  • Vous pourriez, à l’avenir, vouloir appeler la même API C depuis d’autres langages comme Rust ou Go

Cela s’accorde particulièrement bien avec une configuration où l’on garde l’application native telle quelle, et où l’on écrit en C# uniquement la couche de logique facile à remplacer. L’UI et le pilotage des équipements restent en C++, tandis que les décisions, les calculs et les règles de configuration passent en C#.

7. Les cas où cela ne convient toujours pas

Bien sûr, ce n’est pas une solution universelle. Il existe des cas où cela ne convient clairement pas.

  • Vous voulez manipuler directement des classes C++, std::vector ou des exceptions
    • Dans ce cas, C++/CLI ou un wrapper côté natif est plus naturel.
  • Vous voulez entrer dans le monde de l’enregistrement COM, de l’automatisation VBA / Office ou des extensions Explorer
    • Il vaut mieux réfléchir à cela dans le contexte de COM.
  • Vous voulez faire le pont entre 32 bits et 64 bits, ou franchir une frontière de processus
    • Plutôt qu’une DLL in-process, une configuration COM / IPC / processus séparé est plus judicieuse.
  • Vous voulez pouvoir décharger un plugin par la suite
    • Mieux vaut ne pas utiliser une bibliothèque partagée Native AOT en prévoyant de la décharger.
  • Vos bibliothèques dépendantes reposent fortement sur la réflexion ou la génération de code dynamique
    • Si des avertissements apparaissent lors de la publication AOT, il est plus sûr de ne pas les ignorer à la légère.

En fin de compte, la ligne de partage est la capacité ou non à se contenter d’une ABI C. Si ce n’est pas possible, un autre pont sera plus propre.

8. Pièges

Pour finir, voici un résumé des points discrètement faciles à heurter avec les exports Native AOT.

  • Les méthodes marquées UnmanagedCallersOnly doivent être static.
  • Elles ne peuvent pas se trouver dans une méthode générique ni dans une classe générique.
  • Si vous voulez un export nommé, ajoutez EntryPoint.
  • Évitez ref / in / out ; privilégiez le retour via des arguments pointeurs.
  • Seules les méthodes de l’assembly cible de la publication sont exportées. Mettre l’attribut sur une méthode d’une bibliothèque référencée ne suffit pas à la faire apparaître telle quelle.
  • La bitness de l’appelant et de la DLL doivent correspondre.
  • Les avertissements de publication comptent beaucoup. Si des avertissements AOT / trimming apparaissent, il est plus sûr de les traiter en premier.

Chacun de ces points, une fois qu’on le connaît, relève de l’évidence. Mais si on le découvre sans le savoir à l’avance, on y passe des heures plutôt pénibles.

9. Résumé

Quand on veut appeler C# depuis C/C++, ce qui vient d’abord à l’esprit, c’est COM, C++/CLI, ou un processus séparé. Ce sont tous des choix valables.

Cependant, si vous voulez insérer un traitement C# comme une DLL native in-process, Native AOT + UnmanagedCallersOnly est une option vraiment intéressante.

Reprenons les points clés une dernière fois.

  • Ne pas exposer C# tel quel, mais l’aplatir en une ABI C
  • Expliciter la gestion de la durée de vie sur la base de handles
  • Franchir la frontière avec des codes d’erreur, et non des exceptions
  • Fixer la convention d’appel
  • Garder les méthodes exportées minces, séparées de la logique interne

Ce qui est fait n’a rien de spectaculaire. Mais la façon de découper cette frontière a un impact réel sur la maintenabilité par la suite. Quand vous voulez tirer parti de vos actifs natifs tout en apportant la productivité de C# à la seule couche logique, cette configuration mérite d’être gardée en mémoire.

10. Références

Articles récents partageant les mêmes étiquettes, pour approfondir des sujets proches.

Ces pages replacent le sujet dans un contexte plus large de services et de décisions.

Cet article est directement lié aux services suivants.

Questions fréquentes

Questions souvent posées lors d’une consultation sur le sujet de cet article.

Peut-on appeler du code C# depuis C++ ?
Oui. Avec Native AOT de .NET, vous pouvez publier une bibliothèque de classes C# comme bibliothèque partagée native, et exposer les méthodes marquées UnmanagedCallersOnly comme points d'entrée C. Autrement dit, vous pouvez utiliser C# comme « la DLL native appelée » depuis C/C++, en in-process.
Dans quels cas cette configuration est-elle adaptée ?
Dans les cas où l'on veut conserver l'application native telle quelle tout en déplaçant vers C# uniquement des parties comme la logique de décision, le traitement de chaînes, l'interprétation de la configuration ou les règles de calcul. Sa caractéristique est que « le côté natif reste le protagoniste, et C# n'est appelé que comme un composant » — l'inverse de P/Invoke ou de C++/CLI. À l'inverse, si vous appelez des fonctions C depuis C#, P/Invoke est adapté ; si vous voulez manipuler naturellement des types et une gestion de propriété C++, C++/CLI est adapté ; et si vous devez franchir la frontière 32 bits / 64 bits ou une frontière de processus, COM / IPC est adapté.
À quoi faut-il faire attention lors de la conception de l'API ?
Ce qui est exporté n'est jamais qu'un point d'entrée de fonction C, donc il ne faut pas exposer directement string, List<T> ou des exceptions à la frontière. L'essentiel est de la ramener à une API C plate du type create / destroy / operate, d'expliciter la gestion de la durée de vie et les codes d'erreur, de traiter les chaînes sous forme de pointeur + longueur + capacité de buffer, de ne jamais laisser les exceptions franchir la frontière, et de fixer la convention d'appel. Le point clé est de concevoir la surface de la frontière comme une ABI C, et non comme du .NET.
Existe-t-il un exemple de code fonctionnel ?
Oui. Un ensemble d'exemples complet, buildable et exécutable — comprenant la bibliothèque C# publiée avec Native AOT, un exemple d'appel en C++ et des tests unitaires — est publié dans le dépôt GitHub komurasoft-blog-samples. Les exemples de code partent du principe d'une DLL Windows, mais le raisonnement reste quasiment le même sous Linux / macOS.

Profil de l’auteur

Page de présentation de l’auteur de l’article.

Go Komura

Représentant de KomuraSoft LLC

Spécialisé dans le développement de logiciels Windows, le conseil technique et l’analyse de pannes, notamment pour les systèmes existants et les incidents difficiles à reproduire.

Retour au blog