كيف نستدعي DLL من C# Native AOT من C/C++

· آخر تحديث: · · C#, .NET, Native AOT, C++, تطوير Windows, تكامل أصيل

سجل التعديلات (3 تحديثات، آخر تحديث 3 Sep، 2026)

سجل بالتغييرات التي أُجريت على هذا المقال. وحيثما حُفظت نسخة سابقة، تبقى متاحة للقراءة عبر رابط دائم يحمل معرّف DOI.

أُضيفت روابط الاستشارة الموجودة في الأصل الياباني (consultation_services). ولم يتغيّر نصّ المقالة نفسه. قراءة النسخة السابقة لهذا التحديث (DOI: 10.5281/zenodo.22240819)
أُعيدَت الترجمة العربية كترجمة كاملة عن النص الياباني الأصلي، وأُضيفَت خريطة المعرفة.
أعيدت الترجمة كترجمة كاملة عن النص الياباني الأصلي. كانت النسخة العربية السابقة مختصراً يسقط أبواباً وجداول ورسوم Mermaid وتعليقات الأشكال وFAQ. أُعيدت هذه العناصر وفق الأصل الياباني، والادّعاءات التقنية مطابقة للنسخة اليابانية.
النشر الأول
الاستشهاد بهذا المقال(DOI: 10.5281/zenodo.21621347)

هذا المقال محفوظ على Zenodo. يرد أدناه معرّف DOI الذي يشير دائمًا إلى أحدث نسخة، ومعرّف DOI المثبَّت على النسخة التي تقرؤها.

小村 豪 (2026). كيف نستدعي DLL من C# Native AOT من C/C++. شركة كومورا سوفت ذ.م.م.. https://doi.org/10.5281/zenodo.21621347 https://comcomponent.com/ar/blog/2026/03/12/003-csharp-native-aot-native-dll-from-c-cpp/

DOI (أحدث نسخة)
10.5281/zenodo.21621347
DOI (هذه النسخة)
10.5281/zenodo.22279748

في المقالة السابقة لماذا يكون غلاف C++/CLI خياراً قويّاً عند استخدام native DLL من C# نظّمنا سطح الحدّ عند استدعاء C++ من C#. هذه المرّة نعكس الاتّجاه: استدعاء C# من C/C++.

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

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

غير أنّه ليس كلّ شيء يعبر كما هو. تسريب string أو List<T> أو الاستثناءات أو الملكيّة إلى الحدّ يفسد الجوّ فجأة. تنظّم هذه المقالة بمثال أدنى على Windows + C++ متى ينفع هذا الترتيب، وأيّ شكل API يصعب كسره. الفكرة شبه نفسها على Linux / macOS، لكنّ أمثلة الشيفرة تفترض DLL على Windows.

فرق الاتّجاه بين المقالة السابقة وهذهالمقالة السابقة حديث سطح الحدّ عند استدعاء native DLL من C#، وهذه تعكس الاتّجاه: استدعاء native DLL من C# الصادر بـ Native AOT in-process من تطبيق C/C++.السابقة: C# يستدعي C++حديث غلاف C++/CLIهذه: C/C++ يستدعي C#تحويل C# إلى DLL بـ Native AOTUnmanagedCallersOnly هو المدخل

الشكل 1: هذه المرّة الاتّجاه معكوس لـ P/Invoke و C++/CLI، ويصير C# «native DLL في جهة الاستدعاء».

كذلك، الشيفرة الواردة في هذه المقالة منشورة على GitHub كمجموعة عيّنات قابلة للبناء والتشغيل (مكتبة C# تُصدَر بـ Native AOT، ومثال استدعاء من C++، واختبارات وحدات).

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

المحتويات

  1. الخلاصة أوّلاً (بجملة)
  2. دليل الاختيار الذي يُنظر إليه أوّلاً
  3. مخطّط البنية
  4. التكوين الأدنى
    • 4.1. مشروع C#
    • 4.2. شيفرة C# المراد تصديرها
    • 4.3. أمر الإصدار
    • 4.4. مثال الاستدعاء من جانب C++
    • 4.5. التحقّق من أنّ التصدير موجود
    • 4.6. عند الرغبة في الربط الساكن بـ import lib
  5. شكل API يصعب كسره
    • 5.1. التقريب إلى C ABI
    • 5.2. معاملة السلاسل بمؤشّر + طول + سعة المخزن المؤقّت
    • 5.3. عدم عبور الاستثناءات للحدّ
    • 5.4. تثبيت اتّفاقيّة الاستدعاء
    • 5.5. إبقاء ميثودات الـ Export رقيقة ووضع الجسم في موضع آخر
  6. الحالات التي تلائمه
  7. الحالات التي لا تلائمه رغم ذلك
  8. مواضع التعثّر
  9. الخلاصة
  10. المراجع

في المخطّط، يشير الخطّ المتّصل إلى علاقة قائمة دائماً، ويشير الخطّ المتقطّع إلى علاقة مشروطة (شروط قيامها مذكورة في شرح كلّ علاقة في الصفحة التفصيليّة). القائمة الكاملة للعلاقات (المجموع 22، مع الأدلّة ودرجة اليقين) وتعريفات المفاهيم الرئيسة مجمّعة في صفحة تفاصيل خريطة المعرفة (باليابانية). البيانات: JSON-LD / Turtle

1. الخلاصة أوّلاً (بجملة)

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

بمعنى آخر: يمكن استخدام C# كمحتوى native DLL، لكنّ سطح الحدّ يُصمَّم كـ C ABI لا كـ .NET. إن أمكنكم القطع هنا، صار سلاحاً ممتعاً جدّاً.

تصميم سطح الحدّ كـ C ABIمحتوى C# يمكن أن يبقى أصنافاً ومجموعات كما هو، لكنّ السطح الظاهر للخارج ليس string أو List(T) بل C API مسطّح مثل create / destroy / operate مع إعلان إدارة العمر وأكواد الخطأ.المحتوى C# عاديّالسطح الظاهر C API مسطّحإدارة العمر صريحة بالمقبضالخطأ يُعاد كوداًلا تُظهر string أو List(T)

الشكل 2: موضع القطع واحد. ليس «إظهار .NET كما هو» بل إنزال سطح الحدّ إلى C ABI.

2. دليل الاختيار الذي يُنظر إليه أوّلاً

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

هذا الترتيب ينفع في مواضع «الجانب الأصيل هو البطل، و C# يُستدعى كمكوّن». هنا الاتّجاه معكوس تماماً لـ P/Invoke و C++/CLI.

فرق اتّجاه البطلP/Invoke و C++/CLI اتّجاه C# هو البطل ويستدعي الجانب الأصيل، أمّا ترتيب Native AOT في هذه المقالة فاتّجاه معكوس: الجانب الأصيل هو البطل ويستدعي منطق C# كمكوّن.يستدعي الأصيليستدعي C# كمكوّنC# هو البطلP/Invoke أو C++/CLIالأصيل هو البطلالتصدير بـ Native AOT

الشكل 3: الجسر يُختار بالاتّجاه. ترتيب هذه المقالة اتّجاه «الأصيل بطل و C# مكوّن».

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

استدعاء دالّة cdeclتطبيق C / C++DLL من C# صادر بـ Native AOTتصدير موسوم بـ UnmanagedCallersOnlyمنطق الأعمال في C#جدول مقابض / إدارة الحالة

الشكل 4: من تطبيق C/C++ لا يظهر كدالّة C سوى تصدير UnmanagedCallersOnly.

المظهر بسيط. المهمّ هو محاذاة سطح الحدّ مع دوالّ C. التنفيذ الداخليّ في جانب C# يمكن أن يكون أصنافاً أو مجموعات أو LINQ، لكنّ السطح الظاهر للخارج يُسطَّح.

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

نجعل هنا مثالاً أدنى يصنع «جامعاً» من جانب C++ ويجمع قيماً ثمّ يجلب المجموع في النهاية. في العمل اليوميّ يمكن أن يكون محرّك قرار أو تفسير إعدادات أو محلّلاً بسيطاً. اعتبروا الشكل أنّ الجانب الأصيل يحمل مقبضاً ويستدعي دوالّ التشغيل بالترتيب.

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 لأنّ وسائط المؤشّر مستخدمة

عيّنة هذه المقالة تفترض net8.0، لكنّ الفكرة نفسها في .NET 9 / 10 أيضاً.

4.2. شيفرة C# المراد تصديرها

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

ما يُفعل ساذج إلى حدّ بعيد.

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

بهذا الشكل، حتّى إن استُبدل التنفيذ الداخليّ في جانب C# لاحقاً، يبقى ABI جانب C مستقرّاً جدّاً.

API مسطّح قائم على مقبضcreate يصدر مقبضاً، ودوالّ التشغيل مثل add و get تُستدعى مع المقبض، و destroy يرتّب. جسم الحالة يحمله جانب C#، وقيمة الإرجاع كود خطأ، والقيمة الخارجة تُعاد بوسيط مؤشّر.create: إصدار مقبضadd أو get: تشغيل مع المقبضdestroy: الترتيبجسم الحالة يحمله جانب C#قيمة الإرجاع كود خطأ

الشكل 5: ما يُظهر للجانب الأصيل المقبض ودوالّ التشغيل فقط. استبدال التنفيذ الداخليّ يُبقي ABI مستقرّاً.

حول ترقيم المقبض ملاحظة واحدة. في العيّنة يُحمل عدّاد الترقيم كـ long، وتُحوَّل نتيجة Interlocked.Increment إلى nint. هنا خاصّيّتان يجدر معرفتها.

  • 0 لا يُصدر. العدّاد يبدأ من 0، و Increment يعيد القيمة بعد الجمع، لذا أوّل مقبض هو 1. لهذا يمكن لجانب C++ استخدام intptr_t handle = 0; علامة على «لم يُحمَل بعد».
  • في 32bit يحدث قطع. nint بعرض المؤشّر، ففي 64bit يكون 64bit، وفي بناء 32bit يكون 32bit. التحويل من long إلى nint يسقط البتّات العليا بصمت، لذا عندما يتجاوز الترقيم 2^32 تدور القيمة. في استخدام يكرّر create / destroy 24 ساعة متواصلة، يمكن الوصول نظريّاً.

ماذا يحدث عند الدوران، اضبطوا هذا بدقّة. استثناء المفتاح المكرّر في s_instances.Add(handle, instance) لا يعين إلا عندما يكون مقبض بالقيمة نفسها «حيّاً الآن». الاستخدام العاديّ لهذا الـ API تكرار create و destroy، والمقبض بعد destroy يختفي من القاموس. أي عند العودة إلى القيمة نفسها بعد الدوران، لا يوجد المفتاح في القاموس فينجح Add. النتيجة أنّ المقبض القديم الذي ما يزال جانب C يحمله يشير إلى نسخة جديدة غير ذات صلة. لا استثناء ولا كود خطأ، فتنكسر القيمة وحدها بهدوء.

أمر آخر: قيمة 2^32 تماماً بتّاتها الـ 32 الدنيا كلّها 0، لذا يُصدر 0 الذي كان يُفترض أنّه علامة «لم يُحمَل بعد».

لذلك لا تعتمدوا فحص المفتاح المكرّر كجهاز أمان. إن أمكن التعامل مع 32bit، فاختاروا واحداً ممّا يلي.

  • طمر رقم الجيل في المقبض. الأدنى تسلسل، والأعلى جيل، ويُقدَّم الجيل عند كلّ destroy. حتّى إن عاد التسلسل نفسه لا تتطابق القيمة
  • الفشل الدائم عند النفاد. إذا بلغ الترقيم الحدّ الأعلى تُخطئ create بعد ذلك. في جهاز يعمل باستمرار يلزم إعادة التشغيل، لكنّه أسهل تعاملاً من الكسر الهادئ

في الحالين يُحمَل عدّاد الترقيم نفسه كـ nint كي لا يتجاوز عرض nint، ويُحافَظ معاً على عدم إصدار 0.

طريقة الكسر عند دوران ترقيم المقبض والإجراء المضادّعند دوران الترقيم في 32bit ينجح Add لقيمة اختفت من القاموس بعد destroy، فيشير المقبض القديم الذي ما يزال جانب C يحمله إلى نسخة جديدة غير ذات صلة وينكسر بهدوء. الإجراء طمر رقم الجيل أو الفشل الدائم بعد بلوغ الحدّ.دوران الترقيم في 32bitنجاح Add للقيمة نفسهاالمقبض القديم يشير إلى نسخة جديدةلا استثناء ولا كود خطأ، وينكسرالإجراء: طمر رقم الجيلالإجراء: الفشل عند النفاد

الشكل 6: استثناء المفتاح المكرّر لا يصير جهاز أمان. كسر الدوران هادئ، والإجراء يُزرع في التصميم.

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

افتراض واحد مسبقاً. نشر Native AOT يحتاج سلسلة أدوات أصلية على حدة. إن ضربتم dotnet publish بمجرّد إلحاق PublishAot، فالسقوط ليس في تجميع C# بل في مرحلة الربط الأصيل الأخيرة. هذا أوّل حاجز.

البيئة المطلوب
Windows Visual Studio 2022 وما بعده. إدخال حمل عمل «تطوير سطح المكتب بـ C++» مع كلّ المكوّنات الافتراضيّة
Ubuntu 18.04 وما بعده sudo apt-get install clang zlib1g-dev
Alpine 3.15 وما بعده sudo apk add clang build-base zlib-dev
Fedora 39 وما بعده / RHEL 8 وما بعده sudo dnf install clang zlib-ng-devel zlib-ng-compat-devel zlib-devel
macOS Command Line Tools لـ Xcode (مدعوم من .NET 8)

هذه المقالة تفترض Windows + C++، لذا الحديث عمليّاً «تحقّقوا أوّلاً أنّ حمل عمل C++ في Visual Studio مثبَّت». أخطاء من نوع عدم العثور على الرابط أو الفشل حول link.exe غالباً هنا.

بعد ذلك تُنشر كمكتبة مشتركة.

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

بهذا يخرج native DLL تحت bin/Release/net8.0/win-x64/publish/. في مثال Windows .dll، وفي Linux .so، وفي macOS .dylib.

المهمّ هو النشر لكلّ RID. ما صُنع بـ win-x64 لا يُستخدم بافتراض win-arm64، ويلزم أيضاً مطابقة bitness جهة الاستدعاء و DLL.

أوّل حاجز في النشرنشر Native AOT يحتاج سلسلة أدوات أصلية على حدة، وضرب dotnet publish بدونها يسقط في مرحلة الربط الأصيل الأخيرة لا في تجميع C#. يُنشر لكلّ RID وتُطابق bitness.لانعمتشغيل dotnet publishهل سلسلة الأدوات الأصلية موجودةالسقوط في الربط الأصيل الأخيرخروج native DLL لكلّ RIDمطابقة bitness مع جهة الاستدعاء

الشكل 7: السقوط ليس تجميع C# بل مرحلة الربط. أوّل حاجز وجود سلسلة الأدوات.

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

هذه المرّة نستبعد حديث import lib مؤقّتاً، ونستدعي بصراحة بـ 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);
}

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;

    // Native AOT の共有ライブラリはアンロード前提では使わない。
    // FreeLibrary(module);

    return EXIT_SUCCESS;
}

في هذا المثال، ما يظهر من جانب C++ هو «C API يمكن استدعاؤه بمؤشّر دالّة» فقط. أنّ الداخل مكتوب بـ C# لا يكاد يحتاج وعياً.

بوضع DLL المنشور في المجلّد نفسه مع main.exe وتشغيله، يُجمع 10 و 20، فيكون الخرج القياسيّ هذا فقط.

total = 30

عند الفشل في المنتصف يظهر في جانب std::cerr في أيّ مرحلة سقط. LoadLibraryW failed يعني أنّ DLL لم يُعثر عليه أصلاً، و GetProcAddress failed: km_accumulator_add يعني أنّ DLL مقروء لكنّ التصدير لم يُعثر عليه، هذا الفصل.

الفصل عندما يتعذّر الاستدعاءفشل LoadLibraryW يعني أنّ DLL لم يُحمَّل فيُشتبه بالمسار و bitness ونقص DLL المعتمدة، وفشل GetProcAddress يعني أنّ DLL مقروء لكنّ التصدير لم يُعثر عليه، ونجاح الاثنين يعني إمكان الاستدعاء كـ C API.فشلنجاحفشلنجاحهل نجح LoadLibraryW؟DLL لم يُحمَّلالاشتباه بالمسار و bitness و DLL المعتمدةهل نجح GetProcAddress؟التصدير لم يُعثر عليهيمكن الاستدعاء كـ C API

الشكل 8: المرحلة التي يسقط فيها تفصل مشكلة تحميل DLL عن مشكلة التصدير.

4.5. التحقّق من أنّ التصدير موجود

عند «تعذّر الاستدعاء»، انظروا أوّلاً هل ظهر الاسم حقّاً في جانب DLL. استخدام dumpbin من Developer Command Prompt في Visual Studio سريع.

dumpbin /exports NativeAotSample.dll

إن اصطفّت الأسماء الأربعة km_accumulator_create / km_accumulator_add / km_accumulator_get_total / km_accumulator_destroy في قائمة name، فنشر جانب C# نجح. لتضييق الاسم:

dumpbin /exports NativeAotSample.dll | findstr km_

إن لم يظهر الاسم هنا فمشكلة جانب C#، وإن ظهر وفشل GetProcAddress فمشكلة جهة الاستدعاء، يمكن الفصل هكذا.

المواضع التي تُنظر عندما يعيد GetProcAddress القيمة NULL بهذا الترتيب تقريباً.

  1. هل ظهر الاسم في dumpbin /exports (إن لم يظهر فحديث جانب C#)
  2. هل السلسلة المكتوبة في EntryPoint تطابق تماماً السلسلة الممرَّرة إلى GetProcAddress (حساسية حالة الأحرف أيضاً)
  3. هل تطابق bitness EXE جهة الاستدعاء و DLL
  4. هل الميثود الموسومة بـ UnmanagedCallersOnly من نوع static وليست داخل generic
  5. هل كُتبت السمة في جانب التجميعة المستهدفة بالنشر (كتابتها في مكتبة مُشار إليها لا تُظهرها على السطح)

لاحظوا أنّ فشل LoadLibraryW نفسه ليس حديث تصدير. اشتبهوا أوّلاً بمسار DLL و bitness ونقص DLL المعتمدة.

ترتيب التحقّق عندما يكون GetProcAddress هو NULLالتحقّق بالترتيب: هل ظهر الاسم في قائمة exports لـ dumpbin، وهل تطابق سلسلة EntryPoint الممرَّرة تماماً، وهل تطابق bitness، وهل الميثود static خارج generic، وهل كُتبت السمة في جانب التجميعة المستهدفة بالنشر.لم يظهرهل ظهر الاسم في dumpbinهل السلسلة تطابق تماماًهل تطابق bitnessهل هي static خارج genericهل كُتبت السمة في جانب النشرالتحقيق كمشكلة جانب C#

الشكل 9: النظر أوّلاً إلى ظهور الاسم في التصدير يحسم مشكلة جانب C# أم جهة الاستدعاء.

4.6. عند الرغبة في الربط الساكن بـ import lib

حتى هنا كتبنا بأسلوب LoadLibrary / GetProcAddress. لأنّه يسهل رؤية ما صُدِّر وبأيّ توقيع ينبغي الاستلام.

في العمل اليوميّ كثيراً ما تريدون «تضمين ترويسة واستدعاء الدالّة كما هي». عندئذٍ يصير تحميلاً ساكناً بمكتبة استيراد. الخطوات:

  1. إن خرجت مكتبة استيراد (.lib) في مخرجات النشر، اربطوها
  2. إن لم تخرج، جهّزوا ملفّ .def يرتّب أسماء التصدير، واصنعوا مكتبة الاستيراد بـ lib.exe /def:NativeAotSample.def /out:NativeAotSample.lib /machine:x64
  3. في جانب الترويسة استخدموا إعلان دالّة عاديّاً لا نوع مؤشّر دالّة
/* native_api_static.h */
#pragma once
#include <stdint.h>

#ifdef __cplusplus
extern "C" {
#endif

int __cdecl km_accumulator_create(intptr_t* out_handle);
int __cdecl km_accumulator_add(intptr_t handle, int value);
int __cdecl km_accumulator_get_total(intptr_t handle, int64_t* out_total);
int __cdecl km_accumulator_destroy(intptr_t handle);

#ifdef __cplusplus
}
#endif

بهذا الشكل تصير شيفرة جهة الاستدعاء صريحة جدّاً. بالمقابل، إن لم يُعثر على DLL يسقط عند تشغيل العمليّة، فيصعب تشغيل من نوع «الجسم يعمل وهذه الوظيفة وحدها غير متاحة». إن أردتم الإدخال كإضافة، فأسلوب LoadLibrary كما هو أسهل تعاملاً.

الاختيار بين التحميل الديناميكيّ والربط الساكنأسلوب LoadLibrary يحمّل في وقت التشغيل فيلائم الإدخال كإضافة، والربط الساكن بمكتبة استيراد يتيح الاستدعاء كما هو بعد تضمين الترويسة مقابل السقوط عند تشغيل العمليّة إن غاب DLL.إدخال كإضافةالاستدعاء كما هو بالترويسةكيف تريدون الإدماجأسلوب LoadLibraryربط ساكن بـ import libغياب DLL يسقط عند التشغيل

الشكل 10: الكتابة الصريحة للربط الساكن، أمّا التشغيل بـ «هذه الوظيفة وحدها غائبة» فللتحميل الديناميكيّ.

لاحظوا أنّ الإصدار كمكتبة ساكنة (NativeLib=Static) غير مدعوم رسميّاً، لذا الأأمن عدم الاعتماد عليه.

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

إمكان التصدير بـ Native AOT ممتع، لكنّ في العمل اليوميّ ما لا يُصدَّر أهمّ.

5.1. التقريب إلى C ABI

نرتّب الكلمات أوّلاً. جوهر هذه المقالة «تصميم سطح الحدّ كـ C ABI لا كـ .NET»، و ABI اختصار Application Binary Interface، وهو اتفاق كيف تتلاقى الثنائيّات المجمَّعة في وقت التشغيل. اعتبروه اتّفاقاً على مستوى لغة الآلة لا مستوى الشيفرة المصدر. محتواه أساساً ثلاثة.

الاتّفاق ماذا يقرّر ماذا يحدث إن لم يُحفَظ هنا
اتّفاقيّة الاستدعاء (calling convention) كيف تُمرَّر الوسائط في السجلات والمكدّس، وأين تُوضع قيمة الإرجاع، ومن يعيد المكدّس بعد الاستدعاء: المستدعي أم المستدعى انزياح الوسائط، وكسر المكدّس مباشرة بعد العودة
تخطيط النوع كم بايت لكلّ نوع، وفي أيّ موضع تصطفّ أعضاء struct (الحشو والمحاذاة) تعذّر قراءة القيمة من منتصف البنية
الاسم والربط تهجئة اسم الدالّة المصدَّرة، ووجود الزخرفة (decoration) من عدمه تعذّر عثور GetProcAddress على الاسم

cdecl و stdcall اسما العنصر الأوّل «اتّفاقيّة الاستدعاء» من بين هذه. أصناف C++ والاستثناءات تختلف فيها هذه الاتّفاقات الثلاثة حسب المصرِّف، لذا إخراجها كما هي إلى الحدّ لا يتلاقى. بالمقابل، التضييق على دوالّ C والأنواع الأساسيّة يجعل الاتّفاق بسيطاً فيسهل التلاقي. «التقريب إلى C ABI» يعني إنزال سطح الحدّ إلى نطاق هذا الاتّفاق البسيط.

محتوى اتّفاق اسمه ABIABI اتّفاق كيف تتلاقى الثنائيّات المجمَّعة في وقت التشغيل، ويتكوّن من ثلاثة: اتّفاقيّة الاستدعاء التي تقرّر تمرير الوسائط وقيمة الإرجاع، وتخطيط النوع، واسم التصدير والزخرفة.ABI (اتّفاق مستوى لغة الآلة)اتّفاقيّة الاستدعاءتخطيط النوعالاسم والربطcdecl و stdcall اسما هذا الموضع

الشكل 11: «التقريب إلى C ABI» إنزال سطح الحدّ إلى نطاق تكون فيه هذه الاتّفاقات الثلاثة بسيطة.

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

  • أنواع أساسيّة مثل int32_t / int64_t / double
  • struct بتخطيط ثابت
  • مقبض يعادل intptr_t / void*
  • uint8_t* وطول

بالمقابل، ما لا تريدون تسريبه إلى الخارج من البداية هذا النطاق.

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

محاولة عبور هذه كما هي تعكّر سطح الحدّ سريعاً. المهمّ عدم تسريب ملاءمات C# إلى C++، وعدم تسريب ملاءمات C++ إلى C# أكثر من اللازم.

أنواع تُخرج إلى الحدّ وأنواع لا تُسرَّبما يُخرج إلى الحدّ يُقرَّب إلى الأنواع الأساسيّة و struct بتخطيط ثابت والمقبض والمؤشّر والطول، ولا تُسرَّب string و object و List(T) و Task وأصناف C++ و STL إلى الخارج.أنواع تُخرج إلى الحدّأنواع أساسيّة و struct ثابت ومقبضمؤشّر وطولأنواع لا تُسرَّب إلى الخارجstring و List(T) و Taskأصناف C++ و STL

الشكل 12: المهمّ في العمل اليوميّ «ما لا يُصدَّر». لا تسرّبوا ملاءمات الطرفين إلى الحدّ.

لتقليل أخطاء النسخ، نضع أيضاً جدول التقابل. توقيع الميثود الموسومة بـ UnmanagedCallersOnly لا يقبل إلا أنواعاً blittable، لذا ينحصر عمليّاً في هذا النطاق.

جانب C# جانب C / C++ ملاحظة
byte / sbyte uint8_t / int8_t  
short / ushort int16_t / uint16_t  
int / uint int32_t / uint32_t  
long / ulong int64_t / uint64_t long في C++ 32bit على Windows و 64bit على LP64 في Linux، لذا الأأمن عدم كتابته long واستخدام int64_t
nint / nuint intptr_t / uintptr_t عرض المؤشّر. في بناء 32bit يصير 32bit
float / double float / double  
bool لا يُستخدم ليس blittable. مرّروا 0 / 1 بـ int32_t
char / string لا يُستخدم السلاسل كما في 5.2 تُعامل بمؤشّر + طول
T* (مؤشّر unsafe) T* القيمة الخارجة تُعاد بهذا
struct بتخطيط ثابت struct بالتخطيط نفسه رتّبوا ترتيب الأعضاء والنوع والحشو على الجانبين حتماً

5.2. معاملة السلاسل بمؤشّر + طول + سعة المخزن المؤقّت

عند الرغبة في تبادل سلاسل، يغري إخراج 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);

أي تقرير ترميز الأحرف والطول ومن يخصّص المخزن المؤقّت أوّلاً. لأنّه Windows فثمّة خيار التقريب إلى UTF-16، لكنّ النظر إلى لغات أخرى يجعل UTF-8 أسهل تعاملاً في كثير من الحالات.

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

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

في العمل اليوميّ يسهل التعامل بـ:

  • قيمة الإرجاع status code
  • البيانات الفعليّة مخزن مؤقّت out أو وسيط مؤشّر
  • جلب معلومات إضافيّة بشكل get_last_error عند الحاجة

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

تصميم خطأ لا يعبر فيه الاستثناء الحدّعدم تسريب استثناء managed كما هو إلى جهة الاستدعاء، وجعل قيمة الإرجاع status code، والبيانات الفعليّة مخزن مؤقّت out أو وسيط مؤشّر، وجلب معلومات إضافيّة بشكل get_last_error عند الحاجة.استثناء داخل C#التقاطه داخل الحدّقيمة الإرجاع status codeالبيانات الفعليّة بوسيط مؤشّرالمعلومات الإضافيّة بشكل get_last_error

الشكل 13: لا يُترك الاستثناء يعبر الحدّ، بل يُترجم إلى عالم status code ووسيط مؤشّر ويُعاد.

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

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

خصوصاً إن أمكن التعامل مع x86، فإبهام هذا يصير شاقاً لاحقاً. على x64 قد لا يظهر على السطح، لكنّ تقرير القاعدة من البداية أفضل.

5.5. إبقاء ميثودات الـ Export رقيقة ووضع الجسم في موضع آخر

الميثود الموسومة بـ UnmanagedCallersOnly ليست بافتراض الاستدعاء كما هي من شيفرة managed عاديّة. لذا البدء بكتابة منطق الأعمال كلّه فيها يصعّب الاختبار أيضاً.

في العيّنة أيضاً وُضعت إدارة الكيان في AccumulatorStore، و NativeExports المصدَّر مدخل رقيق فقط. هذا مهمّ جدّاً.

  • ميثود التصدير: نافذة ABI
  • الصنف الداخليّ: منطق C# عاديّ

بهذا التقسيم يمكن التفكير في حدّ C++ وشيفرة جسم C# منفصلين.

التصدير رقيق والجسم في موضع آخرإبقاء ميثود التصدير الموسومة بـ UnmanagedCallersOnly نافذة ABI رقيقة، ووضع إدارة الكيان ومنطق الأعمال في صنف داخليّ، يفصل الحدّ عن الجسم ويسهّل الاختبار.ميثود التصدير (نافذة رقيقة)صنف داخليّ (C# عاديّ)مسؤوليّة فحص ABI والتحويل فقطقابل للاختبار كـ C# عاديّ

الشكل 14: لا تبدأوا كتابة منطق الأعمال في التصدير. تقسيم النافذة والجسم يسهّل الصيانة والاختبار.

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

هذا الترتيب ينسجم براحة كبيرة في مواضع كهذه.

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

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

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

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

7. الحالات التي لا تلائمه رغم ذلك

طبعاً هذا ليس لكلّ شيء. المواضع التي لا تلائمه واضحة أيضاً.

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

في النهاية، إمكان القطع بـ C ABI هو خطّ التقسيم. إن تعذّر القطع، فجسر آخر أصفى.

الحالات التي لا تلائمه والجسر البديلالتعامل مع أصناف C++ أو الاستثناءات كما هي يذهب إلى C++/CLI، وعالم تسجيل COM أو الأتمتة إلى COM، وعبور bitness أو حدّ العمليّات إلى COM أو IPC، وإضافة بافتراض التفريغ لا يلائم هذا الترتيب نفسه.أنواع C++ أو الاستثناءات كما هيعالم التسجيل أو الأتمتةعبور bitness أو العمليّاتالتفريغ لاحقاًماذا تطلبونإلى C++/CLI أو غلافإلى سياق COMإلى COM أو IPC أو عمليّة منفصلةهذا الترتيب لا يلائم الافتراض

الشكل 16: خطّ التقسيم «هل يمكن القطع بـ C ABI». إن تعذّر فالجسر الآخر أصفى.

8. مواضع التعثّر

أخيراً نلخّص النقاط التي يسهل التعثّر فيها بهدوء عند تصدير Native AOT.

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

هذه كلّها حديث «بعد معرفتها، نعم كذلك». لكنّ الوقوع فيها مرّة دون معرفة يمرّر وقتاً عسيراً جدّاً.

9. الخلاصة

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

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

نذكر النقاط مرّة أخرى فقط.

  • لا تُظهروا C# كما هو، بل سطّحوا إلى C ABI
  • أعلنوا إدارة العمر بأسلوب المقبض
  • اعبروا الحدّ بكود خطأ لا باستثناء
  • ثبّتوا اتّفاقيّة الاستدعاء
  • أبقوا ميثود التصدير رقيقة، وافصلوا المنطق الداخليّ

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

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

الشكل 17: خمس قواعد الخلاصة. تصميم الحدّ الهادئ هو الأكثر نفعاً للصيانة لاحقاً.

10. مراجع

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

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

الأسئلة الشائعة

أسئلة شائعة حول موضوع هذه المقالة.

هل يمكن استدعاء شيفرة C# من C++؟
نعم. Native AOT في .NET يصدر مكتبة أصناف C# كمكتبة مشتركة أصلية، ويكشف الميثودات الموسومة بـ UnmanagedCallersOnly كنقاط دخول C. أي يمكن استخدام C# بوصفه native DLL في جهة الاستدعاء، in-process من C/C++.
ما المواضع التي يلائمها هذا الترتيب؟
مواضع تريدون فيها الإبقاء على التطبيق الأصيل كما هو، ونقل أجزاء مثل منطق القرار ومعالجة السلاسل وتفسير الإعدادات وقواعد الحساب فقط إلى C#. السمة أنّ «الجانب الأصيل هو البطل، و C# يُستدعى كمكوّن». بالمقابل، لاستدعاء دوالّ C من C# يلائم P/Invoke، وللتعامل الطبيعي مع أنواع C++ والملكيّة يلائم C++/CLI، ولعبور 32bit/64bit أو حدّ العمليّات يلائم COM/IPC.
ما الملاحظات عند تصميم الـ API؟
ما يُصدَّر يبقى مدخل دالّة C فحسب، لذا لا تُظهر string أو List<T> أو الاستثناءات كما هي على الحدّ. سطّحوا إلى C API مسطّح مثل create / destroy / operate، وأعلنوا إدارة العمر وأكواد الخطأ، وعاملوا السلاسل بمؤشّر + طول + سعة المخزن المؤقّت، ولا تدعوا الاستثناءات تعبر الحدّ، وثبّتوا اتّفاقيّة الاستدعاء. النقطة تصميم سطح الحدّ كـ C ABI لا كـ .NET.
هل ثمّة شيفرة عيّنة تعمل؟
نعم. في مستودع komurasoft-blog-samples على GitHub مجموعة عيّنات قابلة للبناء والتشغيل تتضمّن مكتبة C# تُصدَر بـ Native AOT، ومثال استدعاء من C++، واختبارات وحدات. أمثلة الشيفرة تفترض DLL على Windows، لكنّ الفكرة شبه نفسها على Linux / macOS.

الملف الشخصي للمؤلف

صفحة الملف الشخصي لمؤلف المقالة.

غو كومورا

مؤسّس شركة كومورا سوفت ذ.م.م.

يركّز على تطوير برامج ويندوز، والاستشارات التقنية، والتحقيق في الأخطاء، ويتميّز في المشاريع التي تبقى فيها الأصول القديمة ناشطة، وفي تشخيص الأعطال التي يصعب تحديد سببها.

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