يوفر وجود الربط الديناميكي Dynamic Linking بعض المزايا التي يمكننا الاستفادة منها وبعض المشاكل الإضافية التي يجب حلها للحصول على نظام فعّال.
إصدارات المكتبات
إحدى المشاكل المُحتمَلة هي وجود إصدارات مختلفة للمكتبات. لكن هناك احتمال أقل بكثير لوجود مشاكل عند استخدام المكتبات الساكنة، حيث تُدمَج شيفرة المكتبة البرمجية مباشرةً في الملف الثنائي الخاص بالتطبيق. إن أردتَ استخدام إصدار جديد من المكتبة، فيجب إعادة تصريفها في ملف ثنائي جديد لتحل محل الإصدار القديم. يُعَد ذلك أمرًا غير عملي إلى حد ما بالنسبة للمكتبات الشائعة وأكثرها شيوعًا مكتبه libc والمُضمَّنة في معظم التطبيقات. إذا كانت المكتبة متوفرة فقط بوصفها مكتبة ساكنة، فيجب إعادة بناء كل تطبيق في النظام عند أي تعديل فيها.
يمكن أن تسبّب التعديلات في طريقة عمل المكتبة الديناميكية مشاكلًا متعددة. تكون التعديلات في أحسن الأحوال متوافقة تمامًا دون تغيير أيّ شيء مرئي خارجيًا، ولكن يمكن أن تتسبب التعديلات في تعطل التطبيق مثل تغير الدالة التي تأخذ النوع int
لتأخذ النوع int *
. الأسوأ من ذلك هو أن يغيّر إصدارُ المكتبة الجديد الدلالات ويعيد قيمًا مختلفة وخاطئة فجأةً. يمكن أن يكون هذا خطأً يصعب تعقّبه، حيث إن تعطل أحد التطبيقات، فيمكنك استخدام منقّح أخطاء Debugger لعزل مكان حدوث الخطأ، بينما يمكن أن يظهر تلف البيانات أو تعديلها فقط في أجزاء أخرى من التطبيق.
يتطلب الرابط الديناميكي طريقة لتحديد إصدار المكتبات في النظام بحيث يمكن التعرّف على التعديلات الأحدث. هناك عدد من الأنظمة التي يمكن للرابط الديناميكي الحديث استخدامها للعثور على الإصدارات الصحيحة من المكتبات التي سنوضّحها فيما يلي.
نظام sonames
يُستخدَم نظام sonames
لإضافة بعض المعلومات الإضافية إلى مكتبة للمساعدة في تحديد الإصدارات. يسرد التطبيق المكتبات التي يريدها في الحقول DT_NEEDED
ضمن القسم الديناميكي للملف الثنائي، وتوجد المكتبة الفعلية في ملف على القرص الصلب ضمن المجلد /lib
لمكتبات النظام الأساسية أو المجلد /usr/lib
للمكتبات الاختيارية.
يتطلب وجودُ إصدارات متعددة من المكتبة على القرص الصلب استخدامَ أسماء ملفات مختلفة. لذا يستخدم نظام sonames
مجموعة من الأسماء وروابطًا إلى نظام الملفات لبناء تسلسل هرمي من المكتبات من خلال تقديم مفهوم التعديلات الرئيسية Major والثانوية Minor للمكتبة. يُعَد التعديل الثانوي تعديلًا متوافقًا مع إصدار سابق من المكتبة، ويتكون من إصلاحاتٍ للأخطاء فقط. بينما يُعَد التعديل الرئيسي أي تعديل غير متوافق مثل تغيير دخل الدوال أو الطريقة التي تتصرف بها الدالة.
تشكّل الحاجة إلى الاحتفاظ بكل تعديل مكتبة رئيسي أو ثانوي في ملف منفصل على القرص الصلب أساسَ تسلسل المكتبات الهرمي. يكون اسم المكتبة هو libNAME.so.MAJOR.MINOR
حسب العِرف المتبع، حيث يمكنك اختياريًا الحصول على إطلاق Release بوصفه معرفًا نهائيًا بعد العدد الثانوي، ويكفي ذلك لتمييز جميع إصدارات المكتبة المختلفة.
مع ذلك، إذا رُبِط كل تطبيق بهذا الملف مباشرةً، فسنواجه المشكلة نفسها التي واجهناها مع المكتبة الساكنة، إذ يجب إعادة بناء التطبيق للإشارة إلى المكتبة الجديدة في كل مرة يحدث فيها تعديل ثانوي. ما نريده هو أن نشير إلى ما يمثله العدد الرئيسي Major من المكتبة الذي إن تغير، فيجب إعادة تصريف Recompile تطبيقنا، لأننا نحتاج إلى التأكد من أن برنامجنا لا يزال متوافقًا مع المكتبة الجديدة.
يكون soname
بالشكل libNAME.so.MAJOR
، ويجب ضبطه في الحقل DT_SONAME
من القسم الديناميكي لمكتبة مشتركة، حيث يمكن لمؤلف المكتبة تحديد هذا الإصدار عند إنشاء المكتبة.
يمكن أن يحدّد كل ملف مكتبة للإصدار الثانوي على القرص الصلب رقمَ الإصدار الرئيسي نفسه في الحقل DT_SONAME
، مما يسمح للرابط الديناميكي بمعرفة أن ملف المكتبة يطبّق تعديلًا رئيسيًا معينًا لواجهتي API و ABI الخاصتين بالمكتبة.
لذا يُشغَّل تطبيق اسمه ldconfig لإنشاء روابط رمزية للإصدار الرئيسي إلى أحدث إصدار ثانوي على النظام. يعمل تطبيق ldconfig من خلال تشغيل جميع المكتبات التي تطبّق رقم إصدار رئيسي معين، ثم يختار المكتبة التي تحتوي على أعلى رقم تعديل ثانوي، ثم ينشِئ رابطًا رمزيًا من libNAME.so.MAJOR
إلى ملف المكتبة الفعلي الموجود على القرص الصلب مثل libNAME.so.MAJOR.MINOR
.
الجزء الأخير من التسلسل الهرمي هو اسم تصريف Compile Name المكتبة. إن أردت تصريف برنامجك لربطه بمكتبة، فيمكنك استخدام الراية -lNAME
التي تبحث عن الملف libNAME.so
في مسار بحث المكتبة.
لاحظ أننا لم نحدد أي رقم إصدار، لأننا نريد فقط الربط بأحدث مكتبة على النظام. يعود الأمر إلى إجراء التثبيت الخاص بالمكتبة لإنشاء رابط رمزي بين اسم التصريف libNAME.so
وأحدث شيفرة مكتبة على النظام، ويمكن التعامل مع ذلك باستخدام نظام إدارة الحزم dpkg أو rpm. لا يُعَد ذلك عملية آلية، إذ يُحتمَل ألّا تكون أحدث مكتبة على النظام هي المكتبة التي ترغب في تصريفها دائمًا، فمثلًا يمكن أن تكون أحدث مكتبة مُثبَّتة إصدارًا تطويريًا غير مناسب للاستخدام العام.
يوضح الشكل التالي العملية العامة لنظام sonames
:
كيف يبحث الرابط الديناميكي عن المكتبات
يبحث الرابط الديناميكي في الحقل DT_NEEDED
للعثور على المكتبات المطلوبة عند بدء تشغيل التطبيق، حيث يحتوي هذا الحقل على اسم soname
الخاص بالمكتبة، لذا فالخطوة التالية هي أن يمر الرابط الديناميكي على جميع المكتبات في مسار بحثه بحثًا عن المكتبة المطلوبة.
تتضمن هذه العملية من الناحية النظرية خطوتين. أولًا، يجب أن يبحث الرابط الديناميكي في جميع المكتبات للعثور على تلك المكتبات التي تطبّق نظام soname
المحدد. ثانيًا، يجب مقارنة أسماء الملفات الخاصة بالتعديلات الثانوية للعثور على أحدث إصدار والذي يكون جاهزًا للتحميل لاحقًا.
ذكرنا سابقًا أن هناك رابطًا رمزيًا أعدّه برنامج ldconfig بين اسم soname
الخاص بالمكتبة والتعديل الثانوي الأخير، وبالتالي يجب أن يتبع الرابط الديناميكي هذا الرابط فقط للعثور على الملف الصحيح المراد تحميله بدلًا من الاضطرار إلى فتح جميع المكتبات الممكنة وتحديد المكتبات التي تريد استخدامها في كل مرة يكون التطبيق مطلوبًا فيها.
يُعَد الوصول إلى نظام الملفات بطيئًا جدًا، لذا ينشئ برنامج ldconfig ذاكرة مخبئية للمكتبات المُثبَّتة في النظام، حيث تكون هذه الذاكرة المخبئية ببساطة قائمةً بأسماء soname
الخاصة بالمكتبات المتاحة للرابط الديناميكي ومؤشرًا لرابط الإصدار الرئيسي على القرص الصلب، مما يوفر على الرابط الديناميكي قراءة مجلدات كاملة مليئة بالملفات لتحديد الرابط الصحيح.
يمكنك تحليل ذلك باستخدام /sbin/ldconfig -p
الموجود ضمن الملف /etc/ldconfig.so.cache
. إن لم يُعثَر على المكتبة في الذاكرة المخبئية، فسيعود الرابط الديناميكي إلى الخيار الأبطأ المتمثل في المرور على نظام الملفات، وبالتالي يجب إعادة تشغيل برنامج ldconfig عند تثبيت مكتبات جديدة.
البحث عن الرموز
ناقشنا كيف حصل الرابط الديناميكي على عنوان دالة المكتبة ووضعه في جدول PLT ليستخدمه البرنامج، ولكننا لم نناقش حتى الآن كيف يجد الرابط الديناميكي عنوان الدالة. تُسمَّى هذه العملية بالارتباط Binding، لأن اسم الرمز مرتبط بالعنوان الذي يمثله.
يحتوي الرابط الديناميكي على أجزاء من المعلومات مثل الرمز الذي يبحث عنه وقائمة المكتبات التي يمكن أن يكون هذا الرمز فيها كما هو محدَّد باستخدام حقول DT_NEEDED
في الملف الثنائي. تحتوي كل مكتبة كائنات مشتركة على قسم يسمى .dynsym
مميَّز على أنه SHT_DYNSYM
، حيث يُعَد هذا القسم الحد الأدنى من مجموعة الرموز المطلوبة للربط الديناميكي، وهو أيّ رمز في المكتبة يمكن أن يستدعيه برنامج خارجي.
جدول الرموز الديناميكي
هناك ثلاثة أقسام تلعب جميعها دورًا في وصف الرموز الديناميكية. لنلقِ أولًا نظرة على تعريف رمز من مواصفات ملف ELF كما يلي:
typedef struct { Elf32_Word st_name; Elf32_Addr st_value; Elf32_Word st_size; unsigned char st_info; unsigned char st_other; Elf32_Half st_shndx; } Elf32_Sym;
الحقل | القيمة |
---|---|
st_name
|
فهرس إلى جدول السلاسل النصية |
st_value
|
القيمة الموجودة في كائن مشترك قابل للنقل، حيث تحتفظ هذه القيمة بالإزاحة عن قسم الفهرس المعطى في الحقل st_shndx
|
st_size
|
أي حجم مرتبط بالرمز |
st_info
|
معلومات عن ارتباط Binding الرمز الذي سنشرحه لاحقًا ويكون نوع هذا الرمز دالة أو كائن أو غير ذلك |
st_other
|
غير مُستخدَم حاليًا |
st_shndx
|
فهرس القسم الذي يوجد فيه الرمز (اطّلع على الحقل st_value )
|
تكون السلسلة النصية الفعلية لاسم الرمز ضمن قسم منفصل هو القسم .dynstr
، حيث تحتوي المدخلة في هذا القسم فهرسًا إلى قسم السلاسل النصية فقط، مما يؤدي إلى ظهور مستوًى معين من الحِمل على الرابط الديناميكي، إذ يجب أن يقرأ الرابط الديناميكي جميع مدخلات الرموز في القسم .dynstr
، ثم يتبع مؤشر الفهرس للعثور على اسم الرمز للمقارنة.
يمكن تسريع هذه العملية من خلال تقديم قسم ثالث يسمى .hash
يحتوي على جدول تعمية Hash Table لأسماء رموز مدخلات جدول الرموز. يُحسَب جدول التعمية مسبقًا عند إنشاء المكتبة ويسمح للرابط الديناميكي بالعثور على مدخلة الرمز بصورة أسرع باستخدام عملية بحث واحدة أو اثنتين فقط.
ارتباط الرموز Symbol Binding
تشير عملية العثور على عنوان رمز إلى عملية ارتباط هذا الرمز، ولكن ارتباط الرموز Symbol Binding له معنًى منفصل، إذ تفرض عملية ارتباط الرموز رؤيتها خارجيًا أثناء عملية الربط الديناميكي. يُعَد الرمز المحلي Local Symbol غير مرئي خارج ملف الكائن المُعرَّف ضمنه، بينما يُعَد الرمز العام Global Symbol مرئيًا لملفات الكائنات الأخرى ويمكن أن يحقِّق المراجعَ غير المُعرَّفة في كائنات أخرى. يكون المرجع الضعيف Weak Reference نوعًا خاصًا من المراجع العامة ذات الأولوية المنخفضة، مما يعني أنه مُصمَّم لتجاوزه كما سنرى لاحقًا.
يوضح المثال التالي برنامجًا بلغة سي C نحلّله لفحص ارتباطات الرموز:
$ cat test.c static int static_variable; extern int extern_variable; int external_function(void); int function(void) { return external_function(); } static int static_function(void) { return 10; } #pragma weak weak_function int weak_function(void) { return 10; } $ gcc -c test.c $ objdump --syms test.o test.o: file format elf32-powerpc SYMBOL TABLE: 00000000 l df *ABS* 00000000 test.c 00000000 l d .text 00000000 .text 00000000 l d .data 00000000 .data 00000000 l d .bss 00000000 .bss 00000038 l F .text 00000024 static_function 00000000 l d .sbss 00000000 .sbss 00000000 l O .sbss 00000004 static_variable 00000000 l d .note.GNU-stack 00000000 .note.GNU-stack 00000000 l d .comment 00000000 .comment 00000000 g F .text 00000038 function 00000000 *UND* 00000000 external_function 0000005c w F .text 00000024 weak_function $ nm test.o U external_function 00000000 T function 00000038 t static_function 00000000 s static_variable 0000005c W weak_function
لاحظ استخدام #pragma
لتعريف الرمز الضعيف، حيث يُعَد pragma
طريقة لإيصال معلومات إضافية إلى المصرِّف Compiler واستخدامه غير شائع، ولكن يكون في بعض الأحيان مطلوبًا لإخراج المصرِّف من العمليات المعتادة.
يمكن فحص الرموز باستخدام أداتين مختلفتين كما هو موضَّح في المثال السابق، حيث يظهر الارتباط في العمود الثاني في كلتا الحالتين، ويجب أن تكون الشيفرات البرمجية واضحة تمامًا.
تجاوز الرموز Overriding Symbols
يجب أن يكون المبرمج قادرًا على تجاوز رمز في مكتبة، مما يعني تخريب الرمز العادي بتعريفٍ مختلف. ذكرنا أن ترتيب البحث في المكتبات مُحدَّدٌ حسب ترتيب حقول DT_NEEDED
داخل المكتبة، ولكن يمكن إدخال مكتبات لتكون المكتبات الأخيرة التي يجري البحث عنها، وهذا يعني أنه سيُعثَر على أيّ رموز ضمنها بوصفها مرجعًا نهائيًا. يمكن تحقيق ذلك باستخدام متغير بيئة يسمى LD_PRELOAD
يحدد المكتبات التي يجب أن يحمّلها الرابط في النهاية كما في المثال التالي:
$ cat override.c #define _GNU_SOURCE 1 #include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/types.h> #include <dlfcn.h> pid_t getpid(void) { pid_t (*orig_getpid)(void) = dlsym(RTLD_NEXT, "getpid"); printf("Calling GETPID\n"); return orig_getpid(); } $ cat test.c #include <stdio.h> #include <stdlib.h> #include <unistd.h> int main(void) { printf("%d\n", getpid()); } $ gcc -shared -fPIC -o liboverride.so override.c -ldl $ gcc -o test test.c $ LD_PRELOAD=./liboverride.so ./test Calling GETPID 15187
تجاوزنا في المثال السابق الدالة getpid
لطباعة عبارة صغيرة عند استدعائها. نستخدم الدالة dlysm
التي توفرها مكتبة libc
مع وسيط يخبرها بالاستمرار والعثور على الرمز التالي المسمَّى getpid
.
الرموز الضعيفة
الرمز الضعيف هو الرمز المُميَّز بأنه له أولوية أقل ويمكن تجاوزه برمز آخر، حيث إن لم يُعثَر على تقديم Implementation آخر أبدًا، فسيكون الرمز الضعيف هو الرمز المُستخدَم. لذا يجب أن يحمّل المحمل الديناميكي جميع المكتبات ويتجاهل الرموز الضعيفة الموجودة في تلك المكتبات لصالح الرموز العادية الموجودة في مكتبات أخرى، حيث كانت هذه هي الطريقة المتبعة لتقديم معالجة الرموز الضعيفة في لينكس باستخدام مكتبة glibc
سابقًا.
لكن كان ذلك غير صحيح بالنسبة لنص معيار يونكس في ذلك الوقت SysVr4 الذي يفرض أنه يجب أن يتعامل الرابط الساكن مع الرموز الضعيفة التي يجب أن تظل بعيدة عن الرابط الديناميكي. تطابق تقديم لينكس الخاص بجعل الرابط الديناميكي يتجاوز الرموز الضعيفة مع منصة IRIX الخاصة بشركة SGI واختلف عن الأنظمة الأخرى مثل Solaris و AIX في ذلك الوقت. لذا لغى المطورون هذا السلوك عندما أدركوا أنه ينتهك المعيار، وتغير السلوك القديم ليتطلّب ضبط راية بيئة خاصة LD_DYNAMIC_WEAK
.
تحديد ترتيب الارتباط
رأينا كيف يمكننا تجاوز دالة في مكتبة من خلال التحميل المسبق لمكتبة مشتركة أخرى لها الرمز المحدد نفسه. يُعَد الرمز الذي يُحلَّل بوصفه الرمز الأخير بأن له المرتبة الأخيرة في ترتيب تحميل المحمل الديناميكي للمكتبات، حيث تُحمَّل المكتبات بالترتيب المحدَّد في الراية DT_NEEDED
الخاصة بالملف االثنائي، ويُحدَّد هذا الترتيب بدوره من خلال ترتيب تمرير المكتبات في سطر الأوامر عند بناء الكائن. يبدأ الرابط الديناميكي عند تحديد موقع الرموز بآخر مكتبة مُحمَّلة ويعمل بصورة عكسية حتى العثور على الرمز المطلوب.
لكن تحتاج بعض المكتبات المشتركة إلى طريقة لتجاوز هذا السلوك، إذ يجب أن تخبر هذه المكتبات الرابط الديناميكي بأنه يجب أن ينظر أولًا بداخلها عن هذه الرموز بدلًا من العمل بصورة عكسية من آخر مكتبة مُحمَّلة. يمكن للمكتبات ضبط الراية DT_SYMBOLIC
في ترويسة القسم الديناميكي للحصول على هذا السلوك، إذ يمكن ضبط هذه الراية من خلال تمرير الراية -Bsymbolic
عبر سطر أوامر الروابط الساكنة عند بناء المكتبة المشتركة، حيث تتحكم هذه الراية برؤية الرمز Symbol Visibility. لا يمكن تجاوز الرموز الموجودة في المكتبة، لذا يمكن عَدُّها خاصةً بالمكتبة المُحمَّلة.
لكن يؤدي ذلك إلى فقدان قدر كبير من التفاصيل نظرًا لتمييز المكتبة بهذا السلوك أو عدم تمييزها، إذ سيسمح النظام الأفضل بجعل بعض الرموز خاصة وبعض الرموز عامة.
تحديد إصدار الرموز Symbol Versioning
يأتي النظام الأفضل من خلال استخدام تحديد إصدار الرموز، حيث يمكننا تحديد بعض المدخلات الإضافية للرابط الساكن لمنحه بعض المعلومات الإضافية حول الرموز في المكتبة المشتركة كما يلي:
$ cat Makefile all: test testsym clean: rm -f *.so test testsym liboverride.so : override.c $(CC) -shared -fPIC -o liboverride.so override.c libtest.so : libtest.c $(CC) -shared -fPIC -o libtest.so libtest.c libtestsym.so : libtest.c $(CC) -shared -fPIC -Wl,-Bsymbolic -o libtestsym.so libtest.c test : test.c libtest.so liboverride.so $(CC) -L. -ltest -o test test.c testsym : test.c libtestsym.so liboverride.so $(CC) -L. -ltestsym -o testsym test.c $ cat libtest.c #include <stdio.h> int foo(void) { printf("libtest foo called\n"); return 1; } int test_foo(void) { return foo(); } $ cat override.c #include <stdio.h> int foo(void) { printf("override foo called\n"); return 0; } $ cat test.c #include <stdio.h> int main(void) { printf("%d\n", test_foo()); } $ cat Versions {global: test_foo; local: *; }; $ gcc -shared -fPIC -Wl,-version-script=Versions -o libtestver.so libtest.c $ gcc -L. -ltestver -o testver test.c $ LD_LIBRARY_PATH=. LD_PRELOAD=./liboverride.so ./testver libtest foo called 100000574 l F .text 00000054 foo 000005c8 g F .text 00000038 test_foo
يمكننا ذكر ما إذا كان الرمز عامًا أم محليًا في أبسط الحالات على النحو الوارد في المثال السابق. تكون الدالة foo
دالة دعم للدالة test_foo
، ويمكن أن نكون سعداء بتجاوز الوظيفة الكلية للدالة test_foo
، ولكن إن استخدمنا إصدار المكتبة المشتركة، فيجب الوصول إليها دون تعديل، إذ لا ينبغي لأحدٍ تعديل دالة الدعم.
يسمح ذلك بالحفاظ على فضاء أسمائنا منظمًا بطريقة أفضل، إذ يمكن أن ترغب العديدُ من المكتبات في تقديم شيء يمكن تسميته باسم دالة شائعة مثل read
أو write
، ولكن إن فعلت ذلك، فيمكن أن يكون الإصدار الفعلي الممنوح للبرنامج خاطئًا تمامًا. يمكن للمطور من خلال تحديد الرموز بأنها محلية التأكدُ من عدم تعارض أي شيء مع هذا الاسم الداخلي دون أن يؤثر الاسم الذي يختاره على أيّ برنامج آخر.
جاء مفهوم تحديد إصدار الرموز Symbol Versioning من تلك الفكرة، حيث يمكنك تحديد إصدارات متعددة من الرمز نفسه ضمن المكتبة نفسها. يُلحِق الرابط الساكن بعض معلومات الإصدار بعد اسم الرمز مثل @VER
الذي يصف الإصدار المعطى للرمز.
إن قدّم المطور دالة لها الاسم نفسه تقديمًا ثنائيًا أو برمجيًا مختلفًا، فيمكنه زيادة رقم الإصدار. تلتقط التطبيقات الجديدة أحدث إصدار من الرمز عند بنائها بمقابل المكتبة المشتركة. لكن ستطلب التطبيقات المبنية بمقابل الإصدارات السابقة من المكتبة نفسها إصدارات أقدم، فمثلًا سيكون لها سلاسل @VER
أقدم في اسم الرمز الذي تطلبه، وبالتالي ستحصل على التقديم الأصلي.
ترجمة -وبتصرُّف- للقسم Working with libraries and the linker من فصل Dynamic Linking من كتاب Computer Science from the Bottom Up لصاحبه Ian Wienand.
أفضل التعليقات
لا توجد أية تعليقات بعد
انضم إلى النقاش
يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.