لذلك ، بدأت هذه القصة مع صدفة ثلاثة عوامل. أنا:
- كتب في الغالب في C # ؛
- تخيلت فقط كيف يتم ترتيبها وتعمل ؛
- أصبح مهتما في المجمع.
أدى هذا المزيج البريء على ما يبدو إلى ظهور فكرة غريبة: هل من الممكن الجمع بين هذه اللغات بطريقة أو بأخرى؟ أضف في C # القدرة على القيام بإدراج أداة التجميع ، مثلها في C ++.
إذا كنت مهتمًا بالعواقب التي أدت إلى ذلك ، مرحبًا بك في cat.

الصعوبات الأولى
حتى في تلك اللحظة ، أدركت أنه من غير المحتمل وجود أدوات قياسية لاستدعاء رمز المجمّع من رمز C # - وهذا يتناقض مع أحد المفاهيم المهمة للغة: أمان الذاكرة. بعد دراسة سطحية لهذه المسألة (والتي أكدت ، من بين أشياء أخرى ، الحدس الأولي - "من خارج الصندوق" لا يوجد أي احتمال) ، أصبح من الواضح أنه بالإضافة إلى المشكلة الإيديولوجية ، هناك مشكلة فنية بحتة: C # ، كما تعلمون ، يتم تجميعها في رمز فرعي وسيط ، والذي مزيد من التفسير من قبل الجهاز الظاهري CLR. وهنا بالضبط نحن نواجه المشكلة ذاتها: من ناحية ، المترجم (أعني فيما يلي روسلين من مايكروسوفت ، لأنه في الواقع هو المعيار في مجال المترجمين C #) ، من الواضح أنه لا يمكن التعرف عليه و قم بترجمة تعليمات المجمّع من عرض نص إلى تمثيل ثنائي ، مما يعني أنه يجب علينا استخدام إرشادات الجهاز مباشرةً في شكلها الثنائي كإدراج ، ومن ناحية أخرى ، فإن الجهاز الظاهري له رمزه الخاص ولا يمكنه التعرف على ذلك وتنفيذه الأوامر المجمعة التي نقدمها لها.
الحل النظري لهذه المشكلة واضح - تحتاج إلى التأكد من أن كود الإدراج الثنائي يتم تنفيذه من قبل المعالج ، وتجاوز تفسير الجهاز الظاهري. إن أبسط ما يتبادر إلى الذهن هو تخزين الشفرة الثنائية كصفيف من وحدات البايت ، والتي سيتم نقل التحكم بها بطريقة أو بأخرى في الوقت المناسب. من هنا تلوح المهمة الأولى: تحتاج إلى التوصل إلى وسيلة لنقل التحكم إلى ما هو موجود في منطقة ذاكرة تعسفية.
النموذج الأول: "استدعاء" مجموعة
هذه المهمة ربما تكون أخطر عقبة أمام إدراجها. باستخدام أدوات اللغة ، من السهل الحصول على مؤشر إلى صفيفنا ، ولكن مؤشرات C # في العالم موجودة فقط على البيانات ومن المستحيل تحويلها إلى مؤشر لقيام دالة ، على سبيل المثال ، حتى يمكن استدعاؤها لاحقًا (جيدًا ، أو على الأقل لا يمكنني معرفة كيفية للقيام).
لحسن الحظ (أو لسوء الحظ) ، ليس هناك ما هو جديد تحت سطح القمر والبحث السريع في ياندكس عن الكلمات "C #" و "إدراج المجمّع" قادني إلى مقال في عدد
كانون الأول / ديسمبر 2007 من المجلة]] [عكر] . بعد أن قمت بنسخ الوظيفة من هناك بصراحة وتكييفها مع احتياجاتي ، حصلت عليها
[DllImport("kernel32.dll")] extern bool VirtualProtect(int* lpAddress, uint dwSize, uint flNewProtect, uint* lpflOldProtect); public void* InvokeAsm(void* firstAsmArg, void* secondAsmArg, byte[] code) { int i = 0; int* p = &i; p += 0x14 / 4 + 1; i = *p; fixed (byte* b = code) { *p = (int)b; uint prev; VirtualProtect((int*)b, (uint)code.Length, 0x40, &prev); } return (void*)i; }
الفكرة الرئيسية لهذا الرمز هي استبدال عنوان المرسل من الدالة
InvokeAsm()
على المكدس بعنوان صفيف البايت الذي تريد نقل التحكم إليه. ثم ، بعد الخروج من الوظيفة ، بدلاً من الاستمرار في تنفيذ البرنامج ، سيبدأ تنفيذ الشفرة الثنائية الخاصة بنا.
سنتعامل مع السحر الذي
InvokeAsm()
في
InvokeAsm()
بمزيد من التفاصيل. أولاً ، نعلن عن متغير محلي ، والذي يظهر بالطبع على المكدس ، ثم نحصل على عنوانه (وبالتالي نحصل على عنوان الجزء العلوي من الرصة). بعد ذلك ، نضيف إليها ثابتًا سحريًا معينًا تم الحصول عليه عن طريق حساب إزاحة عنوان المرسل في مصحح الأخطاء في الجزء العلوي من المكدس في المصحح ، وحفظ عنوان المرسل وكتابة عنوان صفيف البايت الخاص بنا بدلاً من ذلك. المعنى المقدس لحفظ عنوان المرسل واضح - نحن بحاجة إلى مواصلة تنفيذ البرنامج بعد الإدراج لدينا ، مما يعني أننا بحاجة إلى معرفة مكان نقل التحكم بعده. التالي يأتي استدعاء دالة WinAPI من مكتبة kernel32.dll -
VirtualProtect()
. هناك حاجة لتغيير سمات صفحة الذاكرة التي يوجد بها رمز الإدراج. بالطبع ، عند تجميع البرنامج ، يظهر في قسم البيانات ، وتتضمن صفحة الذاكرة المقابلة حق الوصول للقراءة والكتابة. نحتاج أيضًا إلى إضافة إذن لتنفيذ محتوياته. أخيرًا ، نعيد عنوان المرسل الحقيقي المخزن. بالطبع ، لن يتم إرجاع هذا العنوان إلى التعليمات البرمجية التي تسمى
InvokeAsm()
، لأن التنفيذ مباشرة بعد
return (void*)i;
"فشل" في إدراج. ومع ذلك ، فإن اصطلاحات الاتصال المستخدمة من قبل الجهاز الظاهري (stdcall مع تعطيل التحسين و fastcall مع تمكين) تعني إرجاع القيمة من خلال سجل EAX ، أي للرجوع من الإضافة ، نحتاج إلى اتباع إرشادات اثنين:
push eax
(الرمز 0x50) و
ret
(الكود 0xC3).
إعدادفي المستقبل ، سنتحدث عن بنية x86 (أو بالأحرى ، IA-32) - مبتذلة بسبب حقيقة أنني في ذلك الوقت كنت على الأقل على دراية به ، على عكس ، على سبيل المثال ، x86-64. ومع ذلك ، يجب أن تعمل طريقة نقل التحكم المذكورة أعلاه من أجل رمز 64 بت.
أخيرًا ، يجب الانتباه إلى الوسيطتين غير المستخدمة:
void* firstAsmArg
و
void* secondAsmArg
. هناك حاجة لنقل بيانات المستخدم التعسفي إلى إدراج المجمّع. ستكون هذه الوسائط موجودة في مكان معروف على المكدس (stdcall) ، أو مرة أخرى في سجلات معروفة (fastcall).
قليلا عن التحسيننظرًا لأنه ، من وجهة نظر المترجم ، ما الذي يدور في الكود ، لا يفهم ما ، قد يلقي بطريق الخطأ بعض الدعوة المهمة / إضافة شيء مضمّن / لا ينقذ وسيطة "غير مستخدمة" / يتداخل بطريقة ما مع تنفيذ خطتنا. يتم حل هذا جزئيًا بواسطة [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)]
، ومع ذلك ، حتى هذه الاحتياطات لا تعطي التأثير المطلوب: على سبيل المثال ، المتغير المحلي i
، والذي هو مفتاح الوظيفة بأكملها ، يتحول فجأة إلى سجل . لذلك ، من أجل القضاء تمامًا على احتمال حدوث خطأ ما ، يجب عليك إنشاء مكتبة مع تعطيل التحسين (إما تعطيله في خصائص المشروع أو استخدام تكوين Debug). وبالتالي ، سيتم استخدام stdcall ، لذلك في المستقبل سأنتقل من اتفاقية الاتصال هذه.
تحسينات
آمنة أفضل من غير آمنة
بالطبع ، لا يوجد أي سؤال عن الأمان (بمعنى استخدام هذه الكلمة في C #). ومع ذلك ، فإن طريقة
InvokeAsm()
الموضحة أعلاه تعمل على مؤشرات ، مما يعني أنه لا يمكن استدعاؤها إلا من الكتلة المحددة بالكلمة الرئيسية
unsafe
، والتي لا تكون مريحة دائمًا - على الأقل تتطلب التصنيف مع رمز التبديل / غير الآمن (أو علامة الاختيار المقابلة في خصائص المشروع في VS). لذلك ، يبدو من المنطقي توفير غلاف يعمل على الأقل IntPtr (في أسوأ الأحوال) ، ومن الناحية المثالية ، يسمح للمستخدم بتحديد الأنواع المراد إرسالها وإعادتها. حسنًا ، يبدو هذا كأنه عام ، نكتب عامًا ، ماذا يوجد ، كما يسأل المرء ، للحديث عنه؟ في الواقع - هناك شيء ما.
الأكثر وضوحا: كيفية الحصول على مؤشر إلى وسيطة نوع غير معروف؟ لا يُسمح
T* ptr = &arg
النوع
T* ptr = &arg
في C # ، وبشكل عام ، ليس من الصعب فهم السبب: قد يستخدم المستخدم أحد الأنواع المدارة كمعلمة type ، وهو مؤشر لا يمكن الحصول عليه. قد يكون الحل هو تقييد المعلمة من النوع
unmanaged
، لكن أولاً ، ظهرت فقط في C # 7.3 ، وثانياً ، لا تسمح بتمرير السلاسل والصفائف كوسائط ، على الرغم من أن المشغل
fixed
يسمح باستخدامها (نحصل على المؤشر إلى الأول الحرف أو عنصر الصفيف ، على التوالي). حسنًا ، بالإضافة إلى ذلك ، أود أن أعطي المستخدم الفرصة للعمل بما في ذلك الأنواع الخاضعة للرقابة - بما أننا بدأنا في انتهاك قواعد اللغة ، فإننا سننتهكها حتى النهاية!
الحصول على مؤشر إلى كائن تتم إدارته وكائن بالمؤشر
ومرة أخرى ، بعد مداولات غير مثمرة للغاية ، بدأت في البحث عن الحلول النهائية. هذه المرة ساعدني
مقال هابري . باختصار ، إحدى الطرق المقترحة في ذلك هي كتابة مكتبة مساعدة ، وليس في لغة C # ، ولكن مباشرةً في IL. وتتمثل مهمتها في دفع كائن (في الواقع مرجع إلى الكائن) إلى مكدس الجهاز الظاهري ، وتمريره كوسيطة ، ثم استرداد شيء آخر من المكدس - على سبيل المثال ، رقم أو
IntPtr
. عن طريق القيام بنفس الخطوات بترتيب عكسي ، يمكنك تحويل المؤشر (على سبيل المثال ، يتم إرجاعه من إدراج المجمّع) إلى كائن. هذه الطريقة جيدة لأن كل ما يحدث واضح وشفاف. ولكن هناك ناقصًا: أردت الحصول على أقل عدد ممكن من الملفات ، لذا بدلاً من كتابة مكتبة منفصلة ، قررت تضمين كود IL في المكتبة الرئيسية. الطريقة الوحيدة التي اكتشفتها هي كتابة طرق كعب الروتين في C # ، وبناء المشروع ، وتفكيك الثنائي باستخدام ildasm ، وإعادة كتابة رمز أساليب كعب الروتين ووضعه مرة أخرى مع ilasm. هذه بعض الإجراءات الإضافية ، وبالنظر إلى أنك تحتاج إلى القيام بها في كل مرة تقوم بإنشائها بعد إجراء أي تغييرات على الكود ... بشكل عام ، تعبت من ذلك بسرعة كبيرة ، وبدأت أبحث عن بدائل.
في ذلك الوقت ، وقع كتاب رائع في يدي ، بفضله تعلمت الكثير لنفسي - "CLR via C #" لجيفري ريختر. في ذلك ، في مكان ما حول الفصل العشرين ، تحدثنا عن بنية
GCHandle
، التي لديها طريقة
Alloc()
تأخذ كائنًا وواحدًا من
GCHandleType
تعداد
GCHandleType
. لذلك ، إذا قمت باستدعاء هذه الطريقة بتمريرها الكائن المطلوب و
GCHandle.Pinned
، يمكنك الحصول على عنوان هذا الكائن في الذاكرة. علاوة على ذلك ، قبل الاتصال بـ
GCHandle.Free()
يتم إصلاح الكائن ، أي محمية بالكامل من آثار جامع القمامة. ومع ذلك ، هناك بعض المشاكل. بادئ
GCHandle
، لا تساعد
GCHandle
بأي شكل من الأشكال على إكمال تحويل "المؤشر → كائن" ، فقط "كائن → مؤشر". الأهم من ذلك ، لاستخدام
GCHandleType.Pinned
للفئة أو بنية الكائن الذي نريد الحصول على عنوانه سمة
[StructLayout(LayoutKind.Sequential)]
، بينما
LayoutKind.Auto
استخدام
LayoutKind.Auto
. لذلك هذه الطريقة مناسبة فقط لبعض الأنواع القياسية وللأنواع المخصصة التي تم تصميمها في الأصل مع وضع ذلك في الاعتبار. ليس بالضبط الطريقة العالمية التي نود أن نجدها ، أليس كذلك؟
حسنا ، حاول مرة أخرى. الآن دعنا ننتبه إلى وظيفتين غير
__makeref()
، والتي ، مع ذلك ، تدعمها
__makeref()
:
__makeref()
و
__refvalue()
. يأخذ أولهم كائنًا ويعيد مثيل بنية
TypedReference
التي تخزن مرجعًا للكائن ونوعه ، بينما يستخرج الثاني الكائن من مثيل
typedReference
. لماذا هذه الميزات مهمة بالنسبة لنا؟ لأن
TypedReference
هو هيكل! في سياق المناقشة ، هذا يعني أنه يمكننا الحصول على مؤشر لها ، والذي سيشكل ، مجتمعة ، مؤشرًا إلى الحقل الأول من هذه البنية. وهي تخزن الرابط ذاته إلى الشيء الذي يهمنا. بعد ذلك ، للحصول على مؤشر إلى كائن مدار ، نحتاج إلى قراءة القيمة بواسطة مؤشر إلى ما
__makeref()
وتحويله إلى مؤشر. للحصول على كائن عن طريق المؤشر ، يجب عليك استدعاء
__makeref()
من كائن فارغ مشروط من النوع المطلوب ، والحصول على مؤشر إلى مثيل
TypedReference
إرجاعه ، وكتابة مؤشر إلى الكائن عليه ، ثم استدعاء
__refvalue()
. والنتيجة هي شيء مثل هذا الرمز:
public static Tout ToInstance<Tout>(IntPtr ptr) { Tout temp = default; TypedReference tr = __makeref(temp); Marshal.WriteIntPtr(*(IntPtr*)(&tr), ptr); Tout instance = __refvalue(tr, Tout); return instance; } public static void* ToPointer<T>(ref T obj) { if (typeof(T).IsValueType) { return *(void**)&tr; } else { return **(void***)&tr; } }
تعليقبالعودة إلى مهمة كتابة مجمّع آمن لـ InvokeAsm()
، تجدر الإشارة إلى أن طريقة الحصول على مؤشرات باستخدام __makeref()
و __refvalue()
، بخلاف استخدام GCHandle.Alloc(GCHandleType.Pinned)
، لا تضمن عدم وجود أداة تجميع مجمعي البيانات المهملة الخاصة بنا في أي مكان الكائن لن يتحرك. لذلك ، يجب أن يبدأ المجمع عن طريق إيقاف تشغيل أداة تجميع مجمعي البيانات المهملة وتنتهي باستعادة وظائفه. الحل وقح إلى حد ما ، لكنه فعال.
بالنسبة لأولئك الذين لا يتذكرون الشفرات
لذلك ، تعلمنا كيفية استدعاء الكود الثنائي ، وتعلمنا تمرير ليس فقط القيم المباشرة ، ولكن أيضًا المؤشرات إلى أي شيء كحجج ... هناك مشكلة واحدة فقط. أين يمكن الحصول على نفس الكود الثنائي؟ يمكنك تسليح نفسك بقلم رصاص ومفكرة وجدول شفرة التشغيل (على سبيل المثال ،
هذا واحد ) أو اتخاذ محرر سداسي عشرية مع دعم x86 المجمّع أو حتى مترجم كامل ، لكن كل هذه الخيارات تعني أن المستخدم سيضطر إلى استخدام شيء آخر باستثناء المكتبة. هذا ليس ما أردت تمامًا ، لذلك قررت أن أضم المترجم الخاص بي في المكتبة ، والذي كان يطلق عليه تقليديًا SASM (اختصار لـ Stack Assembler ؛ لا علاقة له بـ
IDE ).
تنصلأنا لست جيدًا في تحليل السلاسل ، وبالتالي فإن رمز المترجم ... حسنًا ، غير كامل ، على أقل تقدير. بالإضافة إلى ذلك ، أنا لست قويًا في التعبيرات المعتادة ، لذا فهي غير موجودة. وبشكل عام - محلل تكراري.
ربما لن أتحدث عن عملية إنشاء هذه "المعجزة" - لا يوجد شيء مثير للاهتمام في هذه القصة ، لكنني سأصف الميزات الرئيسية بإيجاز. معظم تعليمات x86 معتمدة حاليا. إرشادات المعالج الثانوي الرياضية للعمل مع أرقام الفاصلة العائمة ومن الامتدادات (MMX ، SSE ، AVX) غير مدعومة بعد. من الممكن الإعلان عن الثوابت والإجراءات ومتغيرات المكدس المحلية والمتغيرات العامة التي يتم تخصيص الذاكرة لها أثناء الترجمة مباشرةً في صفيف ذي شفرة ثنائية (إذا تم تسمية هذه المتغيرات باستخدام الملصقات ، فيمكن أيضًا الحصول على قيمتها من C # بعد إجراء الإدراج عن طريق استدعاء أساليب
GetBYTEVariable()
و
GetWORDVariable()
و
GetDWORDVariable()
و
GetAStringVariable()
و
GetWStringVariable()
للكائن
SASMCode
) ،
addr
invoke
وحدات الماكرو. إحدى الميزات المهمة هي دعم استيراد الوظائف من المكتبات الخارجية باستخدام
extern < > lib < >
.
الماكرو
asmret
يستحق فقرة منفصلة. في عملية الترجمة ، تتكشف في 11 تعليمات تشكل الخاتمة. تتم إضافة prolog إلى بداية التعليمات البرمجية المترجمة بشكل افتراضي. مهمتهم هي حفظ / استعادة حالة المعالج. بالإضافة إلى ذلك ، يضيف المقدمة أربعة ثوابت -
$first
و
$second
و
$this
و
$return
. أثناء الترجمة ، يتم استبدال هذه الثوابت بعناوين على المكدس ، والتي ، على التوالي ، هي الوسيطتين الأولى والثانية التي تم تمريرها إلى إدراج المجمّع ، وعنوان أمر الإدراج الأول وعنوان المرسل.
يؤدي
ستقول الشفرة أكثر من مجرد كلمات ، وسيكون من الغريب عدم مشاركة نتيجة عمل طويل جدًا ، لذلك أدعو كل شخص يهمني إلى
GitHub .
مع ذلك ، إذا حاولت تعميم كل شيء تم إنجازه بطريقة أو بأخرى ، في رأيي ، فإن مشروعًا مثيرًا للاهتمام ، وحتى ، إلى حد ما ، لم ينجح. على سبيل المثال ، تختلف الخوارزميات المتطابقة لفرز إدراج في C # واستخدام إدراج المجمّع في السرعة بأكثر من مرتين (بالطبع ، لصالح المجمّع). في المشروعات الخطيرة ، بالطبع ، لا يوصى باستخدام المكتبة الناتجة (الآثار الجانبية غير متوقعة ممكنة ، وإن لم يكن ذلك مرجحًا للغاية) ، لكن هذا ممكن تمامًا لنفسك.