استدعاء Win32 API بأمان من C# ── دليل P/Invoke العمليّ (DllImport / LibraryImport / CsWin32)

· آخر تحديث: · · P/Invoke, DllImport, LibraryImport, CsWin32, C#, .NET, Win32, SafeHandle, التكامل مع الشيفرة الأصليّة, تطوير Windows, الاستشارات التقنية

في مدوّنتنا هذه، كتبنا سابقاً عدّة مقالات تدور حول التشغيل التبادليّ الأصليّ (native interop)، مثل «الفصل بين غلاف C++/CLI وP/Invoke»، و«طريقة استدعاء C# Native AOT DLL من C/C++»، و«جسر COM لاستدعاء 64bit DLL من تطبيق 32bit»، و«آليّة تحليل أسماء DLL في Windows». لكن لم يكن هناك بعدُ مقال يتناول بشكل مُجمَّع P/Invoke نفسها التي تُشكِّل الأساس لكلّ ذلك.

يتمتّع P/Invoke بسهولة «يمكن استدعاء دالّة DLL عبر تصريح extern»، لكنّه في الوقت نفسه تقنيّة يقع فيها المرء مرّة واحدة على الأقلّ في مشكلة ما، سواء في ترحيل السلاسل النصّيّة، أو عمر المقبض، أو الحصول على رمز الخطأ، أو تخطيط البُنى. في هذا المقال، نُنظِّم النقاط الواجب مراعاتها في العمل الفعليّ، محوره LibraryImport الذي يُشكِّل القيمة الافتراضيّة ابتداءً من .NET 7.

1. الخلاصة أوّلاً

  • ابتداءً من .NET 7 فما بعده، اجعل LibraryImport الخيار الافتراضيّ بدل DllImport. لأنّه يُنشئ شيفرة الترحيل في وقت الترجمة، فهو متوافق مع Native AOT والتقليم، ولا يتكبّد تكلفة توليد IL stub في وقت التشغيل، ويمكن تنفيذ الشيفرة المُولَّدة خطوة بخطوة في المُصحِّح. المحلِّل SYSLIB1054 يُشير إلى مواضع إعادة كتابة DllImport.12
  • عند كتابة Win32 API يدويّاً، انظر في CsWin32. بمجرّد سرد أسماء الدوالّ المطلوبة في NativeMethods.txt، يُولِّد توقيعات LibraryImport والثوابت والبُنى من بيانات Win32 الرسميّة.3
  • صرِّح بـ StringMarshalling للسلاسل النصّيّة، وتجنَّب StringBuilder. ترحيل StringBuilder يستلزم دائماً نسخ المخزن المؤقّت الأصليّ، وهي آليّة غير فعّالة وسهلة الخطأ في معالجة النهاية.4
  • احتفظ بالمقابض عبر صنف مشتقّ من SafeHandle لا عبر IntPtr خام. هذه هي القاعدة الأساسيّة في التشغيل التبادليّ الأصليّ الخاصّ بـ .NET، لمنع التحرير المبكِّر للمقبض والتحرير المزدوج و«هجوم إعادة التدوير» الناتجة عن GC.56
  • إن أضفت SetLastError = true، اقرأ Marshal.GetLastPInvokeError() فور الاستدعاء. يجب الحصول عليه قبل أن يُعيد تنفيذ شيفرة مُدارة أخرى الكتابة فوق رمز الخطأ.7
  • اجعل LayoutKind.Sequential هو الافتراضيّ للبُنى، وانتبه إلى ما إذا كنت ستُصرِّح بـ Pack أم لا. تخطيط Pack = 0 (الافتراضيّ) الفعليّ يختلف عن القيمة الافتراضيّة لخيار /Zp الخاصّ بمُترجِم C++‏ (8 على x86/ARM/ARM64، و16 على x64/ARM64EC)، وقد يختلف أيضاً بين .NET Framework وNET 5+. لا تفترض أنّ «القيمة الافتراضيّة صحيحة بالضرورة».8
  • أدِر عمر ردود النداء (delegate) حتّى لا يجمعها GC قبل انتهاء الجانب الأصليّ من استخدامها. احتفظ بها في حقل static أو استخدم GC.KeepAlive، وفضِّل UnmanagedCallersOnly إن أمكن.9
  • P/Invoke وغلاف C++/CLI والتشغيل التبادليّ عبر COM ليست بدائل متنافسة بل تقسيم أدوار. واجهة C بسيطة تناسب P/Invoke، وأصناف C++ مع الملكيّة والاستثناءات تناسب C++/CLI، وتجاوز حدود العمليّة (كجسر 32/64 بت) يناسب COM؛ الجدول في الفصل 10 يُلخِّص محاور هذا القرار.

2. DllImport مقابل LibraryImport ── أيّهما نستخدم

DllImport آليّة قديمة، يُنشئ فيها وقت التشغيل IL stub لأغراض الترحيل، ثمّ يُترجَم عبر JIT قبل الاستدعاء. لأنّ التوليد يحدث وقت التشغيل، فهو لا يتوافق جيّداً مع بُنى مثل Native AOT والتقليم التي تُترجَم فيها التجميعات مسبقاً، وتكلفة التوليد نفسها ليست معدومة.1

LibraryImport مولِّد مصدر أُضيف في .NET 7، يُنشئ شيفرة الترحيل وقت الترجمة لدوالّ partial. بما أنّ الشيفرة المُولَّدة موجودة كمصدر C#‎، يمكن تنفيذها خطوة بخطوة في المُصحِّح، وتُكتَشف أخطاء التوقيع مبكِّراً كخطأ بناء.1

using System.Runtime.InteropServices;

internal static partial class NativeMethods
{
    [LibraryImport("nativelib", EntryPoint = "to_lower", StringMarshalling = StringMarshalling.Utf16)]
    internal static partial string ToLower(string str);
}

توجد فرضيّة يسهل إغفالها في هذه القيمة المُعادة من نوع string. يحاول الترحيل دائماً تحرير الذاكرة التي يشير إليها المؤشِّر المُعاد، بعد نسخ السلسلة النصّيّة التي يشير إليها. على Windows يُستخدَم CoTaskMemFree، لذا إن حجز الجانب الأصليّ ذلك المؤشِّر بغير CoTaskMemAlloc (كمخزن مؤقّت ثابت، أو malloc، أو new[]، وهي أساليب ليست نادرة في واجهات C)، فسيحرِّر الترحيل الذاكرة بمُخصِّص خاطئ، ما يؤدّي إلى تلف الكومة (heap) أو انهيار (crash).10 ما لم توضِّح رؤوس (headers) أو وثائق الطرف الآخر صراحةً حجزاً متوافقاً مع CoTaskMemAlloc، استقبِل القيمة المُعادة عبر IntPtr بدل string، واستدعِ بنفسك دالّة التحرير المقابلة (أو إجراء التحرير الذي يتطلّبه الطرف الآخر). حجز المخزن المؤقّت من جانب الاستدعاء ثمّ تمريره (كمصفوفة أحرف بديلة عن StringBuilder المذكورة لاحقاً، أو نمط المخزن [Out] الموضَّح لاحقاً) يُغنيك أصلاً عن جلب هذا النوع من غموض الملكيّة.

الفروق الرئيسيّة عن DllImport هي كالتالي:11

  • أُلغي CharSet واستُبدِل بـ StringMarshalling (‏Utf16/‏Utf8/‏مخصَّص). أُلغيت ANSI وأصبحت UTF-8 خياراً من الدرجة الأولى.
  • استُبدِل CallingConvention بـ UnmanagedCallConvAttribute.
  • لا يوجد مقابل لِـ ExactSpelling وPreserveSig. اسم نقطة الدخول يُحدَّد دائماً بتهجئته الدقيقة، ويجري تحويل القيمة المُعادة دائماً بشكل مباشر.
  • يجب جعل كلٍّ من الصنف والدالّة المستدعاة partial، ويحتاج المشروع إلى AllowUnsafeBlocks.

ما زال DllImport ضروريّاً عند الاعتماد على إعدادات لا يدعمها LibraryImport (كبعض تحديدات MarshalAs). يُخبرك المحلِّل بخطأ حين تحاول استخدام إعداد غير مدعوم، لذا فإنّ المسار الواقعيّ هو كتابة LibraryImport أوّلاً، والعودة إلى DllImport عند رفضه.11

3. CsWin32 ── خيار عدم كتابة التوقيعات يدويّاً

عند تصريح DllImport/LibraryImport واحداً تلو الآخر لدوالّ Win32 API، تتراكم مخاطر الخطأ في نوع المعامل (parameter) وقيمة الثابت وترتيب حقول البُنية. CsWin32 (‏Microsoft.Windows.CsWin32‏) مولِّد مصدر يُولِّد تلقائيّاً توقيع الدالّة المطلوبة والثوابت والبُنى المرتبطة بها من بيانات Win32 API الرسميّة (metadata).3

طريقة الاستخدام بسيطة: أضِف حزمة NuGet إلى المشروع، واسرد أسماء الدوالّ المطلوب استدعاؤها في ملفّ نصّيّ باسم NativeMethods.txt.

GetDpiForWindow
SetWindowPos
CreateFileW
CloseHandle

عند البناء، تُولَّد توقيعات P/Invoke لهذه الدوالّ (بما فيها القيمة المُعادة والمعاملات وتحديد SetLastError). انتبه إلى أنّ الافتراضيّ هو التوليد المبنيّ على DllImport التقليديّ. إن كنت تستهدف Native AOT والتقليم، يمكن التبديل إلى شيفرة مولَّدة مبنيّة على LibraryImport بتحديد allowMarshaling: false في NativeMethods.json.3 يظهر HANDLE كنوع مشتقّ من SafeHandle مناسب، وتُخرَج السلاسل النصّيّة بـ CharSet/‏StringMarshalling الصحيح، ما يمنع أصلاً أخطاء مثل الخلط في CharSet أو ترتيب حقول البُنية الشائعة عند الكتابة اليدويّة.

كما كُتب في «المواضع التي يفيد فيها غلاف C++/CLI»، فإنّ إدراج غلاف رفيع فعّال مع DLL معقّد تتداخل فيه أصناف C++ والملكيّة والاستثناءات، لكن إن كان الطرف الآخر Win32 API بسيطاً (أو DLL بواجهة C مماثلة له)، فإنّ توليد التوقيع تلقائيّاً عبر CsWin32 هو المسار الأقصر والأقلّ عرضةً للأخطاء. لا يمكن استخدام CsWin32 مع DLL خاصّ بشركتك، لكن يمكن حتّى في تلك الحالة الاستفادة من أسلوب كتابة الشيفرة المُولَّدة كنموذج مرجعيّ.

4. مطبّات ترحيل السلاسل النصّيّة

يُسنِد مترجمو C#‎ وVB وF#‎ افتراضيّاً CharSet.None لتصريح P/Invoke الذي لا يُحدِّد CharSet صراحةً. السلوك الفعليّ لـ CharSet.None مطابق لـ CharSet.Ansi، ويُرحَّل على Windows كترميز غير Unicode (صفحة رموز محلَّية/localized code page). إن كانت واجهة Win32 API المُستدعاة تفترض النسخة Unicode (لاحقة W)، فإنّ الاستدعاء بهذه القيمة الافتراضيّة يُسبِّب فساد الأحرف أو فقدان الأحرف متعدِّدة البايت.12

في LibraryImport، الأصل هو التصريح بـ StringMarshalling.Utf16. بما أنّ خيار ANSI نفسه أُلغي، أصبح الحادث الشائع في عهد DllImport («الاعتماد على القيمة الافتراضيّة والانتهاء بـ ANSI دون قصد») أقلّ حدوثاً من الناحية البنيويّة.11

مطبّ آخر هو معامل StringBuilder. يُستخدَم كثيراً في واجهات API التي «يكتب فيها الجانب الأصليّ في مخزن السلسلة النصّيّة ويُعيدها»، لكنّ ترحيل StringBuilder يستلزم دائماً نسخاً إلى المخزن المؤقّت الأصليّ، ويحدث تخصيص إضافيّ آخر عند ToString(). إن كان المخزن المؤقّت [Out] (الافتراضيّ)، فهذه آليّة غير فعّالة تتراكم فيها تخصيصات متعدِّدة في كلّ استدعاء. إضافةً إلى ذلك، لها عيب في سوء التصرّف عندما يكون المخزن المُعاد غير منتهٍ بـ NUL، أو منتهياً بـ NUL مزدوج. في الاستدعاءات عالية التواتر، استخدام مصفوفة أحرف من ArrayPool<char> أكثر استقراراً.4

يُفضَّل أيضاً تجنُّب معامل [Out] string. إذا كانت السلسلة النصّيّة مُدرَجة في التخزين الداخليّ (interned)، فقد يُصبح وقت التشغيل غير مستقرّ.4

5. إدارة عمر المقبض ── سبب استخدام SafeHandle

الاحتفاظ بموارد أصليّة مثل مقبض ملفّ، أو مفتاح سجلّ (registry key)، أو مقبض جهاز، كـ IntPtr خام، تصميمٌ ينبغي تجنُّبه في التشغيل التبادليّ الأصليّ الخاصّ بـ .NET. الأسباب ثلاثة:5

  • التحرير المبكِّر للمقبض بواسطة GC. إذا كان صنف يُنفِّذ finalizer يحتفظ بالمقبض في حقل IntPtr، فقد يحدث تنافس يُغلق فيه GC ذلك الكائن ويُغلِق المقبض أثناء تنفيذ استدعاء P/Invoke.
  • هجوم إعادة تدوير المقبض. تُعيد Windows استخدام قيم المقابض بفاعليّة. إن استمرّ استخدام IntPtr قديم بينما أُعيد تخصيص قيمة المقبض التي كان يُفترَض إغلاقها لمورد آخر، يؤدّي ذلك إلى حادث خطير في التعامل مع مورد غير ذي صلة.
  • التسرّب بسبب استثناءات غير متزامنة. إن حدث انقطاع غير متزامن، كإيقاف خيط (thread interruption)، بين الحصول على المقبض وتخزينه في الحقل، فقد يحدث تسرّب في المقبض.

SafeHandle صنف مجرَّد صُمِّم لحلّ هذه المشكلات. يرث من CriticalFinalizerObject، ما يضمن تنفيذ إجراء التحرير بشكل مؤكَّد حتّى عند إنهاء AppDomain بشكل غير طبيعيّ. استدعاءات P/Invoke تزيد وتُنقِص عدّاد مراجع المقبض تلقائيّاً، لذا لا يحدث إعادة تدوير للمقبض أثناء الاستدعاء أيضاً.5

عند التنفيذ الذاتيّ، رث من SafeHandleZeroOrMinusOneIsInvalid وأمثاله من نطاق Microsoft.Win32.SafeHandles، وأعِد تعريف ReleaseHandle(). تعمل ReleaseHandle() ضمن منطقة تنفيذ مقيَّدة (constrained execution region) يُفترَض فيها «عدم الفشل»، لذا القاعدة المتَّبعة هي عدم كتابة منطق معقَّد، والاكتفاء باستدعاء API تحرير بسيط. لا حاجة لكتابة finalizer بنفسك (بل ينبغي تجنّبه).6

6. معالجة الأخطاء ── SetLastError وGetLastPInvokeError

كثير من واجهات Win32 API يضبط عند الفشل رمز خطأ مخصَّصاً للخيط (thread-local) عبر SetLastError، ويقرؤه جانب الاستدعاء عبر GetLastError. للتعامل مع ذلك في P/Invoke، اجعل DllImportAttribute.SetLastError (وله خاصيّة بنفس الاسم في LibraryImport أيضاً) قيمته true.13

[LibraryImport("kernel32", EntryPoint = "SetCurrentDirectoryW", StringMarshalling = StringMarshalling.Utf16, SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool SetCurrentDirectoryW(string path);

هنا نقطتان تستحقّان الانتباه.

  • اقرأ رمز الخطأ فور الاستدعاء. في .NET (باستثناء .NET Framework)، تُمسَح معلومات الخطأ عند كلّ استدعاء P/Invoke مضبوط بـ SetLastError = true، ويُحتفَظ فقط بنتيجة تلك المرّة الواحدة من الاستدعاء. إن تخلّلها إخراج سجلّ (log) أو استدعاء API آخر، تُكتَب فوقها وتُفقَد، لذا احصل على القيمة في نفس اللحظة التي تكتشف فيها الفشل.13
  • استخدم Marshal.GetLastPInvokeError() لا Marshal.GetLastWin32Error(). ابتداءً من .NET 6 هما متطابقان وظيفيّاً، لكن الأوّل هو الاسم الأحدث المُوصى به لعكس النيّة عبر المنصّات (cross-platform).7
if (!SetCurrentDirectoryW(path))
{
    int error = Marshal.GetLastPInvokeError();
    throw new Win32Exception(error);
}

7. ترحيل البُنى ── الأنواع القابلة للنسخ المباشر وStructLayout

الأنواع التي يتطابق تمثيلها البتّيّ بين .NET والشيفرة الأصليّة تُسمّى «قابلة للنسخ المباشر» (blittable)، ويمكن تمريرها كما هي دون تحويل، ما يجعلها سريعة. تندرج تحتها الأنواع الأساسيّة مثل byte وint وlong، والبُنى ذات التخطيط الثابت المكوَّنة فقط من أنواع قيمة قابلة للنسخ المباشر. بالنسبة للبُنى القابلة للنسخ المباشر، استخدام sizeof() الخاصّ بـ C#‎ أسرع من Marshal.SizeOf<T>(). بالمقابل، bool ليس قابلاً للنسخ المباشر (الفرق بين BOOL الأصليّ في Windows، وهو 4 بايتات، وبين bool في C/C++‎، وهو بايت واحد)، واستخدامه دون انتباه يُنشئ خطأً يُهدَر فيه نصف القيمة المُعادة.14

يُتحكَّم في تخطيط البُنية عبر StructLayoutAttribute. استخدم LayoutKind.Sequential (الترتيب حسب ترتيب التصريح) كافتراضيّ، ولا تستخدم LayoutKind.Explicit إلّا عند الحاجة إلى تحديد موضع الحقول صراحةً، كما في الاتّحادات (union).8

ما يسهل إغفاله هو حقل Pack. بحسب الوثائق الرسميّة، يُحدَّد محاذاة النوع بأكمله بأصغر ما بين «حجم أكبر حقل» و«قيمة Pack المحدَّدة»، ويُحدَّد موضع كلّ حقل بأصغر ما بين «حجمه الخاصّ» و«محاذاة النوع».8 بعبارة أخرى، إن ضُبطت Pack صراحةً على قيمة صغيرة (كـ 2 أو 4)، فإنّها تعمل كحدّ أقصى للمحاذاة، تماماً مثل #pragma pack(N) في C++‎. في المقابل، القيمة الافتراضيّة 0 تعني «جعل محاذاة النوع بأكمله مساويةً لحجم أكبر حقل (دون فرض حدّ أعلى خاصّ إضافيّ)»، وهذه قاعدة مختلفة عن القيمة الافتراضيّة لخيار /Zp الخاصّ بمُترجِم C++‎ (محاذاة أعضاء البُنية؛ الافتراضيّ حدود 8 بايتات على x86/ARM/ARM64، وحدود 16 بايتاً على x64/ARM64EC)، ولا يمكن ببساطة اعتبارهما شيئاً واحداً.15 إضافةً إلى ذلك، قد يختلف هذا التخطيط الافتراضيّ نفسه بين .NET Framework وNET 5+. فمثلاً، تذكر الوثائق الرسميّة أنّ بُنية تحتوي على decimal يختلف حجمها في التعبئة الافتراضيّة، إذ يكون 28 بايتاً في .NET Framework و32 بايتاً في .NET 5+ بسبب اختلاف تركيب الحقول الداخليّة.8 أي أنّ افتراض «القيمة الافتراضيّة صحيحة بالضرورة» على مستوى المعماريّة (architecture) أمرٌ ممنوع. إن كنت تتعامل مع DLL يُغيِّر فيه رأس الجانب الأصليّ حجم التعبئة صراحةً عبر #pragma pack، أو يحتوي على حقول تتطلّب محاذاة تتجاوز 8 بايتات، فينبغي إمّا تحديد Pack صراحةً في جانب C#‎، أو التحقّق من إزاحة الحقول (field offsets) الفعليّة عبر Marshal.OffsetOf ونحوه قبل الاستخدام. إهمال هذا يؤدّي إلى انزياح إزاحات الحقول وحادث تلف صامت للبيانات. بالمقابل، إن كان الطرف الآخر واجهة بسيطة تستخدم رؤوس Windows SDK كما هي، وجميع الحقول من أنواع أساسيّة بحجم 8 بايتات أو أقلّ، فنادراً ما تحدث مشكلة عمليّة حتّى دون لمس Pack مع المحاذاة الافتراضيّة.

// مثال في حال حدَّد رأس (header) الجانب الأصليّ صراحةً pack(4)
[StructLayout(LayoutKind.Sequential, Pack = 4)]
internal struct DeviceInfo
{
    public int DeviceId;
    public uint Flags;
    public long Timestamp;
}

8. إدارة عمر ردود النداء (delegate)

من الشائع تمرير رَدّ نداء (callback) إلى واجهة API أصليّة بمعنى «استدعِ هذه الدالّة عند الانتهاء». في الشيفرة المُدارة يتولّى delegate هذا الدور، لكن يوجد فخّ خاصّ بـ GC هنا. حتّى إن حصلتَ على مؤشِّر دالّة من delegate عبر Marshal.GetFunctionPointerForDelegate، لا يتتبّع GC العلاقة بين مؤشِّر الدالّة ذاك والـ delegate. إن جُمِع الـ delegate بينما لا يزال الجانب الأصليّ يستخدم مؤشِّر الدالّة ذاك، يؤدّي ذلك إلى انهيار.9

مطبّ آخر يسهل إغفاله هو اتّفاقيّة الاستدعاء (calling convention). عند تمرير delegate إلى الجانب الأصليّ كمؤشِّر دالّة عبر P/Invoke، تُستخدَم افتراضيّاً «اتّفاقيّة الاستدعاء الافتراضيّة للمنصّة»، لكن إن أردت المطابقة الصريحة، أضِف UnmanagedFunctionPointerAttribute إلى نوع الـ delegate.16 في x64/ARM/ARM64، اتّفاقيّة الاستدعاء واحدة عمليّاً فلا ضرر عادةً من عدم الانتباه إليها، لكن في Windows x86 (32 بت) يختلف Stdcall (الافتراضيّ في Win32 API) عن Cdecl (الشائع في مكتبات C ذات الأصل اليونكسيّ)، وإن استخدم الطرف الآخر Cdecl بينما بقيتَ على الافتراضيّ، يؤدّي ذلك إلى تلف المكدّس (stack).16

// تصريح صريح باتّفاقيّة الاستدعاء. ضروريّ هنا إن كان الطرف الآخر يستخدم Cdecl في بناء x86
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate void MyCallback(int code);

private static readonly MyCallback s_callback = OnNativeEvent;  // الاحتفاظ بها كحقل static لضمان عمرها

// [UnmanagedFunctionPointer] هو اتّفاقيّة "استدعاء" رَدّ النداء نفسه،
// وهي أمر مختلف عن اتّفاقيّة هذا الاستدعاء ذاته (RegisterCallback وهو P/Invoke).
// افتراضيّ LibraryImport هو الافتراضيّ الخاصّ بالمنصّة (يعادل stdcall على Windows)،
// لذا إن كان الطرف الآخر Cdecl في DLL بلغة C فالتصريح الصريح مطلوب هنا أيضاً
[LibraryImport("nativelib")]
[UnmanagedCallConv(CallConvs = new[] { typeof(CallConvCdecl) })]
internal static partial void RegisterCallback(MyCallback callback);

private static void OnNativeEvent(int code)
{
    // ...
}

// جانب الاستدعاء
RegisterCallback(s_callback);
GC.KeepAlive(s_callback);  // إطالة عمر متغيّر قد يخرج من النطاق فور ذلك، بشكل صريح

إن احتُفِظ بها في حقل static، فلن يجمعها GC طوال عمر التطبيق. فقط عندما يكون مؤكَّداً أنّ الجانب الأصليّ لن يستخدم رَدّ النداء إلّا خلال استدعاء واحد (ويتخلّص من مؤشِّر الدالّة عند عودة رَدّ النداء)، يمكن أيضاً إطالة العمر بأسلوب أخفّ عبر متغيّر محلّيّ مع GC.KeepAlive.

توصي أفضل الممارسات الرسميّة، حين يكون ذلك ممكناً، باستخدام دالّة ثابتة (static method) مُزيَّنة بـ UnmanagedCallersOnlyAttribute مع مؤشِّر دالّة (delegate*<...>‏) بدل نوع Delegate. تكلفتها الإضافيّة أقلّ من ترحيل الـ delegate، وتتناسب أكثر مع Native AOT.9

9. اختلافات 32 بت/64 بت

بمجرّد كتابة توقيع P/Invoke واحد، يُستخدَم في وقت التشغيل مسار الشيفرة نفسه سواء من عمليّة 32 بت أو 64 بت. المشكلة التي تظهر غالباً هنا هي أنّ عرض النوع في الجانب الأصليّ يتبع عدد بتّات العمليّة.

  • الأنواع الشبيهة بالمؤشِّرات مثل HANDLE وHWND وLPARAM تكون 4 بايتات في العمليّة 32 بت، و8 بايتات في العمليّة 64 بت. الصحيح في جانب .NET هو استقبالها عبر IntPtr/‏UIntPtr (أو nint/‏nuint‏)، أمّا استقبالها عبر int/‏long ثابت الحجم فيُنشئ شيفرة تعمل في 32 بت أو 64 بت فقط دون الآخر.4
  • إن احتوت البُنية على حقول من النوع المُشار إليه أعلاه (شبيهة بالمؤشِّرات)، يتغيّر حجم البُنية بأكملها أيضاً حسب عدد البتّات. مع اختلاف القيمة الافتراضيّة لِـ Pack باختلاف المعماريّة كما في الفصل 7، افترض في الاختبار أنّ تخطيط البُنية الثنائيّ (binary layout) قد يختلف بين بناء 32 بت وبناء 64 بت حتّى لتعريف البُنية نفسه.
  • المتطلّب الذي مفاده «الرغبة في استخدام وظيفة DLL لا تعمل إلّا على 64 بت من تطبيق قائم 32 بت» لا يمكن حلّه عبر P/Invoke نفسه (لا يمكن لِـ DLL بعددَي بت مختلفَين التعايش في العمليّة نفسها). في هذه الحالة، يكون التصميم بفصل العمليّة وبناء جسر عبر COM أو أنبوب مُسمّى (named pipe). راجع «مثال جسر COM لاستدعاء 64bit DLL من تطبيق 32bit» للاطّلاع على مثال فعليّ.
  • مشكلة «DLL غير موجود أصلاً» أو «تحميل إصدار غير مقصود» ليست مشكلة P/Invoke بل مشكلة مُحمِّل (loader) Windows. راجع «آليّة تحليل أسماء DLL في Windows» الذي يُنظِّم ترتيب البحث وسلوك SxS، مفيد عند التحقيق في سبب DllNotFoundException.

10. جدول القرار ── P/Invoke مقابل غلاف C++/CLI مقابل التشغيل التبادليّ عبر COM

استدعاء شيفرة أصليّة من C#‎ ليس محصوراً بـ P/Invoke. إن كان الطرف الآخر DLL معقّداً تتضمّن أصناف C++‎ والملكيّة والاستثناءات، فإنّ غلاف C++/CLI فعّال، وإن تجاوزتَ حدود العمليّة (جسر 32/64 بت، أو الاستخدام من لغات أخرى مثل VBA)، فإنّ COM خيار مطروح.

المحور P/Invoke (‏LibraryImport‏) غلاف C++/CLI التشغيل التبادليّ عبر COM
الطرف المناسب واجهة C بسيطة (محورها البُنى والأنواع الأوّليّة) DLL تتداخل فيه أصناف C++‎، والملكيّة، والاستثناءات، وأنواع std:: طرف يتجاوز العمليّة، أو لغات أخرى مثل VBA
تكلفة التنفيذ منخفضة إلى متوسّطة (تعريف التوقيع فقط) متوسّطة (كتابة طبقة غلاف إضافيّة) عالية (تصميم الواجهة، تسجيل السجلّ)
سلامة الأنواع (type safety) متوسّطة (قد لا تظهر أخطاء التوقيع اليدويّة إلّا وقت التشغيل؛ يتحسَّن ذلك مع CsWin32) عالية (يمكن التعامل مع أنواع C++‎ كما هي) متوسّطة (تُضمَن عبر IDL/مكتبة الأنواع)
توافق AOT/التقليم ممتاز (مع LibraryImport) ضعيف (C++/CLI لا يدعم Native AOT) ضعيف
التعامل مع الاستثناءات غير موجود (حكم ذاتيّ عبر القيمة المُعادة أو HRESULT) ممتاز (يمكن تحويل استثناءات C++‎ إلى استثناءات .NET) جيّد (يُحوَّل HRESULT إلى استثناء COM)
تجاوز حدود العمليّة غير ممكن (داخل العمليّة نفسها فقط) غير ممكن (داخل العمليّة نفسها فقط) ممتاز (خادم خارج العمليّة ممكن)
سهولة التصحيح (debugging) جيّدة (يمكن تنفيذ شيفرة LibraryImport المُولَّدة خطوة بخطوة) جيّدة (يمكن تصحيح الشيفرة الأصليّة والمُدارة معاً في VS) ضعيفة (يصعب تتبّع مشكلات عدّاد المراجع والتسجيل)
تكلفة التعلّم منخفضة متوسّطة إلى عالية (بنية C++/CLI) عالية (اتّفاقيّات COM كافّةً)

القاعدة التي لا تُضلِّل هي: إن كان الطرف الآخر Win32 API مبنيّ على دوالّ C، أو DLL خاصّ بشركتك بواجهة بسيطة، فـ P/Invoke (وCsWin32 إن أمكن)؛ إن كان الطرف الآخر أصنافاً في C++‎ وتريد التعامل بشكل طبيعيّ حتّى مع الملكيّة والاستثناءات، فغلاف C++/CLI (التفاصيل في «استدعاء DLL أصليّ من C#‎: غلاف C++/CLI مقابل P/Invoke‏»)؛ وإن كنت تتجاوز حدود العمليّة أصلاً، أو تريد الاستخدام من VBA، فـ COM. في الاتّجاه المعاكس (استدعاء معالجة C#‎ من C/C++‎)، لا تُستخدَم P/Invoke بل بُنية تعتمد على UnmanagedCallersOnly الخاصّة بـ Native AOT. راجع «طريقة استدعاء C# Native AOT DLL من C/C++‎‏».

11. مثال تنفيذ ── التعامل مع المقابض ومعالجة الأخطاء عبر LibraryImport

فيما يلي مثال تنفيذ يجمع بين محتوى ما سبق. نُغلِّف OpenDevice/‏CloseDevice/‏ReadDeviceData التي تكشفها SDK افتراضيّة لجهاز استشعار خياليّ device.dll، متضمّنين إدارة المقابض عبر SafeHandle، وترحيل وقت الترجمة عبر LibraryImport، ومعالجة الأخطاء عبر SetLastError + GetLastPInvokeError.

أوّلاً، صنف مشتقّ من SafeHandle يحتفظ بالمقبض الأصليّ.

using Microsoft.Win32.SafeHandles;

// يُغلِّف مقبض device.dll. يمنع التحرير المزدوج للمقبض، وهجوم إعادة التدوير،
// والتحرير المبكِّر، بشكل مستقلّ عن عمر GC
internal sealed class DeviceSafeHandle : SafeHandleZeroOrMinusOneIsInvalid
{
    // مطلوب منشئ (constructor) بلا معاملات لأنّه يُستخدَم كقيمة مُعادة لِـ OpenDevice
    public DeviceSafeHandle() : base(ownsHandle: true)
    {
    }

    protected override bool ReleaseHandle()
        // داخل ReleaseHandle منطقة تنفيذ مقيَّدة يُفترَض فيها "عدم الفشل".
        // اكتفِ باستدعاء تحرير أصليّ بسيط واحد فقط
        => DeviceNativeMethods.CloseDevice(handle);
}

بعد ذلك، تصريحات P/Invoke. نُصرِّح صراحةً بـ StringMarshalling.Utf16 للسلاسل النصّيّة، ونضيف SetLastError = true لكلّ استدعاء قد يفشل.

using System.Runtime.InteropServices;

internal static partial class DeviceNativeMethods
{
    private const string DeviceDll = "device.dll";

    // بجعل المقبض قيمة مُعادة، يتتبَّع SafeHandle عمره فور نجاح الاستدعاء.
    // عند الفشل تُعاد مقبض قيمة IsInvalid فيها true
    [LibraryImport(DeviceDll, EntryPoint = "OpenDevice",
        StringMarshalling = StringMarshalling.Utf16, SetLastError = true)]
    internal static partial DeviceSafeHandle OpenDevice(string devicePath);

    // واجهة برمجيّة داخليّة (internal API) تُستدعى مباشرةً من ReleaseHandle الخاصّة بـ SafeHandle.
    // بما أنّ handle مخصَّص للتحرير فقط، يُستقبَل عبر IntPtr خام
    [LibraryImport(DeviceDll, EntryPoint = "CloseDevice", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    internal static partial bool CloseDevice(IntPtr handle);

    // buffer مصفوفة محجوزة مسبقاً من جهة الاستدعاء. بما أنّ byte[] قابلة للنسخ المباشر (blittable) تُثبَّت (pin)،
    // وتُنفَّذ كتابة الجانب الأصليّ على الذاكرة نفسها. تحديد [Out] صراحةً ليس
    // ضروريّاً بحدّ ذاته، لكنّه يُضاف لتوثيق النيّة ذاتيّاً
    [LibraryImport(DeviceDll, EntryPoint = "ReadDeviceData", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    internal static partial bool ReadDeviceData(
        DeviceSafeHandle handle,
        [Out] byte[] buffer,
        int bufferLength,
        out int bytesRead);
}

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

using System.ComponentModel;
using System.Runtime.InteropServices;

public sealed class DeviceConnection : IDisposable
{
    private readonly DeviceSafeHandle _handle;

    private DeviceConnection(DeviceSafeHandle handle) => _handle = handle;

    public static DeviceConnection Open(string devicePath)
    {
        DeviceSafeHandle handle = DeviceNativeMethods.OpenDevice(devicePath);
        if (handle.IsInvalid)
        {
            // احصل عليه فور الفشل، قبل أن يُعيد استدعاء API آخر الكتابة فوقه
            int error = Marshal.GetLastPInvokeError();
            handle.Dispose();
            throw new IOException(
                $"تعذَّر فتح الجهاز: {devicePath} (Win32 error {error})",
                new Win32Exception(error));
        }
        return new DeviceConnection(handle);
    }

    public byte[] Read(int maxBytes)
    {
        var buffer = new byte[maxBytes];
        if (!DeviceNativeMethods.ReadDeviceData(_handle, buffer, buffer.Length, out int bytesRead))
        {
            int error = Marshal.GetLastPInvokeError();
            throw new IOException($"فشلت قراءة الجهاز (Win32 error {error})",
                new Win32Exception(error));
        }
        return bytesRead == buffer.Length ? buffer : buffer[..bytesRead];
    }

    // يكفي استدعاء SafeHandle.Dispose، ولا حاجة لكتابة finalizer
    public void Dispose() => _handle.Dispose();
}

يكفي جانب استخدام DeviceConnection أن يُحيطه بـ using، دون الحاجة إلى القلق بشأن نسيان تحرير المقبض. المبدأ الخاصّ بمكان اكتشاف كلّ شيء وكيفيّة تحويله في هذا التصميم هو نفسه توزيع المسؤوليّات حسب الطبقة المشروح في «أين يُوضَع catch والسجلّ في معالجة الاستثناءات‏»، وينطبق مباشرةً هنا أيضاً. النقطة الأساسيّة هي رسم الخطّ الفاصل بحيث تُترجَم رموز الخطأ في الطبقة الأصليّة إلى استثناءات عند حدود P/Invoke، وتُعامَل فوق ذلك الحدّ كاستثناءات .NET عاديّة.

12. الخلاصة

P/Invoke تقنيّة تقع فيها المرء مرّة واحدة على الأقلّ في مشكلة ما، سواء في ترحيل السلاسل النصّيّة، أو عمر المقبض، أو توقيت الحصول على رمز الخطأ، أو تخطيط البُنية، خلف سهولة «يمكن استدعاؤها بمجرّد التصريح بدالّة DLL». اجعل LibraryImport الخيار الافتراضيّ ابتداءً من .NET 7 فما بعده، وإن أمكن دَع CsWin32 يُولِّد التوقيع نفسه. صرِّح بـ StringMarshalling للسلاسل النصّيّة وتجنَّب StringBuilder. احتفظ بالمقابض عبر SafeHandle. عند استخدام SetLastError، احصل على رمز الخطأ فور الاستدعاء. تذكَّر أنّ القيمة الافتراضيّة لِـ Pack في البُنية تختلف باختلاف المعماريّة. أدِر عمر ردود النداء صراحةً ── كلّ نقطة وردت في هذا المقال من النوع الذي «يكفيه بضعة أسطر إن عرفتَه، لكنّه يتحوَّل إلى عطل لا يظهر إلّا في بيئة الإنتاج إن جهلتَه».

أمّا القرار بين المضيّ في P/Invoke أو التحوّل إلى غلاف C++/CLI أو COM، فيُحدَّد بمدى «كونيّة الطابع C» للـ DLL المقابل، وبضرورة تجاوز حدود العمليّة أم لا. الاستشارة حول الرغبة في استدعاء أصل أصليّ قائم من C#‎، أو العكس، استدعاء أصل C#‎ من شيفرة أصليّة، غالباً ما لا تتّضح فيها البُنية المثلى إلّا بالنظر إلى ملفّات الرؤوس أو بنية DLL الفعليّة، فلا تتردَّدوا في التواصل معنا عند التردّد.

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

مجالات الاستشارة ذات الصلة

تتعامل شركة Komura Soft LLC مع تصميم الحدّ الفاصل بين C#‎ وDLL الأصليّة/‏Win32 API، وتطوير مكوّنات COM والتحقيق فيها، والاستشارة التقنيّة حول مشاريع الترحيل التي تربط الأصول الأصليّة القائمة بـ .NET.

روابط مرجعيّة

  1. Microsoft Learn، Source generation for platform invokes. حول توليد الترحيل وقت الترجمة عبر LibraryImportAttribute، والفرق عن توليد IL stub وقت التشغيل الخاصّ بـ DllImport، والتوافق مع Native AOT/التقليم.  2 3

  2. Microsoft Learn، SYSLIB diagnostics for p/invoke source generation. حول قائمة معرِّفات التشخيص بما فيها المحلِّل SYSLIB1054 الذي يحثّ على إعادة الكتابة من DllImport إلى LibraryImport. 

  3. Microsoft Learn، Build a C# .NET app with WinUI 3 and Win32 interop. حول طريقة إدخال C#/Win32 P/Invoke Source Generator (‏Microsoft.Windows.CsWin32‏)، وإجراء توليد التوقيعات بسرد أسماء الدوالّ في NativeMethods.txt.  2 3

  4. Microsoft Learn، Native interoperability best practices. حول عدم كفاءة ترحيل StringBuilder لاستلزامه نسخ المخزن المؤقّت الأصليّ دائماً، وضرورة تجنّب معامل [Out] string، واستخدام SafeHandle وتجنّب finalizer.  2 3 4

  5. Microsoft Learn، SafeHandle Class. حول آليّة منع SafeHandle للتحرير المبكِّر للمقبض وهجوم إعادة التدوير، وضمان التحرير المؤكَّد عبر CriticalFinalizerObject.  2 3

  6. Microsoft Learn، Native interoperability best practices - General guidance. حول التوجيه باستخدام SafeHandle لإدارة عمر الموارد غير المُدارة وتجنّب استخدام finalizer.  2

  7. Microsoft Learn، Marshal.GetLastPInvokeError Method. حول طريقة الحصول على رمز الخطأ فور استدعاء P/Invoke المضبوط بـ SetLastError=true، وكونه مُوصى به أكثر من GetLastWin32Error ابتداءً من .NET 6.  2

  8. Microsoft Learn، StructLayoutAttribute.Pack Field. حول معنى القيمة الافتراضيّة 0 لِـ Pack («حجم التعبئة الافتراضيّ للمنصّة الحاليّة») وقواعد حساب محاذاة الحقول.  2 3 4

  9. Microsoft Learn، Native interoperability best practices - Prevent delegate collection with GC.KeepAlive. حول عدم تتبّع GC للعلاقة بين مؤشِّر الدالّة المُستحصَل عبر GetFunctionPointerForDelegate والـ delegate، وإطالة العمر عبر GC.KeepAlive، والتوصية باستخدام UnmanagedCallersOnly.  2 3

  10. Microsoft Learn، Default Marshalling Behavior - Memory management with the interop marshaller. حول محاولة الترحيل الدائمة لتحرير الذاكرة التي حجزتها الشيفرة غير المُدارة، وضرورة استخدام IntPtr والتحرير اليدويّ للذاكرة المحجوزة بغير CoTaskMemAlloc لأنّ Windows تستخدم CoTaskMemFree. 

  11. Microsoft Learn، Source generation for platform invokes - Differences from DllImport. حول استبدال CharSet بـ StringMarshalling، واستخدام UnmanagedCallConvAttribute بدل CallingConvention، وعدم وجود مقابل لِـ ExactSpelling/PreserveSig.  2 3

  12. Microsoft Learn، Charsets and marshalling. حول إسناد مترجمي C#‎ وVisual Basic وF#‎ افتراضيّاً CharSet.None عند عدم تحديد CharSet صراحةً، وكون CharSet.None بنفس سلوك CharSet.Ansi (الترحيل بغير Unicode). 

  13. Microsoft Learn، DllImportAttribute.SetLastError Field. حول سلوك ضبط SetLastError على true في .NET (مسح معلومات الخطأ عند كلّ استدعاء).  2

  14. Microsoft Learn، Native interoperability best practices - Blittable types. حول تعريف الأنواع القابلة للنسخ المباشر، ومطبّ عدم كون bool قابلاً للنسخ المباشر، وميزة استخدام sizeof() مع البُنى القابلة للنسخ المباشر. 

  15. Microsoft Learn، /Zp (Struct Member Alignment). حول كون القيمة الافتراضيّة لمحاذاة أعضاء البُنية الخاصّة بمُترجِم C++‎ حدود 8 بايتات على x86/ARM/ARM64، وحدود 16 بايتاً على x64/ARM64EC. 

  16. Microsoft Learn، Unmanaged calling conventions. حول اختلاف اتّفاقيّة الاستدعاء الافتراضيّة بين Stdcall وCdecl في Windows x86، وكونها واحدة عمليّاً في x64/ARM/ARM64، وإمكانيّة تحديد اتّفاقيّة الاستدعاء صراحةً عبر UnmanagedFunctionPointerAttribute.  2

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

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

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

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

هل ينبغي استخدام DllImport أم LibraryImport؟
ابتداءً من .NET 7 فما بعده، اجعل LibraryImport الخيار الافتراضيّ. بينما يُنشئ DllImport في وقت التشغيل IL stub لأغراض الترحيل (marshalling)، يُنشئ LibraryImport شيفرة الترحيل عبر مولّد مصدر (source generator) في وقت الترجمة (compile time)، ما يجعله متوافقاً مع Native AOT والتقليم (trimming)، ويتيح تنفيذ الشيفرة المُولَّدة خطوة بخطوة في المُصحِّح (debugger). المحلِّل SYSLIB1054 يُشير إلى المواضع التي ينبغي إعادة كتابتها من DllImport. لا يُعاد استخدام DllImport إلّا عند الاعتماد على إعدادات لا يدعمها LibraryImport (كبعض تحديدات MarshalAs).
لماذا يكون الاحتفاظ بالمقبض (handle) عبر IntPtr في P/Invoke خطراً؟
بسبب وجود ثلاث مشكلات. أوّلاً، قد يحدث تنافس (race) يؤدّي إلى تحرير مبكِّر، إذ يستطيع GC تجميع الكائن وإغلاق المقبض أثناء تنفيذ استدعاء P/Invoke. ثانياً، تُعيد Windows استخدام قيم المقابض بفاعليّة، ما يفضي إلى هجوم إعادة تدوير (recycling) يؤدّي إلى التعامل مع موارد غير ذات صلة عبر قيمة مقبض كان يُفترَض إغلاقها. ثالثاً، قد يحدث تسرّب في المقبض بسبب استثناءات غير متزامنة (asynchronous exceptions). استخدام صنف مشتقّ من SafeHandle يمنع هذه المشكلات عبر الإدارة التلقائيّة لعدّاد المراجع وضمان التحرير المؤكَّد.
هل توجد طريقة لتجنّب كتابة توقيعات Win32 API يدويّاً؟
يمكن استخدام مولّد المصدر CsWin32 (Microsoft.Windows.CsWin32). بعد إضافة حزمة NuGet، يكفي سرد أسماء الدوالّ المطلوبة في ملفّ نصّيّ باسم NativeMethods.txt، فيولِّد التوقيعات والثوابت والبُنى تلقائيّاً من بيانات Win32 الرسميّة (metadata). يظهر HANDLE كنوع مشتقّ من SafeHandle مناسب، ما يمنع أخطاء مثل الخلط في CharSet أو ترتيب الحقول الشائعة عند الكتابة اليدويّة. القيمة الافتراضيّة هي التوليد المبنيّ على DllImport، لذا إن كنت تستهدف Native AOT فحدِّد allowMarshaling: false في NativeMethods.json.
كيف نُفرِّق الاستخدام بين P/Invoke وغلاف C++/CLI وCOM؟
يُحدَّد ذلك حسب طبيعة الـ DLL المقابل ووجود حدود عمليّة (process boundary) أم لا. إن كان واجهة C بسيطة (محورها البُنى والأنواع الأوّليّة)، فـ P/Invoke هو الأقلّ تكلفةً، ومع Win32 API يكون استخدام CsWin32 معه فعّالاً. إن كان DLL معقّداً تتداخل فيه أصناف C++ والملكيّة (ownership) والاستثناءات وأنواع std::، فإدراج غلاف C++/CLI واحد هو الحلّ. أمّا عند تجاوز حدود العمليّة كجسر 32 بت/64 بت، أو عند الاستخدام من لغات أخرى مثل VBA، فـ COM خيار مطروح. لا يمكن لِـ DLL بعددَي بت مختلفَين التعايش في العمليّة نفسها، وهذا المتطلّب لا يمكن حلّه عبر P/Invoke.

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

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

غو كومورا

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

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

روابط عامة

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