تطبيق تحديث ساخن لرمز C ++ على نظام Linux

الصورة


* رابط إلى المكتبة في نهاية المقال. توضح المقالة نفسها الآليات المطبقة في المكتبة بتفاصيل متوسطة. لم يتم الانتهاء من تنفيذ نظام MacOS بعد ، لكنه لا يختلف كثيرًا عن تطبيق Linux. هذا هو أساسا لتطبيق لينكس.


عندما كنت أمشي في جيثب بعد ظهر أحد أيام السبت ، صادفت مكتبة تنفذ تحديث رمز c ++ أثناء الطيران للنوافذ. أنا نفسي سقطت من النوافذ منذ بضع سنوات ، ولم أندم على ذلك قليلاً ، والآن كل البرامج تتم إما على Linux (في المنزل) أو على macOS (في العمل). غوغلينغ قليلاً ، وجدت أن الأسلوب المتبع من المكتبة أعلاه شائع جدًا ، ويستخدم msvc نفس الأسلوب لوظيفة "تحرير ومتابعة" في Visual Studio. المشكلة الوحيدة هي أنني لم أجد أي تطبيقات تحت النوافذ غير (هل أبدو سيئة؟). بالنسبة إلى السؤال الذي قدمه مؤلف المكتبة أعلاه عما إذا كان سيقوم بإنشاء منفذ لمنصات أخرى ، كان الجواب لا.


يجب أن أقول على الفور أنني كنت مهتمًا فقط بالخيار الذي لن أضطر فيه إلى تغيير رمز المشروع الحالي (على سبيل المثال ، في حالة RCCPP أو cr ، حيث يجب أن تكون جميع التعليمات البرمجية المعاد تحميلها موجودة في مكتبة منفصلة تم تحميلها ديناميكيًا).


"كيف ذلك؟" - فكرت ، وبدأت في إضاءة البخور.


لماذا؟


أنا أساسا القيام gamedev. قضيت معظم وقت عملي في كتابة منطق اللعبة وتخطيط أي بصرية. أنا أيضا استخدام imgui للأدوات المساعدة. دورة العمل الخاصة بي مع الكود ، كما قد تفكر في ذلك ، هي الكتابة -> ترجمة -> تشغيل -> تكرار. كل شيء يحدث بسرعة كبيرة (بناء تدريجي ، وجميع أنواع ccache ، وما إلى ذلك). المشكلة هنا هي أن هذه الدورة يجب أن تتكرر في كثير من الأحيان بما فيه الكفاية. على سبيل المثال ، أكتب ميكانيكيات لعبة جديدة ، فليكن "Jump" ، قفزة صالحة يتم التحكم فيها:


1. كتب مشروع التنفيذ على أساس الزخم ، تجميعها ، أطلقت. رأيت أنني بطريق الخطأ أطبق النبض على كل إطار ، وليس مرة واحدة.


2. ثابت ، وتجميعها ، أطلقت ، الآن طبيعي. ولكن سيكون من الضروري أن تأخذ القيمة المطلقة للنبض أكثر.


3. ثابت ، تجميعها ، أطلقت ، العمل. ولكن بطريقة ما تشعر أنها خاطئة. يجب أن نحاول القيام بذلك على أساس القوة.


4. كتب مشروع التنفيذ على أساس القوة ، تجميعها ، أطلقت ، يعمل. سيكون من الضروري فقط تغيير السرعة الآنية في وقت القفزة.
...


10. ثابت ، تجميعها ، أطلقت ، العمل. ولكن لا يزال غير ذلك. ربما تحتاج إلى محاولة تنفيذ بناءً على تغيير في gravityScale .
...


20. عظيم ، يبدو عظمى! الآن نقوم بإخراج جميع المعلمات في محرر gamediz والاختبار والملء.
...


30. القفزة جاهزة.


وفي كل تكرار ، تحتاج إلى جمع الكود وفي التطبيق الذي تم إطلاقه ، يمكنك الوصول إلى المكان الذي يمكنني القفز منه. هذا عادة ما يستغرق 10 ثوان على الأقل. وإذا كان بإمكاني القفز في منطقة مفتوحة فقط ، ما زال يتعين الوصول إليها؟ وإذا كنت بحاجة إلى أن أكون قادرًا على القفز على كتل بارتفاع وحدات N؟ أحتاج هنا بالفعل لجمع مشهد اختبار ، والذي يحتاج أيضًا إلى تصحيح الأخطاء ، والذي يحتاج أيضًا إلى قضاء بعض الوقت. بالنسبة إلى مثل هذه التكرارات ، سيكون تحديث الكود الساخن مثاليًا. بالطبع ، هذه ليست حلاً سحريًا ، فهي ليست مناسبة لكل شيء ، وبعد إعادة التشغيل تحتاج أحيانًا إلى إعادة إنشاء جزء من عالم اللعبة ، ويجب أخذ ذلك في الاعتبار. ولكن في العديد من الأشياء ، يمكن أن يكون هذا مفيدًا ويمكن أن يوفر الكثير من الاهتمام والكثير من الوقت.


متطلبات وبيان المشكلة


  • عند تغيير الرمز ، يجب أن يحل الإصدار الجديد من جميع الوظائف محل الإصدارات القديمة من نفس الوظائف
  • هذا ينبغي أن تعمل على لينكس وماك
  • لا ينبغي أن يتطلب ذلك إجراء تغييرات على رمز التطبيق الحالي.
  • من الناحية المثالية ، يجب أن تكون هذه مكتبة ، مرتبطة بشكل ثابت أو حيوي بالتطبيق ، بدون أدوات مساعدة تابعة لجهة خارجية
  • من المرغوب فيه ألا تؤثر هذه المكتبة على أداء التطبيق كثيرًا.
  • يكفي إذا كان هذا يعمل مع cmake + make / ninja
  • يكفي إذا كانت ستعمل مع تصميمات debazine (بدون تحسينات ، دون تقليم الأحرف ، وما إلى ذلك)

هذا هو الحد الأدنى من مجموعة المتطلبات التي يجب أن يفيها التنفيذ. في المستقبل ، سوف أصف بإيجاز ما تم تنفيذه بشكل إضافي:


  • نقل قيم المتغيرات الثابتة إلى رمز جديد (راجع قسم "نقل المتغيرات الثابتة" لمعرفة سبب أهمية ذلك)
  • إعادة تحميل على أساس التبعيات (تغيير رأس -> إعادة بنائها نصف المشروع جميع الملفات التابعة)
  • إعادة تحميل التعليمات البرمجية من المكتبات الحيوية

التنفيذ


حتى تلك اللحظة ، كنت بعيدًا تمامًا عن مجال الموضوع ، لذلك اضطررت لجمع واستيعاب المعلومات من نقطة الصفر.


على مستوى عالٍ ، تبدو الآلية كما يلي:


  • نحن نراقب نظام الملفات للتغييرات في المصدر
  • عندما يتغير المصدر ، تقوم المكتبة بإعادة تكوينه باستخدام أمر التحويل البرمجي الذي تم بالفعل ترجمة هذا الملف
  • جميع الكائنات التي تم جمعها مرتبطة بمكتبة محملة ديناميكيًا
  • يتم تحميل المكتبة في مساحة عنوان العملية
  • تحل جميع وظائف المكتبة محل نفس الوظائف في التطبيق.
  • يتم نقل قيم المتغيرات الثابتة من التطبيق إلى المكتبة

لنبدأ بالأكثر إثارة للاهتمام - آلية إعادة تحميل الوظيفة.


إعادة تحميل وظائف


فيما يلي 3 طرق أكثر أو أقل شيوعًا لاستبدال الوظائف في (أو تقريبًا) وقت التشغيل:


  • خدعة باستخدام LD_PRELOAD - تتيح لك إنشاء مكتبة محمّلة ديناميكيًا ، على سبيل المثال ، وظيفة strcpy ، وجعلها بحيث تبدأ تطبيقك بنسخة من strcpy بدلاً من المكتبة عند بدء تشغيل التطبيق
  • تغيير جداول PLT و GOT - يسمح لك بـ "التحميل الزائد" للوظائف المصدرة
  • ربط الوظيفة - يسمح لك بإعادة توجيه مؤشر ترابط التنفيذ من وظيفة إلى أخرى

من الواضح أن الخيارين الأولين غير مناسبين ، حيث إنهما يعملان فقط مع الوظائف المصدرة ، ولا نريد تحديد جميع وظائف تطبيقنا بأي سمات. لذلك ، ربط وظيفة هو خيارنا!


باختصار ، التثبيت يعمل مثل هذا:


  • تم العثور على عنوان الوظيفة
  • يتم استبدال وحدات البايت القليلة الأولى من الوظيفة عن طريق انتقال غير مشروط إلى نص وظيفة أخرى
  • ...
  • الربح!
    في msvc هناك 2 علامات لهذا - /hotpatch و /FUNCTIONPADMIN . أول واحد في بداية كل وظيفة يكتب 2 بايت ، والتي لا تفعل شيئا ، لإعادة كتابة لاحقة مع "قفزة قصيرة". يتيح لك الثاني ترك مساحة فارغة أمام جسم كل وظيفة في شكل تعليمات nop ل "قفزة طويلة" إلى الموقع المطلوب ، لذلك في القفزات 2 يمكنك التبديل من الوظيفة القديمة إلى وظيفة جديدة. يمكنك قراءة المزيد حول كيفية تطبيق ذلك في windows و msvc ، على سبيل المثال ، هنا .

لسوء الحظ ، لا يوجد شيء مماثل في clang و gcc (على الأقل في نظامي Linux و macOS). في الواقع هذه ليست مشكلة كبيرة ، سنكتب مباشرة على رأس الوظيفة القديمة. في هذه الحالة ، نخاطر في الوقوع في مشكلة إذا كان تطبيقنا متعدد مؤشرات الترابط. إذا كان هناك غالبًا في بيئة متعددة الخيوط ، فنحن نحد من الوصول إلى البيانات بواسطة مؤشر ترابط واحد بينما يعدلها مؤشر ترابط آخر ، فنحن بحاجة إلى تقييد القدرة على تنفيذ التعليمات البرمجية على مؤشر ترابط واحد بينما يقوم مؤشر ترابط آخر بتعديل هذا الرمز. لم أحسب كيفية القيام بذلك ، وبالتالي فإن التطبيق سوف يتصرف بشكل غير متوقع في بيئة متعددة الخيوط.


هناك نقطة واحدة خفية. على نظام 32 بت ، 5 بايت كافية لنا "القفز" إلى أي مكان. في نظام 64 بت ، إذا كنا لا نريد إفساد السجلات ، فنحن بحاجة إلى 14 بايت. خلاصة القول هي أن 14 بايت في مقياس رمز الجهاز كثير جدًا ، وإذا كان الرمز يحتوي على أي وظيفة كعب روتين مع نص فارغ ، فمن المحتمل أن يكون طوله أقل من 14 بايت. لا أعرف الحقيقة الكاملة ، لكنني أمضيت بعض الوقت خلف المفكك أثناء التفكير في الكود وكتابته وتصحيحه ، ولاحظت أن جميع الوظائف تتم محاذاتها على حدود 16 بايت (إنشاء تصحيح الأخطاء دون تحسينات ، لست متأكدًا من الشفرة المحسّنة). وهذا يعني أنه بين بداية أي وظيفتين ، سيكون هناك 16 بايت على الأقل ، وهو ما يكفي لنا "للتشويش" عليها. googling السطحية التي قادت هنا ، ومع ذلك ، لا أعرف على وجه اليقين ، كنت محظوظًا ، أو اليوم جميع المترجمين يقومون بذلك. في أي حال ، إذا كنت في حالة شك ، قم فقط بتعريف اثنين من المتغيرات في بداية دالة كعب الروتين بحيث تصبح كبيرة بدرجة كافية.


لذلك ، لدينا المجموعة الأولى - آلية لإعادة توجيه الوظائف من الإصدار القديم إلى الإصدار الجديد.


البحث عن وظائف في برنامج نسخ


نحن الآن بحاجة إلى الحصول على عناوين جميع الوظائف (وليس فقط المصدرة) من برنامجنا أو مكتبة ديناميكية تعسفية. يمكن القيام بذلك بكل بساطة باستخدام api system إذا لم يتم استبعاد الأحرف من التطبيق الخاص بك. على نظام Linux ، هذه هي api من elf.h و link.h ، على loader.h ، loader.h و nlist.h .


  • باستخدام dl_iterate_phdr نذهب إلى جميع المكتبات المحملة ، وفي الواقع ، البرنامج
  • ابحث عن العنوان الذي تم تحميل المكتبة به
  • من قسم .symtab على جميع المعلومات المتعلقة بالأحرف ، أي الاسم والنوع وفهرس القسم الذي يقع فيه وحجمه ، وكذلك حساب عنوانه "الحقيقي" بناءً على العنوان الافتراضي وعنوان تحميل المكتبة

هناك دقة واحدة. عند تنزيل ملف elf ، لا يقوم النظام بتحميل قسم .symtab (صحيح إذا كان خطأ) ، وقسم .dynsym لا .dynsym ، حيث لا يمكننا استخراج الأحرف ذات الرؤية STV_INTERNAL و STV_HIDDEN . ببساطة ، لن نرى مثل هذه الوظائف:


 // some_file.cpp namespace { int someUsefulFunction(int value) // <----- { return value * 2; } } 

وهذه المتغيرات:


 // some_file.cpp void someDefaultFunction() { static int someVariable = 0; // <----- ... } 

وبالتالي ، في الفقرة 3 ، لا نعمل مع البرنامج الذي قدمه dl_iterate_phdr ، ولكن مع الملف الذي قمنا بتنزيله من القرص وتحليله بواسطة بعض المحلل اللغوي (أو على api api). لذلك نحن لا نفتقد أي شيء. في نظام التشغيل MacOS ، يشبه الإجراء ، تختلف أسماء الوظائف من api للنظام فقط.


بعد ذلك ، نقوم بتصفية جميع الشخصيات ونوفر فقط:


  • الدالات التي يمكن إعادة تحميلها هي أحرف من نوع STT_FUNC موجودة في قسم .text ، وهي غير صفرية في الحجم. يتخطى هذا المرشح فقط الوظائف التي يحتوي رمزها بالفعل في هذا البرنامج أو المكتبة
  • المتغيرات الثابتة التي تريد نقل قيمها هي أحرف من النوع STT_OBJECT الموجودة في قسم .bss

وحدات البث


لإعادة تحميل الشفرة ، نحتاج إلى معرفة مكان الحصول على ملفات التعليمات البرمجية المصدر وكيفية تجميعها.


في التطبيق الأول ، قرأت هذه المعلومات من قسم .debug_info ، الذي يحتوي على معلومات تصحيح الأخطاء بتنسيق DWARF. لكي تحصل كل وحدة ترجمة (ET) داخل DWARF على خط تجميع لهذا ET ، يجب أن تمر -grecord-gcc-switches أثناء -grecord-gcc-switches . DWARF نفسها ، قمت بتحليل مكتبة libdwarf ، التي تأتي مع libelf . بالإضافة إلى أمر التحويل البرمجي من DWARF ، يمكنك الحصول على معلومات حول تبعيات ETs لدينا على الملفات الأخرى. لكنني رفضت هذا التطبيق لعدة أسباب:


  • المكتبات ثقيلة جدا
  • تحليل تطبيق DWARF تم تجميعه من ~ 500 ET ، مع تحليل التبعية ، استغرق أكثر من 10 ثوانٍ بقليل

10 ثوان لبدء التطبيق أكثر من اللازم. بعد بعض التفكير ، أعدت كتابة منطق تحليل DWARF إلى تحليل compile_commands.json . يمكن إنشاء هذا الملف ببساطة عن طريق إضافة set(CMAKE_EXPORT_COMPILE_COMMANDS ON) إلى CMakeLists.txt. وبالتالي ، نحصل على جميع المعلومات التي نحتاجها.


التعامل مع التبعية


نظرًا لأننا تخلينا عن DWARF ، نحتاج إلى إيجاد خيار آخر ، هو كيفية التعامل مع التبعيات بين الملفات. لم أرغب حقًا في تحليل الملفات بيدي والبحث عن include ، ومن يعرف المزيد عن التبعيات من المترجم نفسه؟


هناك عدد من الخيارات في clang و gcc التي تولد ما يسمى بالموزعين مجانًا تقريبًا مجانًا. تستخدم هذه الملفات أنظمة الإنشاء و ninja لحل التبعيات بين الملفات. Depfiles لها تنسيق بسيط للغاية:


 CMakeFiles/lib_efsw.dir/libs/efsw/src/efsw/DirectorySnapshot.cpp.o: \ /home/ddovod/_private/_projects/jet/live/libs/efsw/src/efsw/base.hpp \ /home/ddovod/_private/_projects/jet/live/libs/efsw/src/efsw/sophist.h \ /home/ddovod/_private/_projects/jet/live/libs/efsw/include/efsw/efsw.hpp \ /usr/bin/../lib/gcc/x86_64-linux-gnu/7.3.0/../../../../include/c++/7.3.0/string \ /usr/bin/../lib/gcc/x86_64-linux-gnu/7.3.0/../../../../include/x86_64-linux-gnu/c++/7.3.0/bits/c++config.h \ /usr/bin/../lib/gcc/x86_64-linux-gnu/7.3.0/../../../../include/x86_64-linux-gnu/c++/7.3.0/bits/os_defines.h \ ... 

يضع المترجم هذه الملفات بجوار ملفات الكائنات لكل ET ، ويبقى لنا تحليلها ووضعها في hashmap. compile_commands.json تحليل مجموع compile_commands.json + depfiles لنفس 500 ET أكثر بقليل من ثانية واحدة. لكي يعمل كل شيء ، نحتاج إلى إضافة علامة -MD مستوى العالم لجميع ملفات المشاريع في خيار التحويل البرمجي.


هناك دقة واحدة مرتبطة النينجا. يولد نظام الإنشاء هذا ملفًا بصرف النظر عن وجود علامة -MD لاحتياجاتهم. ولكن بعد إنشائها ، يتم ترجمتها إلى تنسيقها الثنائي ، وتحذف الملفات المصدر. لذلك ، عند بدء تشغيل النينجا ، يجب أن تمر علامة -d keepdepfile . أيضًا ، لأسباب غير معروفة بالنسبة لي ، في حالة some_file.cpp.d (مع خيار some_file.cpp.d ، يُسمى الملف some_file.cpp.d ، بينما يسمى some_file.cpp.od . لذلك ، تحتاج إلى التحقق من كلا الإصدارين.


نقل متغير ثابت


لنفترض أن لدينا مثل هذا الرمز (مثال اصطناعي للغاية):


 // Singleton.hpp class Singletor { public: static Singleton& instance(); }; int veryUsefulFunction(int value); // Singleton.cpp Singleton& Singletor::instance() { static Singleton ins; return ins; } int veryUsefulFunction(int value) { return value * 2; } 

نريد تغيير وظيفة veryUsefulFunction إلى هذا:


 int veryUsefulFunction(int value) { return value * 3; } 

عند إعادة التحميل ، في المكتبة الديناميكية برمز جديد ، بالإضافة إلى veryUsefulFunction ، فإن المتغير static Singleton ins; ، وأسلوب Singletor::instance . نتيجة لذلك ، سيبدأ البرنامج في استدعاء إصدارات جديدة من كلتا الوظيفتين. ولكن لم يتم تهيئة ins الثابتة في هذه المكتبة ، وبالتالي ، في المرة الأولى التي يتم الوصول إليها ، سيتم استدعاء مُنشئ فئة Singleton . بالطبع ، نحن لا نريد هذا. لذلك ، ينقل التطبيق قيم جميع هذه المتغيرات التي يجدها في المكتبة الديناميكية المجمعة من الكود القديم إلى هذه المكتبة الديناميكية للغاية مع الكود الجديد مع متغيرات الحراسة الخاصة بهم.


هناك لحظة خفية وغير قابلة للذوبان بشكل عام.
لنفترض أن لدينا فئة:


 class SomeClass { public: void calledEachUpdate() { m_someVar1++; } private: int m_someVar1 = 0; }; 

يتم calledEachUpdate الأسلوب يسمى calledEachUpdate 60 مرة في الثانية. نغيره بإضافة حقل جديد:


 class SomeClass { public: void calledEachUpdate() { m_someVar1++; m_someVar2++; } private: int m_someVar1 = 0; int m_someVar2 = 0; }; 

في حالة وجود مثيل لهذه الفئة في الذاكرة الديناميكية أو في الحزمة ، بعد إعادة تحميل الرمز ، فمن المحتمل أن يتعطل التطبيق. يحتوي المثيل المخصص على المتغير m_someVar1 فقط ، ولكن بعد إعادة التشغيل ، calledEachUpdate الطريقة m_someVar2 تغيير m_someVar2 ، وتغيير ما لا ينتمي فعليًا إلى هذا المثيل ، مما يؤدي إلى عواقب غير متوقعة. في هذه الحالة ، يتم نقل منطق نقل الحالة إلى المبرمج ، الذي يجب عليه حفظ حالة الكائن بطريقة أو بأخرى وحذف الكائن نفسه قبل إعادة تحميل الرمز ، وإنشاء كائن جديد بعد إعادة التشغيل. توفر المكتبة الأحداث في شكل onCodePreLoad و onCodePostLoad تفويض الأساليب التي يمكن معالجة التطبيق.


لا أعرف كيف (وما إذا كان) من الممكن حل هذا الوضع بطريقة عامة ، كما أعتقد. الآن هذه الحالة "أكثر أو أقل طبيعية" ستعمل فقط للمتغيرات الثابتة ، وتستخدم المنطق التالي:


 void* oldVarPtr = ...; void* newVarPtr = ...; size_t oldVarSize = ...; size_t newVarSize = ...; memcpy(newVarPtr, oldVarPtr, std::min(oldVarSize, newVarSize)); 

هذا ليس صحيحًا جدًا ، لكنه الأفضل الذي توصلت إليه.


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


وضع كل ذلك معا


كيف يعمل كل شيء معا.


  • تتكرر المكتبة على رؤوس جميع المكتبات التي تم تحميلها ديناميكيًا في العملية ، وفي الواقع ، يقوم البرنامج نفسه بتوزيع وترشيح الأحرف.
  • بعد ذلك ، تحاول المكتبة العثور على ملف compile_commands.json في دليل التطبيق وفي الأدلة الأصل بشكل متكرر ، وتسحب جميع المعلومات الضرورية عن ET من هناك.
  • مع العلم أن المسار إلى ملفات الكائنات ، تقوم المكتبة بتحميل وتوزيع الملفات.
  • بعد ذلك ، يتم حساب الدليل الأكثر شيوعًا لجميع ملفات التعليمات البرمجية المصدر للبرنامج ، ويبدأ رصد هذا الدليل بشكل متكرر.
  • عندما يتغير ملف ما ، تتطلع المكتبة إلى معرفة ما إذا كانت موجودة في hashmap التبعيات ، وإذا كان هناك ، تبدأ العديد من عمليات التحويل البرمجي للملفات المتغيرة وتبعياتها في الخلفية ، باستخدام أوامر compile_commands.json من compile_commands.json .
  • عندما يطلب منك البرنامج إعادة تحميل الشفرة (في طلبي ، يتم تعيين التركيبة Ctrl+r على ذلك) ، تنتظر المكتبة إكمال عمليات الترجمة وترتبط جميع الكائنات الجديدة بالمكتبة الديناميكية.
  • ثم يتم تحميل هذه المكتبة في مساحة عنوان العملية dlopen وظيفة dlopen .
  • يتم تحميل المعلومات على الرموز من هذه المكتبة ، ويتم إعادة تقاطع كامل مجموعة الرموز من هذه المكتبة والرموز الموجودة بالفعل في العملية (إذا كانت وظيفة) أو نقلها (إذا كان متغيرًا ثابتًا).

يعمل هذا بشكل جيد للغاية ، خاصةً عندما تعرف ما يوجد أسفل الغطاء وما تتوقعه ، على الأقل في مستوى عالٍ.


شخصيا ، لقد فوجئت للغاية بسبب عدم وجود مثل هذا الحل لنظام التشغيل Linux ، هل أي شخص مهتم حقًا بهذا؟


سأكون سعيدًا بأي نقد ، شكراً!


رابط للتنفيذ

Source: https://habr.com/ru/post/ar435260/


All Articles