مرحبا بالجميع! تم إعداد ترجمة المقال خاصة لطلاب الدورة التدريبية "C ++ Developer" . هل هو مثير للاهتمام لتطوير في هذا الاتجاه؟ تعال على الانترنت يوم 13 ديسمبر في الساعة 20:00 بتوقيت موسكو. إلى الفئة الرئيسية "تدرب على استخدام Google Test Framework" !

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

ممتاز ، ثم لنبدأ.
الجزء 1 - vtables - أساسيات
لنلقِ نظرة على الكود التالي:
#include <iostream> using namespace std; class NonVirtualClass { public: void foo() {} }; class VirtualClass { public: virtual void foo() {} }; int main() { cout << "Size of NonVirtualClass: " << sizeof(NonVirtualClass) << endl; cout << "Size of VirtualClass: " << sizeof(VirtualClass) << endl; }
$ # main.cpp $ clang++ main.cpp && ./a.out Size of NonVirtualClass: 1 Size of VirtualClass: 8
يحتوي NonVirtualClass
على حجم بايت واحد ، لأنه في فئة C ++ لا يمكن أن يكون حجم الفصول صفرًا. ومع ذلك ، هذا ليس مهما الآن.
VirtualClass
8 بايت على جهاز 64 بت. لماذا؟ لأنه يوجد في الداخل مؤشر مخفي يشير إلى vtable. vtables هي جداول ترجمة ثابتة تم إنشاؤها لكل فصل افتراضي. يتحدث هذا المقال عن محتواها وكيفية استخدامها.
للحصول على فهم أعمق لما يبدو عليه vtables ، دعونا نلقي نظرة على الكود التالي مع gdb لمعرفة كيفية تخصيص الذاكرة:
#include <iostream> class Parent { public: virtual void Foo() {} virtual void FooNotOverridden() {} }; class Derived : public Parent { public: void Foo() override {} }; int main() { Parent p1, p2; Derived d1, d2; std::cout << "done" << std::endl; }
$ # , gdb $ clang++ -std=c++14 -stdlib=libc++ -g main.cpp && gdb ./a.out ... (gdb) # gdb - C++ (gdb) set print asm-demangle on (gdb) set print demangle on (gdb) # main (gdb) b main Breakpoint 1 at 0x4009ac: file main.cpp, line 15. (gdb) run Starting program: /home/shmike/cpp/a.out Breakpoint 1, main () at main.cpp:15 15 Parent p1, p2; (gdb) # (gdb) n 16 Derived d1, d2; (gdb) # (gdb) n 18 std::cout << "done" << std::endl; (gdb) # p1, p2, d1, d2 - , (gdb) p p1 $1 = {_vptr$Parent = 0x400bb8 <vtable for Parent+16>} (gdb) p p2 $2 = {_vptr$Parent = 0x400bb8 <vtable for Parent+16>} (gdb) p d1 $3 = {<Parent> = {_vptr$Parent = 0x400b50 <vtable for Derived+16>}, <No data fields>} (gdb) p d2 $4 = {<Parent> = {_vptr$Parent = 0x400b50 <vtable for Derived+16>}, <No data fields>}
إليكم ما تعلمناه مما سبق:
- على الرغم من أن الفئات لا تحتوي على أعضاء بيانات ، إلا أن هناك مؤشرًا مخفيًا لـ vtable ؛
- vtable لـ p1 و p2 هو نفسه. vtables هي بيانات ثابتة لكل نوع ؛
- يرث d1 و d2 مؤشر vtable من Parent ، مما يشير إلى vtable مشتق ؛
- تشير جميع vtables إلى إزاحة 16 بايت (0x10) في vtable. سنناقش هذا لاحقًا.
دعنا نواصل جلستنا gdb لرؤية محتويات vtables. سأستخدم الأمر x ، والذي يعرض الذاكرة على الشاشة. سنقوم بإخراج 300 بايت بالسداسي عشري ، بدءًا من 0x400b40. لماذا بالضبط هذا العنوان؟ لأننا رأينا أعلاه أن مؤشر vtable يشير إلى 0x400b50 ، ورمز هذا العنوان هو vtable for Derived+16 (16 == 0x10)
.
(gdb) x/300xb 0x400b40 0x400b40 <vtable for Derived>: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x400b48 <vtable for Derived+8>: 0x90 0x0b 0x40 0x00 0x00 0x00 0x00 0x00 0x400b50 <vtable for Derived+16>: 0x80 0x0a 0x40 0x00 0x00 0x00 0x00 0x00 0x400b58 <vtable for Derived+24>: 0x90 0x0a 0x40 0x00 0x00 0x00 0x00 0x00 0x400b60 <typeinfo name for Derived>: 0x37 0x44 0x65 0x72 0x69 0x76 0x65 0x64 0x400b68 <typeinfo name for Derived+8>: 0x00 0x36 0x50 0x61 0x72 0x65 0x6e 0x74 0x400b70 <typeinfo name for Parent+7>: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x400b78 <typeinfo for Parent>: 0x90 0x20 0x60 0x00 0x00 0x00 0x00 0x00 0x400b80 <typeinfo for Parent+8>: 0x69 0x0b 0x40 0x00 0x00 0x00 0x00 0x00 0x400b88: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x400b90 <typeinfo for Derived>: 0x10 0x22 0x60 0x00 0x00 0x00 0x00 0x00 0x400b98 <typeinfo for Derived+8>: 0x60 0x0b 0x40 0x00 0x00 0x00 0x00 0x00 0x400ba0 <typeinfo for Derived+16>: 0x78 0x0b 0x40 0x00 0x00 0x00 0x00 0x00 0x400ba8 <vtable for Parent>: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x400bb0 <vtable for Parent+8>: 0x78 0x0b 0x40 0x00 0x00 0x00 0x00 0x00 0x400bb8 <vtable for Parent+16>: 0xa0 0x0a 0x40 0x00 0x00 0x00 0x00 0x00 0x400bc0 <vtable for Parent+24>: 0x90 0x0a 0x40 0x00 0x00 0x00 0x00 0x00 ...
ملحوظة: نلقي نظرة على الشخصيات المزيفة. إذا كنت مهتمًا حقًا ، فإن _ZTV هي بادئة vtable ، و _ZTS هي بادئة سلسلة الكتابة (الاسم) ، و _ZTI لـ typeinfo.
هنا هو هيكل vtable Parent
:
هنا هو هيكل vtable Derived
:
1:
(gdb) # , 0x400aa0 (gdb) info symbol 0x400aa0 Parent::Foo() in section .text of a.out
2:
(gdb) info symbol 0x400a90 Parent::FooNotOverridden() in section .text of a.out
3:
(gdb) info symbol 0x400a80 Derived::Foo() in section .text of a.out
تذكر أن مؤشر vtable في Derived يشير إلى إزاحة +16 بايت في vtable؟ المؤشر الثالث هو عنوان مؤشر الطريقة الأولى. تريد طريقة ثالثة؟ لا مشكلة - إضافة 2 sizeof (باطلة ) إلى مؤشر vtable. نريد سجل typeinfo؟ انتقل إلى المؤشر أمامه.
الانتقال - ماذا عن هيكل سجل typeinfo؟
Parent
:
وهنا هو إدخال typeinfo Derived
:
1:
(gdb) info symbol 0x602090 vtable for __cxxabiv1::__class_type_info@@CXXABI_1.3 + 16 in section .bss of a.out
2:
(gdb) x/s 0x400b69 0x400b69 <typeinfo name for Parent>: "6Parent"
3:
(gdb) info symbol 0x602210 vtable for __cxxabiv1::__si_class_type_info@@CXXABI_1.3 + 16 in section .bss of a.out
4:
(gdb) x/s 0x400b60 0x400b60 <typeinfo name for Derived>: "7Derived"
إذا كنت تريد معرفة المزيد حول __si_class_type_info ، يمكنك العثور على بعض المعلومات هنا وكذلك هنا .
هذا يستنفد مهاراتي مع gdb ويكمل هذا الجزء أيضًا. أقترح أن يجد بعض الناس هذا منخفضًا جدًا ، أو ربما ليس ببساطة ذو قيمة عملية. إذا كان الأمر كذلك ، أوصي بتخطي الجزءين 2 و 3 ، والانتقال مباشرة إلى الجزء 4 .
الجزء 2 - الوراثة المتعددة
عالم التسلسلات الهرمية وراثة واحدة أسهل للمترجم. كما رأينا في الجزء الأول ، يقوم كل فصل تابع بتوسيع الجدول الأصلي من خلال إضافة إدخالات لكل طريقة افتراضية جديدة.
دعنا ننظر إلى الوراثة المتعددة ، مما يعقد الموقف ، حتى عندما يتم تطبيق الوراثة فقط من خلال واجهات.
دعونا نلقي نظرة على مقتطف الشفرة التالي:
class Mother { public: virtual void MotherMethod() {} int mother_data; }; class Father { public: virtual void FatherMethod() {} int father_data; }; class Child : public Mother, public Father { public: virtual void ChildMethod() {} int child_data; };
لاحظ أن هناك 2 مؤشرات vtable. حدسي ، أتوقع 1 أو 3 مؤشرات (الأم والأب والطفل). في الواقع ، من المستحيل وجود مؤشر واحد (المزيد حول هذا لاحقًا) ، والمترجم ذكي بما فيه الكفاية لدمج إدخالات الطفل vtable Child كملحق للأم vtable ، وبالتالي توفير مؤشر واحد.
لماذا لا يمكن للطفل الحصول على مؤشر vtable واحد لجميع الأنواع الثلاثة؟ تذكر أنه يمكن تمرير مؤشر تابع إلى دالة تقبل مؤشر الأم أو الأب ، وكلاهما يتوقع أن يحتوي هذا المؤشر على البيانات الصحيحة في الإزاحات الصحيحة. هذه الوظائف لا تحتاج إلى معرفتها عن الطفل ، ويجب عليك بالتأكيد ألا تفترض أن الطفل هو حقًا ما هو تحت مؤشر الأم / الأب الذي تعمل به.
(١) ليس له صلة بهذا الموضوع ، لكن ، مع ذلك ، من المثير للاهتمام أن يتم وضع بيانات الطفل في ملء الأب. وهذا ما يسمى الحشو الذيل وقد يكون موضوع وظيفة في المستقبل.
هنا هو هيكل vtable
:
في هذا المثال ، سيكون لدى المثيل التابع نفس المؤشر عند الإرسال إلى المؤشر الأم. ولكن عند التحويل إلى مؤشر الأب ، يقوم المحول البرمجي بحساب إزاحة هذا المؤشر للإشارة إلى الجزء الأب _vptr $ للطفل (الحقل الثالث في بنية الطفل ، انظر الجدول أعلاه).
بمعنى آخر ، بالنسبة للطفل المعطى c ؛: (باطل ) & c! = (باطل ) static_cast <الأب *> (& c). بعض الأشخاص لا يتوقعون ذلك ، وربما توفر لك هذه المعلومات يومًا ما بعض الوقت في تصحيح الأخطاء.
لقد وجدت هذا مفيدًا أكثر من مرة. لكن مهلا ، هذا ليس كل شيء.
ماذا لو قرر الطفل تجاوز إحدى طرق الأب؟ النظر في هذا الرمز:
class Mother { public: virtual void MotherFoo() {} }; class Father { public: virtual void FatherFoo() {} }; class Child : public Mother, public Father { public: void FatherFoo() override {} };
الوضع يزداد صعوبة. يمكن أن تأخذ الدالة الوسيطة الأب * وتدعو الأب () لذلك. ولكن إذا نجحت في استخدام مثيل تابع ، فمن المتوقع أن يتم استدعاء الأسلوب التابع الذي تم تجاوزه باستخدام هذا المؤشر الصحيح. ومع ذلك ، فإن المتصل لا يعرف أنه يحتوي بالفعل على الطفل. يحتوي على مؤشر إلى إزاحة الطفل ، حيث يوجد موقع الآب. شخص ما لديه لتعويض هذا المؤشر ، ولكن كيف نفعل ذلك؟ ما السحر الذي يقوم به المترجم لإنجاز هذا العمل؟
قبل أن نجيب على هذا ، لاحظ أن تجاوز إحدى الطرق الأم ليست صعبة للغاية ، لأن هذا المؤشر هو نفسه. يعرف الطفل ما يجب قراءته بعد الأم vtable ، ويتوقع أن تكون أساليب الطفل بعد ذلك مباشرة.
إليك الحل: يقوم المترجم بإنشاء طريقة thunk بتصحيح هذا المؤشر ثم استدعاء الأسلوب "الحقيقي". سيكون عنوان طريقة المحول تحت الأب vtable ، في حين أن الطريقة "الحقيقية" ستكون تحت الطفل vtable.
هنا هو vtable Child
:
0x4008e8 <vtable for Child>: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x4008f0 <vtable for Child+8>: 0x60 0x09 0x40 0x00 0x00 0x00 0x00 0x00 0x4008f8 <vtable for Child+16>: 0x00 0x08 0x40 0x00 0x00 0x00 0x00 0x00 0x400900 <vtable for Child+24>: 0x10 0x08 0x40 0x00 0x00 0x00 0x00 0x00 0x400908 <vtable for Child+32>: 0xf8 0xff 0xff 0xff 0xff 0xff 0xff 0xff 0x400910 <vtable for Child+40>: 0x60 0x09 0x40 0x00 0x00 0x00 0x00 0x00 0x400918 <vtable for Child+48>: 0x20 0x08 0x40 0x00 0x00 0x00 0x00 0x00
ماذا يعني:
Explanation: كما رأينا سابقًا ، لدى الطفل 2 vtables - واحد يستخدم للأم والطفل ، والآخر للأب. في الأب vtable ، يشير FatherFoo () إلى "مهايئ" ، و في vtable Child يشير مباشرة إلى Child :: FatherFoo ().
وما هو في هذا "المحول" ، تسأل؟
(gdb) disas /m 0x400820, 0x400850 Dump of assembler code from 0x400820 to 0x400850: 15 void FatherFoo() override {} 0x0000000000400820 <non-virtual thunk to Child::FatherFoo()+0>: push %rbp 0x0000000000400821 <non-virtual thunk to Child::FatherFoo()+1>: mov %rsp,%rbp 0x0000000000400824 <non-virtual thunk to Child::FatherFoo()+4>: sub $0x10,%rsp 0x0000000000400828 <non-virtual thunk to Child::FatherFoo()+8>: mov %rdi,-0x8(%rbp) 0x000000000040082c <non-virtual thunk to Child::FatherFoo()+12>: mov -0x8(%rbp),%rdi 0x0000000000400830 <non-virtual thunk to Child::FatherFoo()+16>: add $0xfffffffffffffff8,%rdi 0x0000000000400837 <non-virtual thunk to Child::FatherFoo()+23>: callq 0x400810 <Child::FatherFoo()> 0x000000000040083c <non-virtual thunk to Child::FatherFoo()+28>: add $0x10,%rsp 0x0000000000400840 <non-virtual thunk to Child::FatherFoo()+32>: pop %rbp 0x0000000000400841 <non-virtual thunk to Child::FatherFoo()+33>: retq 0x0000000000400842: nopw %cs:0x0(%rax,%rax,1) 0x000000000040084c: nopl 0x0(%rax)
كما ناقشنا بالفعل ، هذا هو إزاحة ويسمى FatherFoo (). وكم يجب أن نحول هذا إلى طفل؟ top_offset!
يرجى ملاحظة أنني شخصياً أجد أن اسم thunk غير الظاهري مربك للغاية لأنه إدخال جدول افتراضي لوظيفة افتراضية. لست متأكدًا من أنها ليست افتراضية ، ولكن هذا رأيي فقط.
هذا كل شيء في الوقت الحالي ، في المستقبل القريب ، سنترجم 3 و 4 أجزاء. اتبع الأخبار!