كيف نُحوِّل C# إلى native DLL باستخدام Native AOT - استدعاء exports من نوع UnmanagedCallersOnly من C/C++

· · C#, .NET, Native AOT, C++, تطوير Windows, تكامل أصيل

كيف نُحوِّل C# إلى native DLL باستخدام Native AOT - استدعاء exports من نوع UnmanagedCallersOnly من C/C++

في المقال السابق لماذا يكون wrapper بـ C++/CLI غالباً أفضل طريقة لاستخدام native DLL من C#، كان التركيز على استدعاء C++ من C#. هذه المرّة الاتّجاه معكوس: استدعاء C# من C أو C++.

ثمّة حالات تريد فيها استدعاء منطق مكتوب بـ C# من تطبيق C/C++ قائم، لكنّ P/Invoke يشير في الاتّجاه المعاكس، وإدخال C++/CLI أو COM قد يبدو ثقيلاً جدّاً. يصدق هذا بشكل خاصّ حين تريد إبقاء التطبيق الأصيل نفسه كما هو، وتنقل فقط أجزاء كمنطق القرار، أو معالجة السلاسل، أو تفسير الإعدادات، أو قواعد الحساب إلى C#.

يمكن لـ COM أيضاً أن يجسّر تلك الفجوة، لكنّ هذا المقال يركّز على شيء أكثر in-process وأكثر شبهاً بـ DLL. مع .NET Native AOT، يمكنك إصدار مكتبة فئات على هيئة مكتبة مشتركة أصيلة، وكشف الميثودات الموسومة بـ UnmanagedCallersOnly كنقاط دخول C. بعبارة أخرى، يمكنك استخدام C# بوصفه native DLL يُستدعى من الخارج.

لا يعني ذلك أنّ كلّ شيء يعبر الحدّ بنظافة. إذا تركت string أو List<T> أو الاستثناءات أو ملكيّة الموارد تتسرّب عبر الحدّ، يتحوّل التصميم بسرعة إلى تصميم سيّئ. لذا يستخدم هذا المقال مثالاً أدنى على Windows + C++، وينظّم متى يلائم هذا الأسلوب، وأيّ شكل API يبقى متيناً.

المحتويات

  1. النسخة المختصرة
  2. مقارنة سريعة
  3. مخطّط البنية
  4. التكوين الأدنى
  5. أشكال API يصعب كسرها
  6. الحالات التي يلائم فيها
  7. الحالات التي ما يزال لا يلائم فيها
  8. أفخاخ شائعة
  9. الخلاصة
  10. مراجع

1. النسخة المختصرة

  • إذا أردت أن تستدعي C/C++ منطق C# in-process، فإنّ Native AOT + UnmanagedCallersOnly خيار قويّ جدّاً
  • لكنّ ما يُصدَّر يبقى مجرّد حدّ دالّة بنمط C، لا نموذج كائنات .NET
  • عمليّاً، من الأكثر استقراراً تسطيح السطح إلى C API كـ create / destroy / operate وجعل دورة الحياة وأكواد الأخطاء صريحة
  • إذا أردت التعامل مع فئات C++ و STL بشكل طبيعيّ، فإنّ C++/CLI يلائم أكثر؛ وإذا احتجت إلى التسجيل أو الأتمتة أو السلوك عبر العمليّات، فإنّ COM غالباً ما يلائم أكثر

باختصار، يمكنك استخدام C# كباطن لـ native DLL، لكن يجب تصميم الحدّ بوصفه C ABI لا بوصفه .NET.

2. مقارنة سريعة

ما تريد فعله المرشّح القويّ السبب
استدعاء دوالّ C من C# P/Invoke الاتّجاه طبيعيّ
استخدام مكتبة C++ بشكل طبيعيّ من C# C++/CLI يسهّل امتصاص أنواع C++ والملكيّة والاستثناءات و std::wstring
عبور 32-bit / 64-bit أو حدود العمليّات COM / IPC DLL داخل العمليّة وحده لا يحلّ ذلك
استدعاء منطق C# كـ native DLL من C/C++ Native AOT + UnmanagedCallersOnly يمكنك تصدير نقاط دخول C الخاصّة بك

يكون هذا الأسلوب جذّاباً بشكل خاصّ حين يكون الجانب الأصيل هو البرنامج الرئيسيّ، و C# مكوّناً قابلاً لإعادة الاستخدام.

3. مخطّط البنية

flowchart LR
    Cpp["C / C++ app"] -->|cdecl function call| Dll["C# DLL published with Native AOT"]
    Dll --> Exports["Exports marked with UnmanagedCallersOnly"]
    Exports --> Core["C# business logic"]
    Exports --> Store["Handle table / state management"]

النقطة المهمّة هي محاذاة الحدّ مع دوالّ C. يمكن لباطن C# أن يستخدم الفئات والـ collections و LINQ بحرّيّة، لكن يجب أن يكون السطح الخارجيّ مسطّحاً.

4. التكوين الأدنى

المثال هنا هو مُجمِّع بسيط. ينشئ الجانب الأصيل مُجمِّعاً، يضيف إليه قيماً، ثمّ يسترجع المجموع. الفكرة الجوهريّة ببساطة هي أنّ الجانب الأصيل يحمل handle ويستدعي دوالّ العمليّات بالترتيب.

4.1. مشروع C#

<!-- 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>

النقاط المهمّة:

  • تفعيل إصدار Native AOT
  • السماح بـ unsafe لأنّه ستُستخدم وسائط مؤشّرات

4.2. كود C# المُصدَّر

تصبح الميثودات الموسومة بـ UnmanagedCallersOnly نقاط الدخول المرئيّة من الكود الأصيل. في هذا النموذج، يُصدَر handle كقيمة شبيهة بعدد صحيح، وتعيش الحالة الفعليّة في dictionary على جانب 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);
    }
}

الفكرة بسيطة:

  • لا يرى الجانب الأصيل سوى handle بنمط intptr_t
  • تعيش الحالة الفعليّة على جانب C#
  • يُقسَّم السطح إلى دوالّ مسطّحة كـ create / add / get / destroy
  • القيم المرتجعة هي أكواد حالة، أمّا المخرجات الفعليّة فتعود عبر وسائط المؤشّرات

4.3. أمر الإصدار

أصدره كمكتبة مشتركة:

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

ينتج عن ذلك native DLL تحت bin/Release/net8.0/win-x64/publish/. نقطة مهمّة هي أنّك تُصدر لكلّ RID ويجب أن يتطابق المُستدعِي والـ DLL في عدد البتّات.

4.4. مثال الاستدعاء من C++

يستخدم هذا المثال LoadLibrary / GetProcAddress مباشرةً ليكون الشكل المُصدَّر سهل الرؤية.

/* 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);
}

من جانب C++، يبدو هذا ليس أكثر من C API يمكن استدعاؤه عبر مؤشّرات الدوالّ.

5. أشكال API يصعب كسرها

5.1. ابقَ قريباً من C ABI

أشياء جيّدة لكشفها عبر الحدّ:

  • أنواع عدديّة بدائيّة مثل int32_t و int64_t و double
  • structs بتخطيط ثابت
  • قيم شبيهة بالـ handles مثل intptr_t / void*
  • uint8_t* مع طول

أشياء عادةً لا تريد تسريبها مباشرةً:

  • string
  • object
  • List<T>
  • Task
  • Span<T>
  • فئات C++ أو std::vector أو std::wstring

5.2. تعامل مع السلاسل بمؤشّر + طول + حجم buffer

إذا احتجت إلى السلاسل، فقاوم الإغراء بكشف string مباشرةً. على حدّ المكتبة، أشكال كهذه أسهل بكثير في التفكير:

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);

المفتاح هو أن تقرّر مسبقاً:

  • الترميز
  • الطول
  • من يملك تخصيص الـ buffer

5.3. لا تترك الاستثناءات تعبر الحدّ

حدود الدوالّ الأصيلة مكان سيّئ لكشف الاستثناءات. في الحدّ الأدنى، من الأكثر أماناً عدم ترك الاستثناءات الـ managed تتسرّب مباشرةً إلى المُستدعِي.

شكل عمليّ شائع هو:

  • إعادة كود حالة
  • إعادة البيانات الفعليّة عبر out buffers أو وسائط مؤشّرات
  • إذا لزم الأمر، أضف شيئاً مثل get_last_error لتفاصيل إضافيّة

5.4. ثبّت اتّفاقيّة الاستدعاء

يستخدم هذا النموذج CallConvCdecl صراحةً. إذا أردت أن يبقى الـ header وأنواع مؤشّرات الدوالّ مستقرّة، فاختيار اتّفاقيّة الاستدعاء صراحةً أكثر أماناً من تركها ضمنيّة.

5.5. اجعل ميثودات الـ export رقيقة وضع المنطق الفعليّ في مكان آخر

الميثودات الموسومة بـ UnmanagedCallersOnly ليست APIs managed عاديّة تستدعيها من بقيّة كود C# لديك. لذا إن كدّست كلّ منطق الأعمال فيها، تصبح صعبة الاختبار.

لهذا السبب يُبقي النموذج الميثودات المُصدَّرة في NativeExports رقيقةً، ويضع إدارة الحالة الفعليّة في AccumulatorStore.

  • ميثودات الـ export: نقاط دخول ABI
  • الفئات الداخليّة: منطق C# عاديّ

6. الحالات التي يلائم فيها

يلائم هذا الشكل بشكل خاصّ حين:

  • تريد إبقاء تطبيق C/C++ القائم ونقل جزء فقط من منطق الأعمال إلى C#
  • لا تريد أن يعتمد التوزيع على .NET runtime مُثبَّت مسبقاً
  • يمكن أن يبقى سطح الدوالّ المُصدَّرة صغيراً
  • قد ترغب لاحقاً في أن يكون نفس C API قابلاً للاستدعاء من Rust أو Go أو لغات أخرى أيضاً

يكون جذّاباً بشكل خاصّ حين يبقى التطبيق الأصيل هو الفاعل الرئيسيّ، بينما تنتقل طبقات المنطق القابلة للاستبدال إلى C#.

7. الحالات التي ما يزال لا يلائم فيها

هذا ليس حلّاً شاملاً. يكون عادةً غير ملائم حين:

  • تريد التعامل مع فئات C++ و std::vector والاستثناءات بشكل طبيعيّ
    • C++/CLI أو wrapper على الجانب الأصيل عادةً أكثر طبيعيّةً
  • تريد تسجيلاً بنمط COM، أو أتمتة VBA / Office، أو سلوك إضافات Explorer
    • ذلك عادةً يُفهَم بشكل أفضل بوصفه مشكلة COM
  • تريد جسر 32-bit و 64-bit أو عبور حدود العمليّات
    • DLL داخل العمليّة ليس الأداة الصحيحة؛ COM / IPC هي الأنسب
  • تريد أن يكون الـ plugin قابلاً للتفريغ لاحقاً
    • مكتبات Native AOT المشتركة ليست شيئاً ينبغي أن تصمّم حول تفريغه
  • تعتمد التبعيّات بشكل كبير على reflection أو توليد الكود الديناميكيّ
    • لا تتجاهل تحذيرات AOT / trimming باستهتار

الفاصل الحقيقيّ هو ما إذا كان بإمكانك قبول C ABI بنظافة عند الحدّ.

8. أفخاخ شائعة

بعض الأفخاخ العمليّة الشائعة:

  • الميثودات الموسومة بـ UnmanagedCallersOnly يجب أن تكون static
  • لا يمكن أن تعيش على ميثودات generic أو داخل فئات generic
  • إذا أردت export مسمّى، استخدم EntryPoint
  • بدلاً من ref / in / out، تكون وسائط المؤشّرات عادةً الشكل الأنظف
  • ما يُصدَّر هو الميثود في الـ assembly المُصدَّر نفسه؛ تزيين ميثود من مكتبة مرجعيّة لا يكشفها تلقائيّاً
  • يجب أن يتطابق المُستدعِي والـ DLL في عدد البتّات
  • تحذيرات AOT / trimming مهمّة جدّاً؛ لا تُلوِّح بها مُبعِداً

كلّ من هذه واضح بمجرّد أن تعرفه. لكن أن تخطو على واحد منها مرّةً واحدةً دون معرفة عادةً ما يكلّفك قدراً مُحبِطاً من الوقت.

9. الخلاصة

حين تريد أن يستدعي C/C++ منطق C#، فإنّ أوّل أفكار يلجأ إليها الناس عادةً هي COM أو C++/CLI أو عمليّة أخرى. كلّها خيارات صحيحة.

لكن إذا أردت إدراج منطق C# كـ native DLL داخل العمليّة، فإنّ Native AOT + UnmanagedCallersOnly خيار مثير جدّاً للاهتمام.

النقاط المهمّة هي:

  • تسطيح الحدّ إلى C ABI بدلاً من كشف C# مباشرةً
  • جعل دورة الحياة صريحةً عبر handles
  • عبور الحدّ بأكواد أخطاء بدلاً من الاستثناءات
  • تثبيت اتّفاقيّة الاستدعاء
  • إبقاء ميثودات الـ export رقيقةً وفصلها عن المنطق الداخليّ

هذا ليس عملاً برّاقاً. لكنّ القرارات حول أين تقطع الحدّ لها أثر كبير على قابليّة الصيانة بعيدة المدى. إن أردت الإبقاء على الأصول الأصيلة مع جلب إنتاجيّة C# إلى طبقة المنطق، فهذا شكل مفيد جدّاً تذكُّره.

10. مراجع

مقالات ذات صلة

أحدث المقالات التي تشترك في نفس الوسوم. عمّق فهمك بمواضيع مرتبطة.

أين يتصل هذا الموضوع

ترتبط هذه المقالة بشكل طبيعي بصفحات الخدمات التالية.

العودة إلى المدونة