كيف نُحوِّل C# إلى native DLL باستخدام Native AOT - استدعاء exports من نوع UnmanagedCallersOnly من C/C++
كيف نُحوِّل 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 يبقى متيناً.
المحتويات
- النسخة المختصرة
- مقارنة سريعة
- مخطّط البنية
- التكوين الأدنى
- 4.1. مشروع C#
- 4.2. كود C# المُصدَّر
- 4.3. أمر الإصدار
- 4.4. مثال الاستدعاء من C++
- أشكال API يصعب كسرها
- الحالات التي يلائم فيها
- الحالات التي ما يزال لا يلائم فيها
- أفخاخ شائعة
- الخلاصة
- مراجع
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*مع طول
أشياء عادةً لا تريد تسريبها مباشرةً:
stringobjectList<T>TaskSpan<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. مراجع
- Native code interop with Native AOT - Microsoft Learn
- Building native libraries - Microsoft Learn
- Native AOT deployment - Microsoft Learn
- UnmanagedCallersOnlyAttribute Class - Microsoft Learn
- UnmanagedCallersOnlyAttribute.CallConvs Field - Microsoft Learn
- C# compiler breaking changes for UnmanagedCallersOnly
- Building Native Libraries with NativeAOT - dotnet/samples
- لماذا يكون wrapper بـ C++/CLI غالباً أفضل طريقة لاستخدام native DLL من C#
- كيف يستطيع تطبيق 32-bit استدعاء DLL بـ 64-bit - دراسة حالة جسر COM
مقالات ذات صلة
أحدث المقالات التي تشترك في نفس الوسوم. عمّق فهمك بمواضيع مرتبطة.
قائمة تحقّق للتعامل الآمن مع child processes في تطبيقات Windows - Job Objects ونشر الـ exit وstdio وتصميم الـ watchdog
دليل تصميم متكامل للتعامل الآمن مع child processes في تطبيقات Windows عبر Job Objects ونشر الـ exit وتصريف stdio ووضع الـ watchdog خارج ا...
مزالق تطبيقات serial communication - framing وtimeouts وflow control وreconnects ومحوّلات USB وتجمّد الـ UI
ملخّص عمليّ لمزالق تطبيقات serial communication على Windows: framing وtimeouts وflow control وreconnects ومحوّلات USB-to-serial وتجمّد ال...
المزالق وأفضل الممارسات عند استخدام shared memory - تنظيم مسبق للتزامن، الرؤية، العمر، ABI، والأمان
نُلخّص أبرز المزالق عند استخدام shared memory ونصمّم للتزامن، الرؤية، العمر، ABI، والاستعادة، حتّى يبني القارئ تكاملاً ثابتاً منخفض الأعطال.
ما الذي يجب التحقّق منه قبل ترحيل .NET Framework إلى .NET - قائمة تحقّق عمليّة لما قبل الترحيل
قائمة عمليّة لما قبل ترحيل .NET Framework إلى .NET: جرد المشاريع وفحص WCF و Web Forms و EF6 و NuGet لتجنّب المفاجآت قبل بدء التنفيذ.
ما هو Native AOT في .NET - الفروق عن JIT و ReadyToRun و trimming
شرح Native AOT في .NET كنموذج نشر يقدّم الترجمة الأصليّة، مع الفروق عن JIT و ReadyToRun و trimming، والأنسب أن يكون أدوات CLI و workers و...
أين يتصل هذا الموضوع
ترتبط هذه المقالة بشكل طبيعي بصفحات الخدمات التالية.
تطوير تطبيقات ويندوز
ندعم تطوير برامج ويندوز للأعمال، وتكامل الأجهزة، وأدوات التواصل.
صيانة وتحديث برامج ويندوز الحالية
ندعم إضافة الميزات، والصيانة، والتحديث المتدرّج لبرامج ويندوز الحالية.