حول [[trivial_abi]] في كلانج

أخيرًا ، كتبت منشورًا عن [[trivial_abi]]!

هذه ميزة خاصة جديدة في Clang trunk ، جديدة اعتبارًا من فبراير 2018. هذا ملحق بائع للغة C ++ ، وهو ليس C ++ قياسي ، وهو غير مدعوم من صندوق الخليج ، ولا توجد اقتراحات نشطة من WG21 لإدراجها في معيار C ++ ، على حد علمي.



لم أشارك في تنفيذ هذه الميزة. لقد ألقيت نظرة على التصحيحات الموجودة في القائمة البريدية لـ cfe-commits وأشادت بصمت بنفسي. لكن هذه ميزة رائعة أعتقد أنه يجب على الجميع معرفتها.

لذلك ، أول ما سنبدأ به: هذه ليست سمة قياسية ، ولا يدعم جذع Clang الإملاء القياسي للسمة [[trivial_abi]] من أجلها. بدلاً من ذلك ، يجب عليك كتابتها بالأسلوب القديم ، كما هو موضح أدناه:

__attribute__((trivial_abi)) __attribute__((__trivial_abi__)) [[clang::trivial_abi]] 

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

 #define TRIVIAL_ABI __attribute__((trivial_abi)) class TRIVIAL_ABI Widget { // ... }; 


ما المشكلة هل هذا حل؟



أتذكر مشاركتي بتاريخ 04/17/2018 حيث عرضت نسختين من الفصل؟

تقريبا. perev: نظرًا لأن منشور 4/17/2018 يحتوي على حجم صغير ، لم أقم بنشره بشكل منفصل ، لكنني أدرجته هنا أسفل المفسد.
وظيفة من 04/17/2018

عيوب المفقودين تافهة نداء المدمرة


راجع قائمة المراسلات المقترحة لـ C ++. أي من الوظيفتين ، foo أو bar ، ستحصل على أفضل رمز تم إنشاؤه بواسطة المترجم؟

 struct Integer { int value; ~Integer() {} // deliberately non-trivial }; void foo(std::vector<int>& v) { v.back() *= 0xDEADBEEF; v.pop_back(); } void bar(std::vector<Integer>& v) { v.back().value *= 0xDEADBEEF; v.pop_back(); } 


ترجمة مع GCC و libstdc ++. تخمين صحيح؟

 foo: movq 8(%rdi), %rax imull $-559038737, -4(%rax), %edx subq $4, %rax movl %edx, (%rax) movq %rax, 8(%rdi) ret bar: subq $4, 8(%rdi) ret 


إليك ما يحدث هنا: تتمتع دول مجلس التعاون الخليجي بذكاء كافٍ لفهم أنه عند بدء تشغيل أداة تدمير لمنطقة الذاكرة ، ينتهي عمرها ، وتكون جميع الإدخالات السابقة إلى منطقة الذاكرة هذه "ميتة". لكن دول مجلس التعاون الخليجي أيضًا ذكية بما يكفي لفهم أن المدمر التافه (مثل المدمر الزائف ~ int ()) لا يفعل شيئًا ولا ينتج أي آثار.

لذلك ، تستدعي الدالة الشريط pop_back ، الذي يعمل ~ Integer () ، مما يجعل vec.back () ميتًا ، ويزيل GCC الضرب تمامًا بواسطة 0xDEADBEEF.

من ناحية أخرى ، فإن foo تستدعي pop_back ، التي تطلق ~ int () المدمرة الزائفة (يمكنها تخطي المكالمة بالكامل ، لكن لا تفعل ذلك) ، ترى دول مجلس التعاون الخليجي أنها فارغة وتنسى. لذلك ، لا يرى GCC أن vec.back () ميت ولا يزيل الضرب بواسطة 0xDEADBEEF.

يحدث هذا لمدمرة تافهة ، ولكن ليس لمدمرة زائفة مثل ~ int (). استبدال ~ Integer () {} بـ ~ Integer () = افتراضي ؛ وانظر كيف ظهر التعليم الخاطئ مرة أخرى!

 struct Foo { int value; ~Foo() = default; // trivial }; struct Bar { int value; ~Bar() {} // deliberately non-trivial }; 

في هذا المنشور ، يتم إعطاء الكود الذي قام فيه المترجم بإنشاء كود لـ Foo أسوأ من Bar. يجدر مناقشة سبب هذا الأمر غير متوقع. يتوقع المبرمجون بشكل حدسي أن تكون الشفرة "التافهة" أفضل من الشفرة "غير التافهة". هذا هو الحال في معظم الحالات. على وجه الخصوص ، هذا هو الحال عند إجراء مكالمة دالة أو العودة:

 template<class T> T incr(T obj) { obj.value += 1; return obj; } 

incr compiles إلى التعليمة البرمجية التالية:

 leal 1(%rdi), %eax retq 

(leal هو الأمر x86 الذي يعني "add.") نرى أن obj المكون من 4 بايت يتم تمريره إلى incr في سجل٪ edi ، ونضيف 1 إلى قيمته ونعيده إلى٪ eax. أربع بايت في الإدخال ، أربعة بايت في الإخراج ، سهلة وبسيطة.

الآن دعنا ننظر إلى incr (الحالة مع مدمرة غير تافهة).

 movl (%rsi), %eax addl $1, %eax movl %eax, (%rsi) movl %eax, (%rdi) movq %rdi, %rax retq 

هنا ، لا يتم تمرير obj في السجل ، على الرغم من حقيقة أنه هنا نفس 4 بايت مع نفس دلالات. هنا يتم تمرير obj وإعادته إلى العنوان. هنا المتصل يحتفظ ببعض المساحة لقيمة الإرجاع ويمرر لنا مؤشرًا إلى هذه المساحة بالـ rdi ، ويعطينا المتصل مؤشرًا لقيمة الإرجاع obj في السجل التالي للوسائط٪ rsi. نقوم باستخراج القيمة من (٪ rsi) ، نضيف 1 ، ونحفظها مرة أخرى إلى (٪ rsi) لتحديث قيمة obj نفسها ، ثم (تافهة) نسخ 4 بايت من obj إلى الفتحة للحصول على قيمة الإرجاع المشار إليها بواسطة٪ rdi. أخيرًا ، نقوم بنسخ المؤشر الأصلي الذي تم تمريره بواسطة المتصل من٪ rdi إلى٪ rax ، نظرًا لأن مستند ABI x86-64 (الصفحة 22) يخبرنا بذلك.

السبب في أن Bar مختلف تمامًا عن Foo لأن Bar لديه مدمر غير بديهي ، وينص x 86-64 ABI (الصفحة 19) على وجه التحديد:

إذا كان كائن C ++ له مُنشئ نسخة غير بديهي أو مُدمّر غير تافه ، يتم تمريره عبر ارتباط غير مرئي (يتم استبدال الكائن بمؤشر [...] في قائمة المعلمات)

تعرّف وثيقة Itanium C ++ ABI الأحدث ما يلي:
إذا كان نوع المعلمة غير بديهي لغرض المكالمة ، يجب على المتصل تخصيص مكان مؤقت وتمرير رابط إلى هذا المكان المؤقت:
[...]
يعتبر النوع غير ضروري لغرض الاتصال إذا:

أنه يحتوي على مُنشئ نسخ غير بديهي ، أو مُنشئ متحرك ، أو مُدمِّر ، أو يتم حذف كل مُنشئات النقل والنسخ الخاصة به.

وهذا ما يفسر كل شيء: شريط لديه توليد رمز الفقيرة لأنه يتم تمريره من خلال رابط غير مرئي. يتم إرساله عبر ارتباط غير مرئي منذ حدوث مجموعة غير محظوظة من حالتين مستقلتين:
  • تقول وثيقة ABI إن الكائنات ذات التدمير غير التافه يتم تمريرها عبر روابط غير مرئية
  • شريط لديه المدمرة غير تافهة.

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

دع شخصًا يعطينا مخططًا لفظيًا
  • كل الناس مميت
  • سقراط رجل.
  • وبالتالي ، فإن سقراط مميت.


إذا كنا نريد دحض الاستنتاج "سقراط مميت" ، فيجب علينا دحض أحد المباني: إما لدحض الشيء الرئيسي (ربما بعض الناس ليسوا بشر) ، أو لدحض القطاع الخاص (ربما سقراط ليس شخصًا).

لكي يتم تمرير Bar في سجل (مثل Foo) ، يجب أن نفند أحد المقرتين. مسار C ++ القياسي هو إعطاء Bar مدمرة تافهة ، وتدمير الفرضية الخاصة. ولكن هناك طريقة أخرى!

كيف [[trivial_abi]] يحل المشكلة


سمة كلانج الجديدة تدمر الفرضية الرئيسية. يمتد Clang مستند ABI كما يلي:
إذا كان نوع المعلمة غير بديهي لغرض المكالمة ، يجب على المتصل تخصيص مكان مؤقت وتمرير رابط إلى هذا المكان المؤقت:
[...]
يُعتبر النوع غير ضروري لغرض المكالمة إذا تم وضع علامة عليه [[trivial_abi]] و:
أنه يحتوي على مُنشئ نسخ غير بديهي ، أو مُنشئ متحرك ، أو مُدمِّر ، أو يتم حذف كل مُنشئات النقل والنسخ الخاصة به.

حتى لو كانت الفئة ذات المنشئ المتحرك غير المدروس أو المدمر يمكن اعتبارها تافهة لغرض المكالمة ، إذا تم تعليمها على أنها [[trivial_abi]].

حتى الآن ، باستخدام Clang ، يمكننا كتابة مثل هذا:

 #define TRIVIAL_ABI __attribute__((trivial_abi)) struct TRIVIAL_ABI Baz { int value; ~Baz() {} // deliberately non-trivial }; 

ترجمة incr <Baz> ، واحصل على نفس الرمز كـ incr <Foo>!

تحذير رقم 1: [[trivial_abi]] في بعض الأحيان لا يفعل شيئًا


آمل أن نتمكن من تصنيع أغلفة "تافهة لأغراض الاتصال" على أنواع المكتبات القياسية ، مثل هذا:

 template<class T, class D> struct TRIVIAL_ABI trivial_unique_ptr : std::unique_ptr<T, D> { using std::unique_ptr<T, D>::unique_ptr; }; 

للأسف ، هذا لا يعمل. إذا كان لدى صفك أي فئة أساسية أو حقول غير ثابتة "غير تافهة لغرض الاستدعاء" ، فإن امتداد Clang بالصيغة التي تمت كتابتها بها الآن يجعل صفك "غير بديهي" ، ولن يكون للسمة أي تأثير. (لا يتم إصدار رسائل تشخيصية. هذا يعني أنه يمكنك استخدام [[trivial_abi]] في قالب الفصل كصفة اختيارية ، وسيكون الفصل "تافهًا مشروطًا" ، وهو أمر مفيد في بعض الأحيان. العيب ، بالطبع ، هو أنه يمكنك قم بتمييز الفئة على أنها تافهة ، ثم ابحث عن المحول البرمجي إصلاحها بهدوء.)

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

حسب علمي ، الطريقة الوحيدة لاستخدام TRIVIAL_ABI لـ "أنواع الأدوات المساعدة القياسية" مثل <T> و unique_ptr <T> و shared_ptr <T> الاختيارية هي
  • قم بتنفيذها بنفسك من نقطة الصفر وتطبيق السمة ، أو
  • اقتحم نسختك المحلية من libc ++ وأدخل السمة هناك بيديك

(في عالم المصادر المفتوحة ، كلتا الطريقتين متشابهتان بشكل أساسي)

تحذير رقم 2: مسؤولية المدمر


في المثال مع Foo / Bar ، يحتوي الفصل على أداة إتلاف فارغة. اسمح لنا أن يكون لفصلنا في الواقع مدمر لا نظير له.

 struct Up1 { int value; Up1(Up1&& u) : value(u.value) { u.value = 0; } ~Up1() { puts("destroyed"); } }; 

يجب أن يكون هذا مألوفًا لك ، فهذا فريد من نوعه <int> ، مبسط إلى الحد الأقصى ، مع طباعة الرسالة عند حذفها.

بدون TRIVIAL_ABI ، يبدو incr <Up1> وكأنه incr <Bar>:

 movl (%rsi), %eax addl $1, %eax movl %eax, (%rdi) movl $0, (%rsi) movq %rdi, %rax retq 


مع TRIVIAL_ABI ، يبدو incr أكبر وأكثر رعبا !

 pushq %rbx leal 1(%rdi), %ebx movl $.L.str, %edi callq puts movl %ebx, %eax popq %rbx retq 


في اتفاقية الاتصال التقليدية ، يتم دائمًا تمرير الأنواع ذات التدمير غير التافه بواسطة رابط غير مرئي ، مما يعني أن جانب التلقي (incr في هذه الحالة) يقبل دائمًا مؤشر إلى كائن معلمة دون امتلاك هذا الكائن. الكائن مملوك للمتصل ، مما يجعل العمل يعمل!

عندما يتم تمرير نوع مع [[trivial_abi]] في السجلات ، فإننا نقوم أساسًا بعمل نسخة من كائن المعلمة.

نظرًا لأن الإصدار x86-64 يحتوي على سجل واحد فقط لإرجاعه (تصفيق) ، فإن الوظيفة التي تم استدعاؤها ليس لها طريقة لإرجاع الكائن في النهاية. يجب أن تأخذ الوظيفة المطلوبة ملكية الكائن الذي انتقلنا إليه! هذا يعني أن الدالة التي تم استدعاؤها يجب استدعاء destructor للكائن المعلمة عند الانتهاء.

في المثال السابق ، Foo / Bar / Baz ، يسمى المدمر ، لكنه كان فارغًا ، ولم نلاحظه. الآن في incr <Up2> ، نرى رمزًا إضافيًا تم إنشاؤه بواسطة destructor على جانب الوظيفة المدعوة.

يمكن افتراض أنه قد يتم إنشاء هذا الرمز الإضافي في بعض حالات المستخدم. ولكن ، على العكس من ذلك ، فإن دعوة المدمر لا تظهر في أي مكان! يتم استدعاء in inc لأنه لا يتم استدعاء في وظيفة الاستدعاء. وبشكل عام ، سيتم موازنة السعر والفوائد.

تحذير رقم 3: أمر المدمر


سيتم استدعاء destructor لمعلمة مع ABI تافهة من خلال وظيفة تسمى ، وليس واحد استدعاء (تحذير رقم 2). يشير ريتشارد سميث إلى أن هذا يعني أنه يعني أنه لن يتم استدعاؤه بالترتيب الذي يوجد به مدمرات المعلمات الأخرى.

 struct TRIVIAL_ABI alpha { alpha() { puts("alpha constructed"); } ~alpha() { puts("alpha destroyed"); } }; struct beta { beta() { puts("beta constructed"); } ~beta() { puts("beta destroyed"); } }; void foo(alpha, beta) {} int main() { foo(alpha{}, beta{}); } 

يطبع هذا الرمز:

 alpha constructed beta constructed alpha destroyed beta destroyed 

عندما يتم تعريف TRIVIAL_ABI على أنه [[clang :: trivial_abi]] ، فإنه يطبع:

 alpha constructed beta constructed beta destroyed alpha destroyed 

العلاقة مع كائن "relocatable" / "move-relocates"


لا علاقة ... ، هاه؟

كما ترون ، لا توجد متطلبات لأن يكون للفئة [[trivial_abi]] أي دلالات محددة لمنشئ متحرك أو مُدمِّر أو مُنشئ افتراضي. من المحتمل أن يتم نقل أي فئة معينة بشكل تافه ، وذلك ببساطة لأن معظم الفئات قابلة للانتقال بشكل تافه.

يمكننا ببساطة إنشاء فئة offset_ptr بحيث لا يمكن نقلها بسهولة:

 template<class T> class TRIVIAL_ABI offset_ptr { intptr_t value_; public: offset_ptr(T *p) : value_((const char*)p - (const char*)this) {} offset_ptr(const offset_ptr& rhs) : value_((const char*)rhs.get() - (const char*)this) {} T *get() const { return (T *)((const char *)this + value_); } offset_ptr& operator=(const offset_ptr& rhs) { value_ = ((const char*)rhs.get() - (const char*)this); return *this; } offset_ptr& operator+=(int diff) { value_ += (diff * sizeof (T)); return *this; } }; int main() { offset_ptr<int> top = &a[4]; top = incr(top); assert(top.get() == &a[5]); } 

هنا هو رمز كاملة.
عندما يتم تعريف TRIVIAL_ABI ، يجتاز جذع Clang هذا الاختبار في -O0 و -O1 ، ولكن عند -O2 (أي بمجرد محاولة إجراء مكالمات إلى trivial_offset_ptr :: operator + = ومنشئ النسخ) ، فإنه يتعطل عند التأكيد.

لذلك تحذير واحد آخر. إذا كان نوعك يفعل شيئًا مجنونًا مع هذا المؤشر ، فربما لن ترغب في تمريره في السجلات.

علة 37319 ، في الواقع ، طلب وثائق. في هذه الحالة ، اتضح أنه لا توجد طريقة لجعل الشفرة تعمل بالطريقة التي يريدها المبرمج. نقول أن قيمة value_ يجب أن تعتمد على قيمة هذا المؤشر ، ولكن على الحد الفاصل بين الاستدعاء والوظائف التي تم استدعاؤها ، يكون الكائن في السجلات ولا يوجد مؤشر له! لذلك ، تقوم دالة الاستدعاء بالكتابة إلى الذاكرة ، وتمرير هذا المؤشر مرة أخرى ، وكيف ينبغي أن تحسب الدالة المُطلَّبة القيمة الصحيحة من أجل كتابتها على value_؟ ربما من الأفضل أن نسأل كيف يعمل حتى في OO؟ يجب أن لا يعمل هذا الرمز على الإطلاق.

لذا ، إذا كنت تريد استخدام [[trivial_abi]] ، فيجب عليك تجنب وظائف الأعضاء (ليست خاصة فحسب ، بل وأيضاً بشكل عام) التي تعتمد اعتمادًا كبيرًا على عنوان الكائن (مع بعض المعاني غير المحددة للكلمة "أساسي").

بشكل حدسي ، عندما يتم تعليم فئة على أنها [[trivial_abi]] ، كلما كنت تتوقع النسخ ، يمكنك الحصول على نسخة بالإضافة إلى memcpy. وبالمثل ، عندما تتوقع تحركًا ، يمكنك بالفعل الحصول على الخطوة بالإضافة إلى memcpy.

عندما يكون النوع "قابلاً للنقل بشكل تافه" (كما هو محدد في C ++ الآن ) ، ثم في أي وقت تتوقع فيه النسخ والتدمير ، يمكنك بالفعل الحصول على memcpy. وبالمثل ، عندما تتوقع النزوح والدمار ، يمكنك في الواقع الحصول على memcpy. في الواقع ، ستفقد المكالمات إلى الوظائف الخاصة إذا تحدثنا عن "نقل تافه" ، ولكن عندما يكون للصفه [[trivial_abi]] سمة من كلانج ، لا تضيع المكالمات. يمكنك فقط الحصول على (كما كان) memcpy بالإضافة إلى المكالمات التي تتوقعها. هذا (نوع من) memcpy هو السعر الذي تدفعه مقابل اتفاقية تسجيل مكالمات أسرع.

روابط لمزيد من القراءة:


مؤشر ترابط cfe-dev من أكيرا هاتاناكا من نوفمبر 2017
الوثائق الرسمية كلانج
وحدة الاختبارات ل trivial_abi
الخطأ 37319: ربما لا يعمل trivial_offset_ptr

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


All Articles