تسمح لك معظم برامج التحويل البرمجي C بالوصول إلى صفيف
extern
مع حدود غير محددة ، على سبيل المثال:
extern int external_array[]; int array_get (long int index) { return external_array[index]; }
قد يكون تعريف external_array في وحدة ترجمة أخرى وقد يبدو كما يلي:
int external_array[3] = { 1, 2, 3 };
السؤال هو ماذا يحدث إذا تغير هذا التعريف المنفصل مثل هذا:
int external_array[4] = { 1, 2, 3, 4 };
أو هكذا:
int external_array[2] = { 1, 2 };
هل سيتم الحفاظ على الواجهة الثنائية (بشرط أن تكون هناك آلية تسمح للتطبيق بتحديد حجم المصفوفة في وقت التشغيل)؟
الغريب ، في العديد من البنيات ،
زيادة حجم المصفوفة ينتهك توافق الواجهة الثنائية (ABI). يمكن أن يؤدي تقليل حجم الصفيف أيضًا إلى حدوث مشكلات توافق. في هذه المقالة ، سوف نلقي نظرة فاحصة على توافق ABI وشرح كيفية تجنب المشاكل.
الروابط في قسم البيانات من الملف القابل للتنفيذ
لفهم كيف يصبح حجم المصفوفة جزءًا من الواجهة الثنائية ، نحتاج أولاً إلى فحص الروابط في قسم البيانات بالملف القابل للتنفيذ. بالطبع ، تعتمد التفاصيل على البنية المحددة ، وهنا سنركز على بنية x86-64.
تدعم بنية x86-64 العنونة بالنسبة إلى عداد البرنامج ، أي الوصول إلى متغير الصفيف العمومي ، كما في دالة
array_get
الموضحة مسبقًا ، يمكن تجميعها في تعليمة
movl
واحدة:
array_get: movl external_array(,%rdi,4), %eax ret
من هذا ، يقوم المجمّع بإنشاء ملف كائن يتم تعليم التعليمة فيه على أنه
R_X86_64_32S
.
0000000000000000 : 0: mov 0x0(,%rdi,4),%eax 3: R_X86_64_32S external_array 7: retq
توضح هذه الخطوة رابط (
ld
) كيفية نشر الموقع المقابل لمتغير
external_array
أثناء الارتباط عند إنشاء الملف القابل للتنفيذ.
هذا له نتيجتان مهمتان.
- منذ تحديد إزاحة المتغير في وقت الإنشاء ، في وقت التشغيل لا يوجد أي حمولة لتحديده. السعر الوحيد هو الوصول إلى الذاكرة نفسها.
- لتحديد الإزاحة ، تحتاج إلى معرفة أحجام جميع البيانات المتغيرة. خلاف ذلك ، سيكون من المستحيل حساب تنسيق قسم البيانات أثناء التخطيط.
بالنسبة إلى تطبيقات C الموجهة إلى
Executable و Format Format (ELF) ، كما في GNU / Linux ، لا تحتوي الإشارات إلى المتغيرات الخارجية على أحجام كائن. في المثال
array_get
حجم الكائن غير معروف حتى للمترجم. في الواقع ، يبدو ملف المجمّع بالكامل كما يلي (مع حذف معلومات الترويج فقط من
-fno-asynchronous-unwind-tables
، والمطلوبة تقنيًا لتوافق psABI):
.file "get.c" .text .p2align 4,,15 .globl array_get .type array_get, @function array_get: movl external_array(,%rdi,4), %eax ret .size array_get, .-array_get .ident "GCC: (GNU) 8.3.1 20190223 (Red Hat 8.3.1-2)" .section .note.GNU-stack,"",@progbits
لا توجد معلومات حول حجم
external_array
في ملف المجمّع هذا: مرجع الحرف الوحيد موجود على السطر مع تعليمة
movl
، والبيانات الرقمية الوحيدة في التعليمات هي حجم عنصر الصفيف (ضمنيًا
movl
مضروبًا في 4).
إذا تطلب ELF أحجام للمتغيرات غير المحددة ، فسيكون من المستحيل تجميع وظيفة
array_get
.
كيف يحصل الموصل على حجم الحرف الفعلي؟ ينظر إلى تعريف الرمز ويستخدم معلومات الحجم التي يجدها هناك. يتيح ذلك للمترجم حساب تخطيط قسم البيانات وملء حركات البيانات مع الإزاحات المناسبة.
كائنات ELF الشائعة
لا تتطلب تطبيقات C لـ ELF من المبرمج إضافة علامة شفرة المصدر للإشارة إلى ما إذا كانت دالة أو متغير موجود في الكائن الحالي (والذي قد يكون المكتبة أو القابل للتنفيذ الرئيسي) أو في كائن آخر. سوف رابط و المحمل الديناميكي رعاية هذا.
في الوقت نفسه ، كانت هناك رغبة في الملفات القابلة للتنفيذ بعدم خفض الأداء عن طريق تغيير نموذج التحويل البرمجي. هذا يعني أنه عند ترجمة التعليمات البرمجية المصدر للبرنامج الرئيسي (أي ، بدون
-fPIC
، وفي هذه الحالة بالذات بدون -
-fPIE
) ، يتم
array_get
وظيفة
array_get
في
نفس تسلسل الأوامر تمامًا قبل تقديم كائنات مشتركة ديناميكية. بالإضافة إلى ذلك ، لا يهم إذا تم تعريف متغير
external_array
في الملف القابل للتنفيذ الأساسي أو ما إذا كان يتم تحميل أي كائن مشترك بشكل منفصل في وقت التشغيل. التعليمات التي أنشأها المترجم هي نفسها في كلتا الحالتين.
كيف هذا ممكن؟ بعد كل شيء ، الكائنات ELF المشتركة هي مستقلة عن الموقف. يتم تحميلها في
عناوين عشوائية لا يمكن التنبؤ بها في وقت التشغيل. ومع ذلك ، يقوم المترجم بإنشاء تسلسل لرمز الجهاز يتطلب تحديد موقع هذه المتغيرات عند
إزاحة ثابتة محسوبة أثناء الارتباط ، قبل بدء البرنامج بوقت طويل.
الحقيقة هي أن كائن واحد فقط تحميل (الملف القابل للتنفيذ الرئيسي) يستخدم هذه الإزاحات الثابتة. كل الكائنات الأخرى (المحمل الديناميكي نفسه ، مكتبة وقت تشغيل C وأي مكتبة أخرى يستخدمها البرنامج) يتم تجميعها وتجميعها ككائنات مستقلة تمامًا عن الموضع (PICs). لمثل هذه الكائنات ، يقوم المترجم بتحميل العنوان الفعلي لكل متغير من جدول الإزاحة العامة (GOT). يمكننا أن نرى هذا الدوار إذا قمنا بتجميع مثال
-fPIC
مع
-fPIC
، مما يؤدي إلى رمز التجميع هذا:
array_get: movq external_array@GOTPCREL(%rip), %rax movl (%rax,%rdi,4), %eax ret
ونتيجة لذلك ، لم يعد عنوان متغير
external_array
مضغوطًا ويمكن تغييره في وقت التشغيل عن طريق تهيئة سجل GOT بشكل مناسب. هذا يعني أنه في وقت التشغيل ، يمكن أن يكون تعريف
external_array
في نفس الكائن المشترك أو كائن مشترك آخر أو البرنامج الرئيسي. سوف يجد المحمل الديناميكي التعريف المناسب استنادًا إلى قواعد البحث عن أحرف ELF ويربط مرجع الرمز غير المحدد بتعريفه عن طريق تحديث سجل GOT إلى عنوانه الفعلي.
نعود إلى المثال الأصلي ، حيث
array_get
وظيفة
array_get
في البرنامج الرئيسي ، لذلك يتم تحديد عنوان المتغير مباشرةً. الفكرة الأساسية المطبقة في الرابط هي أن البرنامج الرئيسي سيوفر تعريفًا للمتغير
external_array
،
حتى إذا تم تعريفه فعليًا في كائن شائع في وقت التشغيل . بدلاً من تحديد التعريف الأولي للمتغير في الكائن المشترك ، سيقوم المُحمل الديناميكي بتحديد
نسخة من المتغير في قسم البيانات في الملف القابل للتنفيذ.
هذا له نتيجتان مهمتان. بادئ ذي بدء ، تذكر أنه يتم تعريف_الصفيف
external_array
كما يلي:
int external_array[3] = { 1, 2, 3 };
يوجد مُهيئ هنا يجب تطبيقه على التعريف في الملف القابل للتنفيذ الرئيسي. للقيام بذلك ، في الملف القابل للتنفيذ الرئيسي ، يتم وضع رابط لموقع
نسخ نسخة الرمز. يُظهر
readelf -rW
أنه يتحرك
R_X86_64_COPY
.
يحتوي قسم الترحيل '.rela.dyn' عند الإزاحة 0x408 على 3 إدخالات:
إزاحة معلومات النوع قيمة الرمز اسم الرمز + إضافة
0000000000403ff0 0000000100000006 R_X86_64_GLOB_DAT 0000000000000000 __libc_start_main@GLIBC_2.2.5 + 0
0000000000403ff8 0000000200000006 R_X86_64_GLOB_DAT 0000000000000000 __gmon_start__ + 0
0000000000404020 0000000300000005 R_X86_64_COPY 0000000000404020 external_array + 0
مثل الحركات الأخرى ، تتم معالجة حركة النسخ بواسطة أداة التحميل الديناميكية. يتضمن عملية نسخ بسيطة في اتجاه البت. يتم تحديد الهدف من النسخة بواسطة إزاحة الإزاحة (
0000000000404020
في المثال). يتم تحديد المصدر في وقت التشغيل استنادًا إلى اسم الرمز (
external_array
) وقيمته. عند إنشاء نسخة ، سينظر المحمل الديناميكي أيضًا في حجم الحرف للحصول على عدد البايتات التي يجب نسخها. لجعل كل هذا ممكنًا ، يتم تصدير رمز_الصفيف
external_array
تلقائيًا من الملف القابل للتنفيذ كرمز محدد بحيث يكون مرئيًا للمحمل الديناميكي في وقت التشغيل. يعكس جدول الرموز الديناميكي (
.dynsym
) هذا ، كما هو موضح بواسطة أمر
readelf -sW
:
يحتوي جدول الرموز '.dynsym' على 4 إدخالات:
Num: قيمة حجم النوع Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __libc_start_main@GLIBC_2.2.5 (2)
2: 0000000000000000 0 NOTYPE WEAK DEFAULT UND __gmon_start__
3: 0000000000404020 12 OBJECT GLOBAL DEFAULT 22 external_array
من أين تأتي المعلومات حول حجم الكائن (12 بايت ، في هذا المثال)؟ يفتح الرابط جميع الكائنات الشائعة ، ويبحث عن تعريفه ويأخذ معلومات حول الحجم. كما كان من قبل ، يتيح هذا للرابط حساب تخطيط قسم البيانات بحيث يمكن استخدام الإزاحات الثابتة. مرة أخرى ، يتم تحديد حجم التعريف في الملف التنفيذي الرئيسي ولا يمكن تغييره في وقت التشغيل.
يقوم الرابط الديناميكي أيضًا بإعادة توجيه الارتباطات الرمزية في الكائنات المشتركة إلى النسخة المنقولة في الملف القابل للتنفيذ الرئيسي. هذا يضمن أنه في البرنامج بأكمله لا يوجد سوى نسخة واحدة من المتغير ، كما تتطلب دلالات اللغة C. وإذا لم يتغير المتغير بعد التهيئة ، فلن تكون التحديثات من الملف القابل للتنفيذ الرئيسي مرئية للكائنات الديناميكية المشتركة والعكس.
التأثير على التوافق الثنائي
ماذا يحدث إذا قمنا بتغيير تعريف
external_array
في كائن مشترك دون ربط (أو إعادة ترجمة) البرنامج الرئيسي؟ أولاً ، فكر في
إضافة عنصر صفيف.
int external_array[4] = { 1, 2, 3, 4 };
سيؤدي هذا إلى إنشاء تحذير من أداة التحميل الديناميكية في وقت التشغيل:
main-program: Symbol `external_array' has different size in shared object, consider re-linking
البرنامج الرئيسي لا يزال يحتوي على تعريف
external_array
مع مساحة لـ 12 بايت فقط. هذا يعني أن النسخة غير مكتملة: يتم نسخ العناصر الثلاثة الأولى فقط من الصفيف. نتيجة لذلك ، لم
extern_array[3]
تعريف الوصول إلى عنصر الصفيف
extern_array[3]
. لا يؤثر هذا النهج على البرنامج الرئيسي فحسب ، بل يؤثر أيضًا على الشفرة بأكملها في العملية ، لأن كل الإشارات إلى
extern_array
أعيد توجيهها إلى التعريف في البرنامج الرئيسي. يتضمن ذلك كائنًا عامًا يوفر تعريفًا
extern_array
. ربما لا يكون مستعدًا لمواجهة موقف اختفى فيه عنصر صفيف في تعريفه الخاص.
ماذا عن التغيير في الاتجاه المعاكس ، وإزالة عنصر؟
int external_array[2] = { 1, 2 };
إذا كان البرنامج يتجنب الوصول إلى عنصر الصفيف
extern_array[2]
، لأنه يكتشف بطريقة ما الطول المخفض للصفيف ،
extern_array[2]
هذا. بعد الصفيف ، هناك بعض الذاكرة غير المستخدمة ، ولكن هذا لن يقطع البرنامج.
هذا يعني أننا نحصل على القاعدة التالية:
- إضافة عناصر إلى متغير صفيف عمومي ينتهك التوافق الثنائي.
- قد تؤدي إزالة العناصر إلى قطع التوافق إذا لم تكن هناك آلية تتجنب الوصول إلى العناصر المحذوفة.
لسوء الحظ ، يبدو تحذير المحمل الديناميكي أكثر ضررًا مما هو عليه بالفعل ، وبالنسبة للعناصر البعيدة لا يوجد تحذير على الإطلاق.
كيفية تجنب هذا الموقف
من السهل إلى حد ما اكتشاف تغييرات ABI باستخدام أدوات مثل
libabigail .
تتمثل أسهل طريقة لتجنب هذا الموقف في تنفيذ دالة تُرجع عنوان الصفيف:
static int local_array[3] = { 1, 2, 3 }; int * get_external_array (void) { return local_array; }
إذا كان يتعذر جعل تعريف المصفوفة ثابتًا نظرًا لطريقة استخدامه في المكتبة ، فيمكننا بدلاً من ذلك إخفاء رؤيته ومنع تصديره أيضًا ، وبالتالي تجنب مشكلة الاقتطاع:
int local_array[3] __attribute__ ((visibility ("hidden"))) = { 1, 2, 3 };
كل شيء أكثر تعقيدًا إذا تم تصدير متغير الصفيف لأسباب التوافق مع الإصدارات السابقة. نظرًا لأنه يتم اقتطاع الصفيف من المكتبة ، فلن يتمكن البرنامج الرئيسي القديم ذي تعريف الصفيف الأقصر من توفير الوصول إلى الصفيف الكامل لرمز العميل الجديد إذا تم استخدامه مع نفس الصفيف العمومي. بدلاً من ذلك ، قد تستخدم وظيفة الوصول صفيفًا منفردًا (ثابتًا أو مخفيًا) ، أو ربما صفيفًا منفصلًا للعناصر المضافة في النهاية. العيب هو أنه لا يمكن تخزين كل شيء في صفيف مستمر إذا تم تصدير متغير الصفيف للتوافق مع الإصدارات السابقة. يجب أن يعكس تصميم الواجهة الثانوية هذا.
باستخدام التحكم في إصدار الأحرف ، يمكنك تصدير إصدارات متعددة بأحجام مختلفة ، دون تغيير الحجم في إصدار معين. باستخدام هذا النموذج ، ستستخدم البرامج الجديدة ذات الصلة دائمًا أحدث إصدار ، ويفترض أنه أكبر حجم. نظرًا لأن إصدار الرمز وحجمه يتم إصلاحهما بواسطة محرر الرابط في نفس الوقت ، فإنهما دائمًا ما يكونا ثابتين. تستخدم مكتبة GNU C هذا النهج للمتغيرات التاريخية
sys_errlist
و
sys_siglist
. ومع ذلك ، هذا لا يزال لا يوفر مجموعة مستمرة واحدة.
كل الأشياء التي تم
get_external_array
في الاعتبار ، تعتبر وظيفة الوصول (على سبيل المثال ، دالة
get_external_array
أعلاه) هي الطريقة الأفضل لتجنب مشكلة توافق ABI هذه.