الملفات القابلة للتنفيذ في نظام التشغيل وتمثيلها باستخدام الصيغة ELF


الملفات القابلة للتنفيذ في نظام التشغيل وتمثيلها باستخدام الصيغة ELF March 31, 2023 at 06:07PM

يحتوي البرنامج الذي يعمل في الذاكرة على مكونين رئيسيين هما: الشيفرة البرمجية Code المعروفة أيضًا باسم النص Text والبيانات Data. لا يبقى الملف القابل للتنفيذ في الذاكرة، ولكنه يقضي معظم وقته بوصفه ملفًا على القرص الصلب ينتظر تحميله عند التشغيل. يُعَد الملف مجرد مصفوفة متجاورة من البتات، لذا تبتكر جميع الأنظمة طرقًا لتنظيم الشيفرة البرمجية والبيانات ضمن الملفات للتنفيذ عند الطلب، حيث يشار إلى هذه الصيغة من الملفات باسم ملف ثنائي Binary أو ملف قابل للتنفيذ Executable، وتكون البتات والبايتات الخاصة بالملف بصيغة جاهزة لوضعها في الذاكرة وتفسيرها مباشرةً بواسطة عتاد المعالج.

تمثيل الملفات القابلة للتنفيذ

يجب أن تحدّد أيّ صيغة لملفٍ قابل للتنفيذ executable file مكانَ وجود الشيفرة البرمجية والبيانات في الملف الثنائي، حيث تُعَد الشيفرة البرمجية والبيانات القسمين الأساسيين لملفٍ قابل للتنفيذ، وأحد المكونات الإضافية التي لم نذكرها حتى الآن هو مساحة تخزين المتغيرات العامة غير المُهيَّأة uninitialised global variables.

إذا صرّحنا عن متغير وأعطيناه قيمة أولية، فيجب تخزين هذه القيمة في ملف قابل للتنفيذ بحيث يمكن تهيئته بالقيمة الصحيحة عند بدء البرنامج، ولكن هناك العديد من المتغيرات غير المهيأة أو التي قيمتها صفر عند تنفيذ البرنامج لأول مرة. يُعَد حجز مساحة لهذه المتغيرات في الملف القابل للتنفيذ ثم تخزين قيم صفرية أو فارغة NULL هدرًا للمساحة، مما يؤدي إلى تضخّم حجم الملف القابل للتنفيذ على القرص الصلب دون داع لذلك. تُعرِّف معظم الصيغ الثنائية مفهوم القسم BSS الإضافي بوصفه حجمًا بديلًا للبيانات الصفرية غير المُهيَّأة. يمكن تخصيص الذاكرة الإضافية التي يحدّدها القسم BSS وضبطها على القيمة صفر عند تحميل البرنامج. يرمز الاختصار BSS إلى العبارة Block Started by Symbol، وهو أمر بلغة تجميع حاسوب IBM القديم، ولكن يُرجَّح أن الاشتقاق الدقيق له ضاع مع الوقت.

الصيغة الثنائية Binary Format

يُنشَأ الملف القابل بالتنفيذ باستخدام سلسلة أدوات من الشيفرة المصدرية، حيث يجب أن يكون هذا الملف بصيغة محددة وواضحة بحيث يمكن للمصرِّف إنشاؤه ويمكن لنظام التشغيل تحديده وتحميله في الذاكرة وتحويله إلى عملية مُشغَّلة يمكن لنظام التشغيل إدارتها. يمكن أن تكون هذه الصيغة من الملفات القابلة للتنفيذ خاصة بنظام التشغيل، إذ لا نتوقع تنفيذ برنامج مُصرَّف لنظامٍ ما على نظام آخر مثل أن تعمل برامج ويندوز على نظام لينكس أو أن تعمل برامج لينكس على نظام macOS.

لكن خيط المعالجة Thread المشترك بين جميع صيغ الملفات القابلة للتنفيذ هو أنها تتضمن ترويسة معيارية مُعرَّفة مسبقًا توضّح كيفية تخزين شيفرة وبيانات البرنامج في بقية الملف، حيث يمكن أن تشرح ذلك بالكلمات مثل أن نقول: "تبدأ شيفرة البرنامج من 20 بايت في هذا الملف، ويبلغ طولها 50 كيلوبايت، وتتبعها بيانات البرنامج ويبلغ طولها 20 كيلوبايت".

هناك صيغة معينة أصبحت في الآونة الأخيرة معيارًا لتمثيل الملفات القابلة للتنفيذ في الأنظمة الحديثة القائمة على نظام يونكس، ويطلَق على هذا التنسيق بصيغة الرابط والملفات القابلة للتنفيذ Executable and Linker Format -أو ELF اختصارًا، حيث سنشرحها بمزيد من التفصيل لاحقًا.

تاريخ الصيغة الثنائية

سنوضح فيما يلي صيغتين للملفات الثنائية سبقت ظهور صيغة ملفات ELF هما a.out و COFF.

a.out

لم تكن صيغة ملفات ELF المعيار دائمًا، إذ استخدمت أنظمة يونكس الأصلية صيغة ملف بالاسم a.out. يمكننا أن نرى آثار ذلك عند تصريف برنامج بدون الخيار ‎-o لتحديد اسم ملف الخرج، حيث سينشأ الملف القابل للتنفيذ بالاسم الافتراضي a.out الذي يُعَد اسم ملف الخرج الافتراضي الناتج عن الرابط Linker. يستخدم المصرِّف Compiler أسماء الملفات المُنشَأة عشوائيًا بوصفها ملفات وسيطة لشيفرة التجميع والشيفرة المُصرَّفة.

a.out هو صيغة ترويسة بسيطة تسمح فقط بقسم واحد للبيانات والشيفرة وBSS، وهذا غير كافٍ للأنظمة الحديثة ذات المكتبات الديناميكية.

COFF

كانت صيغة ملف التعليمات المُصرَّفة المشترك Common Object File Format -أو COFF اختصارًا- مقدمة لظهور صيغة ملفات ELF، حيث كانت صيغة ترويستها أكثر مرونة، مما يسمح بمزيد -ولكن محدود- من الأقسام في الملف.

تواجه صيغة COFF صعوبات في دعم المكتبات المشتركة، لذا اختيرت صيغة ELF بوصفها تقديمًا Implementation بديلًا على نظام لينكس. لكن توجد صيغة COFF في مايكروسوفت ويندوز بوصفها صيغة ملفات قابلة للتنفيذ والنقل Portable Executable -أو PE اختصارًا- التي تُعَد بالنسبة إلى ويندوز مثل صيغة ملفات ELF في لينكس.

صيغة ملفات ELF

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

توسّع الصيغة ELF صيغة الملفات COFF وتمنح الترويسة مرونة كافية لتحديد عدد عشوائي من الأقسام، بحيث يكون لكل منها خاصياته الخاصة، مما يسهّل الربط الديناميكي وتنقيح الأخطاء Debugging.

01_elf-overview.png

ترويسة ملفات ELF

يحتوي الملف على ترويسة ملف File Header تصِف الملف، ثم يحتوي على مؤشرات لكل قسم من الأقسام التي يتكون منها الملف. يوضح المثال التالي الوصف على النحو الوارد في توثيق واجهة برمجة تطبيقات ELF32 (نموذج 32 بت من صيغة ملفات ELF)، وهو تخطيط لبنية لغة C الذي يعرّف ترويسة ELF:

typedef struct {
  unsigned char e_ident[EI_NIDENT];
  Elf32_Half    e_type;
  Elf32_Half    e_machine;
  Elf32_Word    e_version;
  Elf32_Addr    e_entry;
  Elf32_Off     e_phoff;
  Elf32_Off     e_shoff;
  Elf32_Word    e_flags;
  Elf32_Half    e_ehsize;
  Elf32_Half    e_phentsize;
  Elf32_Half    e_phnum;
  Elf32_Half    e_shentsize;
  Elf32_Half    e_shnum;
  Elf32_Half    e_shstrndx;
} Elf32_Ehdr;

إليك مثال عن ترويسة ELF كما هو موضح باستخدام الأداة readelf:

$ readelf --header /bin/ls

ELF Header:
  Magic:   7f 45 4c 46 01 02 01 00 00 00 00 00 00 00 00 00 
  Class:                             ELF32
  Data:                              2's complement, big endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              EXEC (Executable file)
  Machine:                           PowerPC
  Version:                           0x1
  Entry point address:               0x10002640
  Start of program headers:          52 (bytes into file)
  Start of section headers:          87460 (bytes into file)
  Flags:                             0x0
  Size of this header:               52 (bytes)
  Size of program headers:           32 (bytes)
  Number of program headers:         8
  Size of section headers:           40 (bytes)
  Number of section headers:         29
  Section header string table index: 28

  [...]

يوضّح المثال السابق نموذجًا سهل القراءة على الإنسان كما مولدًا باستخدام برنامج readelf، وهو جزء من أدوات Binutils في GNU.

تُوجَد المصفوفة e_ident في بداية أيّ ملف ELF، وتبدأ دائمًا بمجموعة بايتات سحرية. البايت الأول هو 0x7F ثم الثلاثة بايتات التالية هي "ELF". يمكنك فحص ملف ELF الثنائي لترى ذلك بنفسك باستخدام الأمر hexdump.

يفحص المثال التالي عدد ELF السحري:

ianw@mingus:~$ hexdump -C /bin/ls | more
00000000  7f 45 4c 46 01 02 01 00  00 00 00 00 00 00 00 00  |.ELF............|

... (يتبع ذلك بقية البرنامج) 

لاحظ وجود البت 0x7F في البداية ثم سلسلة آسكي ASCII المُشفَّرة "ELF". ألقِ نظرة على المعيار وشاهد ما تعرّفه بقية المصفوفة وما هي القيم الموجودة في الملف الثنائي. لدينا بعد ذلك بعض الرايات Flags لنوع الجهاز الذي اُنشِئ هذا الملف الثنائي من أجله. لاحظ أن صيغة ELF تعرّف إصدارات مختلفة من الأحجام مثل إصدارات 32 بت و64 بت، حيث سنشرح هنا الإصدار 32 بت. يكمن الاختلاف في أنه يجب الاحتفاظ بالعناوين على أجهزة 64 بت في متغيرات بحجم 64 بتًا. يمكننا أن نرى أن الملف الثنائي أُنشِئ للجهاز الذي يستخدم صيغة Big Endian (تخزين البتات الأقل أهمية أولًا)، حيث يستخدم هذا الجهاز المكمل الثنائي لتمثيل الأعداد السالبة. لاحظ بعد ذلك أن الخاصية Machine تخبرنا أنه جهاز PowerPC الثنائي.

يبدو أن عنوان نقطة الدخول واضح وصريح بدرجة كافية، وهو العنوان الموجود في الذاكرة الذي تبدأ منه شيفرة البرنامج. يُقال لمبرمجي لغة C المبتدئين أن الدالة الرئيسية main()‎ هي أول برنامج يُستدعَى في برامجهم، ولكن يمكننا التحقق من أنه ليس كذلك باستخدام عنوان نقطة الدخول كما يلي:

$ cat test.c
#include <stdio.h>

int main(void)
{
  printf("main is : %p\n", &main);
  return 0;
}

$ gcc -Wall -o test test.c

$ ./test
main is : 0x10000430

$ readelf --headers ./test | grep 'Entry point'
  Entry point address:               0x100002b0

$ objdump --disassemble ./test | grep 100002b0
100002b0 <_start>:
100002b0:       7c 29 0b 78     mr      r9,r1

لاحظ في المثال السابق أنه يمكننا أن نرى أن نقطة الدخول هي دالة تسمى ‎_start. لم يعرّف برنامجنا هذه الدالة على الإطلاق، ويشير الخط السفلي في بداية اسم الدالة إلى أنها موجودة في فضاء أسماء منفصل. سنشرح لاحقًا كيفية بدء البرنامج بالتفصيل.

تحتوي الترويسة بعد ذلك على مؤشرات إلى المكان الموجود في الملف الذي تبدأ فيه الأجزاء المهمة الأخرى من ملف ELF مثل جدول المحتويات.

الرموز Symbols 3 والمنقولات Relocation

توفر مواصفات ملف ELF جداول رموز Symbol Tables تربط بين السلاسل النصية أو الرموز ومواقع في الملف. تُعَد الرموز مطلوبة للربط Linking، فمثلًا يمكن أن يتطلب إسنادُ قيمة للمتغير foo المُصرَّح عنه بالشكل extern int foo رابطًا للعثور على عنوان المتغير foo، والذي يمكن أن يتضمن البحث عن الكلمة "foo" في جدول الرموز وإيجاد العنوان.

ترتبط المنقولات Relocations ارتباطًا وثيقًا بالرموز، حيث يُعَد الانتقال مساحةً فارغة تُترَك لإصلاحها لاحقًا، إذ لا يمكن استخدام المتغير foo في المثال السابق حتى معرفة عنوانه، ولكن نعلم في نظام 32 بت أن عنوان المتغير foo يجب أن يكون بقيمة 4 بايتات، لذلك يمكن للمصرِّف ببساطة ترك مساحة فارغة بمقدار 4 بايتات والاحتفاظ بانتقالٍ Relocation يخبر الرابط بأن يضع القيمة الحقيقية للمتغير foo في هذه المساحة التي مقدارها 4 بايتات في هذا العنوان في أيّ وقت يحتاج فيه المصرِّف استخدامَ هذا العنوان لإسناد قيمة مثلًا، ولكن يتطلب ذلك تحليل الرمز "foo".

المقاطع Segments والأقسام Sections

تحدد صيغة ELF عرضين لملف ELF، حيث يُستخدَم أحدهما للربط والآخر للتنفيذ، مما يوفر مرونة كبيرة لمصممي الأنظمة. سنتحدث عن الأقسام الموجودة في شيفرة الكائن التي تنتظر أن تُربَط بملف قابل للتنفيذ، ويُربَط قسم واحد أو أكثر مع مقطعٍ ما في الملف القابل للتنفيذ.

المقاطع Segments

من الأسهل في بعض الأحيان النظر إلى المستوى الأعلى من التجريد abstraction المتمثل بالمقاطع قبل فحص الطبقات السفلية. يحتوي ملف ELF على ترويسة تصف تخطيط الملف العام، حيث تشير ترويسة ELF إلى مجموعة أخرى من الترويسات تسمى ترويسات البرامج Program Headers، حيث تصِف هذه الترويسات لنظام التشغيل أيّ شيء يمكن أن يكون مطلوبًا لتحميل الملف الثنائي في الذاكرة وتنفيذه. كما تصف ترويسات البرامج المقاطع، ولكن هناك بعض الأشياء الأخرى المطلوبة لتشغيل الملف القابل للتنفيذ.

إليك مثال عن ترويسة برنامج:

typedef struct {
  Elf32_Word p_type;
  Elf32_Off  p_offset;
  Elf32_Addr p_vaddr;
  Elf32_Addr p_paddr;
  Elf32_Word p_filesz;
  Elf32_Word p_memsz;
  Elf32_Word p_flags;
  Elf32_Word p_align;
}

يوضح المثال السابق تعريف ترويسة البرنامج. لا بد أنك لاحظت من تعريف ترويسة ELF سابقًا وجود الحقول e_phoff وe_phnum وe_phentsize التي تمثّل الإزاحة في الملف حيث تبدأ ترويسات البرامج وعدد ترويسات البرامج الموجودة وحجم كل ترويسة برنامج، وبالتالي يمكنك العثور على ترويسات البرامج وقراءتها بسهولة باستخدام هذه الأجزاء الثلاثة من المعلومات.

تُعَد ترويسات البرامج أكثر من مجرد مقاطع، حيث يعرّف الحقل p_type ما تعرّفه ترويسة البرنامج، فمثلًا إذا كان هذا الحقل هو PT_INTERP، فستُعرَّف الترويسة بأنها مؤشر سلسلة نصية يؤشّر إلى مفسّر Interpreter الملف الثنائي. ناقشنا سابقًا الفرق بين اللغات المُصرَّفة Compiled واللغات المُفسَّرة Interpreted وميّزنا المُصرِّف بأنه ينشئ ملفًا ثنائيًا يمكن تشغيله بطريقة مستقلة. لكن لا بد أنك تتساءل عن سبب حاجتنا لمفسّر! حسنًا، ترغب الأنظمة الحديثة في المرونة عند تحميل الملفات القابلة للتنفيذ، لذا لا يمكن الحصول على بعض المعلومات بصورة كافية إلّا في الوقت الفعلي الذي يُعَد فيه البرنامج للتشغيل، وهذا ما يسمى بالربط الديناميكي Dynamic Linking الذي سنتحدث عنه لاحقًا، وبالتالي يجب إجراء بعض التغييرات الطفيفة على البرنامج الثنائي للسماح له بالعمل بصورة صحيحة في وقت التشغيل. لذا يُعَد مفسّر الملف الثنائي المعتاد هو المحمّل الديناميكي Dynamic Loader، لأنه يأخذ الخطوات النهائية لإكمال تحميل الملف القابل للتنفيذ وإعداد الصورة الثنائية للتشغيل.

تصف القيمة PT_LOAD في الحقل p_type المقاطع، ثم تصف الحقول الأخرى في ترويسة البرنامج كلّ مقطع منها. يخبرك الحقل p_offset بمقدار بُعد بيانات المقطع عن الملف الموجود على القرص الصلب. بينما يخبرك الحقل p_vaddr بالعنوان الذي يجب أن توجد عنده البيانات في الذاكرة الوهمية Virtual Memory، حيث يصف الحقل p_addr العنوان الحقيقي Physical Address الذي يُعَد مفيدًا للأنظمة المدمَجة الصغيرة التي لا تطبّق الذاكرة الوهمية. تخبرك الرايتان p_filesz وp_memsz بحجم المقطع الموجود على القرص الصلب وكم يجب أن يكون حجمه في الذاكرة. إذا كان حجم الذاكرة أكبر من حجم القرص الصلب، فيجب ملء التداخل بينهما بالأصفار، وبالتالي يمكنك توفير مساحة كبيرة في ملفاتك الثنائية من خلال عدم الاضطرار إلى هدر مساحة للمتغيرات العامة الفارغة. أخيرًا، يشير الحقل p_flags إلى أذونات المقطع، حيث يمكن تحديد أذونات التنفيذ والقراءة والكتابة، فمثلًا يجب تمييز مقاطع الشيفرة البرمجية بأنها للقراءة والتنفيذ فقط، وتمييز أقسام البيانات للقراءة والكتابة فقط بدون تنفيذ.

هناك عدد من أنواع المقاطع الأخرى المُعرَّفة في ترويسات البرامج الموصوفة كاملةً في مواصفات المعايير.

الأقسام Sections

تشكّل الأقسام مقاطعًا، حيث تُعَد الأقسام طريقة لتنظيم الملف الثنائي في مناطق منطقية لتوصيل المعلومات بين المصرِّف والرابط. تُستخدَم الأقسام في بعض الملفات الثنائية الخاصة مثل نواة لينكس Linux Kernel بطرق أكثر تحديدًا سنوضحها لاحقًا.

رأينا كيف تصل المقاطع في النهاية إلى كتلة بيانات في ملف على القرص الصلب مع بعض المواصفات حول المكان الذي يجب تحميلها فيه والأذونات التي تمتلكها. تمتلك الأقسام ترويسةً مماثلة لترويسة المقاطع كما هو موضح في المثال التالي:

typedef struct {
  Elf32_Word sh_name;
  Elf32_Word sh_type;
  Elf32_Word sh_flags;
  Elf32_Addr sh_addr;
  Elf32_Off  sh_offset;
  Elf32_Word sh_size;
  Elf32_Word sh_link;
  Elf32_Word sh_info;
  Elf32_Word sh_addralign;
  Elf32_Word sh_entsize;
}

تحتوي الأقسام على عدد من الأنواع المُعرَّفة للحقل sh_type مثل تعريف قسم من النوع SH_PROGBITS بوصفه قسمًا يحتوي على بيانات ثنائية يستخدمها البرنامج. تشير الرايات الأخرى إلى ما إذا كان هذا القسم جدولَ رموز يستخدمه الرابط أو منقح الأخطاء مثلًا أو يمكن أن يكون شيئًا ما خاصًا بالمحمّل الديناميكي. كما توجد سمات إضافية مثل سمة التخصيص Allocate التي تشير إلى أن هذا القسم سيحتاج إلى ذاكرة مخصصة له.

سنختبر الآن البرنامج الموضح في المثال التالي:

#include <stdio.h>

int big_big_array[10*1024*1024];

char *a_string = "Hello, World!";

int a_var_with_value = 0x100;

int main(void)
{
  big_big_array[0] = 100;
  printf("%s\n", a_string);
  a_var_with_value += 20;
}

يوضح المثال التالي خرج الأداة readelf مع بعض الأجزاء الأخرى، حيث يمكننا باستخدام هذا الخرج تحليل كل جزء من برنامجنا البسيط السابق ومعرفة ما سيحدث به في خرج الملف الثنائي النهائي:

$ readelf --all ./sections
ELF Header:
  ...
    Size of section headers:           40 (bytes)
    Number of section headers:         37
    Section header string table index: 34

Section Headers:
  [Nr] Name              Type            Addr     Off    Size   ES Flg Lk Inf Al
  [ 0]                   NULL            00000000 000000 000000 00      0   0  0
  [ 1] .interp           PROGBITS        10000114 000114 00000d 00   A  0   0  1
  [ 2] .note.ABI-tag     NOTE            10000124 000124 000020 00   A  0   0  4
  [ 3] .hash             HASH            10000144 000144 00002c 04   A  4   0  4
  [ 4] .dynsym           DYNSYM          10000170 000170 000060 10   A  5   1  4
  [ 5] .dynstr           STRTAB          100001d0 0001d0 00005e 00   A  0   0  1
  [ 6] .gnu.version      VERSYM          1000022e 00022e 00000c 02   A  4   0  2
  [ 7] .gnu.version_r    VERNEED         1000023c 00023c 000020 00   A  5   1  4
  [ 8] .rela.dyn         RELA            1000025c 00025c 00000c 0c   A  4   0  4
  [ 9] .rela.plt         RELA            10000268 000268 000018 0c   A  4  25  4
  [10] .init             PROGBITS        10000280 000280 000028 00  AX  0   0  4
  [11] .text             PROGBITS        100002b0 0002b0 000560 00  AX  0   0 16
  [12] .fini             PROGBITS        10000810 000810 000020 00  AX  0   0  4
  [13] .rodata           PROGBITS        10000830 000830 000024 00   A  0   0  4
  [14] .sdata2           PROGBITS        10000854 000854 000000 00   A  0   0  4
  [15] .eh_frame         PROGBITS        10000854 000854 000004 00   A  0   0  4
  [16] .ctors            PROGBITS        10010858 000858 000008 00  WA  0   0  4
  [17] .dtors            PROGBITS        10010860 000860 000008 00  WA  0   0  4
  [18] .jcr              PROGBITS        10010868 000868 000004 00  WA  0   0  4
  [19] .got2             PROGBITS        1001086c 00086c 000010 00  WA  0   0  1
  [20] .dynamic          DYNAMIC         1001087c 00087c 0000c8 08  WA  5   0  4
  [21] .data             PROGBITS        10010944 000944 000008 00  WA  0   0  4
  [22] .got              PROGBITS        1001094c 00094c 000014 04 WAX  0   0  4
  [23] .sdata            PROGBITS        10010960 000960 000008 00  WA  0   0  4
  [24] .sbss             NOBITS          10010968 000968 000000 00  WA  0   0  1
  [25] .plt              NOBITS          10010968 000968 000060 00 WAX  0   0  4
  [26] .bss              NOBITS          100109c8 000968 2800004 00  WA  0   0  4
  [27] .comment          PROGBITS        00000000 000968 00018f 00      0   0  1
  [28] .debug_aranges    PROGBITS        00000000 000af8 000078 00      0   0  8
  [29] .debug_pubnames   PROGBITS        00000000 000b70 000025 00      0   0  1
  [30] .debug_info       PROGBITS        00000000 000b95 0002e5 00      0   0  1
  [31] .debug_abbrev     PROGBITS        00000000 000e7a 000076 00      0   0  1
  [32] .debug_line       PROGBITS        00000000 000ef0 0001de 00      0   0  1
  [33] .debug_str        PROGBITS        00000000 0010ce 0000f0 01  MS  0   0  1
  [34] .shstrtab         STRTAB          00000000 0011be 00013b 00      0   0  1
  [35] .symtab           SYMTAB          00000000 0018c4 000c90 10     36  65  4
  [36] .strtab           STRTAB          00000000 002554 000909 00      0   0  1
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings)
  I (info), L (link order), G (group), x (unknown)
  O (extra OS processing required) o (OS specific), p (processor specific)

There are no section groups in this file.
 ...

Symbol table '.symtab' contains 201 entries:
  Num:    Value  Size Type    Bind   Vis      Ndx Name
...
    99: 100109cc 0x2800000 OBJECT  GLOBAL DEFAULT   26 big_big_array
...
   110: 10010960     4 OBJECT  GLOBAL DEFAULT   23 a_string
...
   130: 10010964     4 OBJECT  GLOBAL DEFAULT   23 a_var_with_value
...
   144: 10000430    96 FUNC    GLOBAL DEFAULT   11 main

لنلقِ أولًا نظرة على المتغير big_big_array الذي -كما يوحي الاسم- هو مصفوفة عامة كبيرة إلى حد ما، وإذا انتقلنا إلى جدول الرموز، فيمكننا أن نرى أن هذا المتغير موجود في الموقع 0x100109cc الذي يمكننا ربطه بالقسم ‎.bss في قائمة الأقسام لأنه يبدأ تحته مباشرةً عند الموقع 0x100109c8، ولاحظ حجمه الكبير جدًا.

ذكرنا أن القسم BSS هو جزء معياري من صورة ثنائية، لأنه ليس منطقيًا أن تطلب أن يكون لملفٍ ثنائي على القرص الصلب 10 ميجابايتات من المساحة المخصَّصة له عندما تكون كل هذه المساحة قيمًا صفرية. لاحظ أن هذا القسم يحتوي على النوع NOBITS، مما يعني أنه لا يحتوي على أيّ بايت على القرص الصلب. لذا يُعرَّف القسم ‎.bss للمتغيرات العامة التي يجب أن تكون قيمتها صفرًا عند بدء البرنامج. رأينا كيف يمكن أن يختلف حجم الذاكرة عن حجم القرص الصلب عند مناقشتنا للمقاطع، فوجود المتغيرات في القسم ‎.bss دليل على أنها ستُعطَى قيمة صفرية عند بدء البرنامج.

يوجد المتغير a_string في القسم ‎.sdata الذي يمثّل البيانات الصغيرة Small Data، حيث يُعَد هذا القسم وقسم ‎.sbss المقابل له أقسامًا متوفرة في بعض المعماريات حيث يمكن الوصول إلى البيانات باستخدام الإزاحة عن بعض المؤشرات المعروفة، وهذا يعني أنه يمكن إضافة قيمة ثابتة إلى العنوان الأساسي، مما يجعل الوصول إلى البيانات في الأقسام أسرع نظرًا لعدم وجود عمليات بحث مطلوبة إضافية وتحميل للعناوين في الذاكرة. تقتصر معظم المعماريات على حجم القيم الفورية Immediate Value التي يمكنك إضافتها إلى المسجل مثل القيمة الفورية 70 عند تطبيق التعليمة r1 = add r2, 70;‎ على عكس جمع قيمتين مخزنتين في مسجلين r1 = add r2,r3، وبالتالي يمكن تطبيق إزاحة بمقدار مسافة صغيرة معينة عن العنوان. يمكننا أيضًا أن نرى أن المتغير a_var_with_value يوجد في المكان نفسه.

بينما توجد الدالة الرئيسية main في القسم ‎.text. تذكر أن "النص Text" و"الشيفرة Code" يُستخدَمان للإشارة إلى برنامج في الذاكرة.

الأقسام والمقاطع مع بعضها البعض

إليك مثال يحتوي على الأقسام والمقاطع مع بعضها البعض:

$ readelf --segments /bin/ls

Elf file type is EXEC (Executable file)
Entry point 0x100026c0
There are 8 program headers, starting at offset 52

Program Headers:
  Type           Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align
  PHDR           0x000034 0x10000034 0x10000034 0x00100 0x00100 R E 0x4
  INTERP         0x000154 0x10000154 0x10000154 0x0000d 0x0000d R   0x1
    [Requesting program interpreter: /lib/ld.so.1]
  LOAD           0x000000 0x10000000 0x10000000 0x14d5c 0x14d5c R E 0x10000
  LOAD           0x014d60 0x10024d60 0x10024d60 0x002b0 0x00b7c RWE 0x10000
  DYNAMIC        0x014f00 0x10024f00 0x10024f00 0x000d8 0x000d8 RW  0x4
  NOTE           0x000164 0x10000164 0x10000164 0x00020 0x00020 R   0x4
  GNU_EH_FRAME   0x014d30 0x10014d30 0x10014d30 0x0002c 0x0002c R   0x4
  GNU_STACK      0x000000 0x00000000 0x00000000 0x00000 0x00000 RWE 0x4

 Section to Segment mapping:
  Segment Sections...
    00
    01     .interp
    02     .interp .note.ABI-tag .hash .dynsym .dynstr .gnu.version .gnu.version_ r .rela.dyn .rela.plt .init .text .fini .rodata .eh_frame_hdr
    03     .data .eh_frame .got2 .dynamic .ctors .dtors .jcr .got .sdata .sbss .p lt .bss
    04     .dynamic
    05     .note.ABI-tag
    06     .eh_frame_hdr
    07

يوضح المثال السابق كيف تظهِر الأداة readelf ربط المقاطع والأقسام في ملف ELF مع الملف الثنائي ‎/bin/ls.

انتقل إلى نهاية الخرج حيث يمكننا أن نرى الأقسام المنقولة إلى المقاطع، فمثلًا يُوضَع القسم ‎.interp في المقطع الذي له الراية INTERP. لاحظ أن الأداة readelf تخبرنا بطلب المفسّر ‎/lib/ld.so.1، وهو الرابط الديناميكي الذي يُشغَّل لإعداد الملف الثنائي للتنفيذ.

يمكننا أن نرى الفرق بين النص والبيانات بالنظر إلى مقطعي LOAD. لاحظ أن المقطع الأول لديه أذونات القراءة والتنفيذ فقط، بينما يكون للمقطع الآخر أذونات القراءة والكتابة والتنفيذ، أي أن مقطع الشيفرة له أذونات القراءة والكتابة (r/w) ومقطع البيانات له أذونات القراءة والكتابة والتنفيذ (r/w/e)، ولكن لا يجب أن تكون البيانات قابلة للتنفيذ. لن يُميَّز قسم البيانات في معظم المعماريات مثل المعمارية x86 الأكثر شيوعًا على أنه يحتوي على قسم بيانات قابل للتنفيذ. لكن المثال السابق مأخوذ من معمارية PowerPC التي لها نموذج برمجة مختلف قليلًا وهو واجهة التطبيق الثنائية Application Binary Interface -أو ABI اختصارًا- التي تتطلب أن يكون قسم البيانات قابلًا للتنفيذ. هذه هي حياة مبرمج الأنظمة، إذ وُضِعت القواعد لكسرها.

تستدعي واجهة ABI في معمارية PowerPC شيفرات اختبارية Stubs للدوال في المكتبات الديناميكية مباشرةً في جدول الإزاحة العام Global Offset Table -أو GOT اختصارًا- بدلًا من جعلها ترتد بين مدخلات منفصلة من جدول PLT، وبالتالي يحتاج المعالج إلى أذونات تنفيذ للقسم GOT الذي يمكنك رؤيته مضمَّنًا في مقطع البيانات.

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

واجهات ABI

تُعَد واجهة ABI مصطلحًا ستسمع عنه كثيرًا عند العمل مع برمجة الأنظمة، وهو مختلف عن مصطلح API الذي يُعَد واجهات يراها المبرمج في شيفرته البرمجية. تشير ABI إلى واجهات المستوى الأدنى التي يجب أن يتفق عليها المصرِّف ونظام التشغيل والمعالج إلى حد ما للتواصل مع بعضها البعض. سنقدم فيما يلي عددًا من المفاهيم المهمة لفهم واجهات ABI.

ترتيب البايتات

تُرتَّب البايتات باستخدام ترتيب Endianess الذي يحتوي على نوعين هما: Big-endian أي تخزين البتات الأقل أهمية أولًا، و Little-endian أي تخزين البتات الأكثر أهمية أولًا.

العرف المتبع في الاستدعاءات

يمكن تنفيذ الاستدعاءات بطريقتين هما: تمرير المعاملات باستخدام المسجلات registers أو المكدس stack وواصفات الدوال.

بخصوص واصفات الدوال، لا تُستدعَى الدالة في العديد من المعماريات مباشرةً، بل تُستدعَى عبر واصف دالة Function Descriptor. يتكون واصف الدالة في المعمارية IA64 مثلًا من مكونين هما: عنوان الدالة (يُمثَّل بقيمة مقدارها 64 بتًا أو 8 بايتات) وعنوان المؤشر العام Global Pointer أو gp اختصارًا. تحدد واجهة ABI أن المسجل r1 يجب أن يحتوي دائمًا على قيمة المؤشر gp الخاص بالدالة، وهذا يعني أن مهمة المستدعي عند استدعاء دالة هي حفظ قيمة المؤشر gp الخاصة به وضبط المسجل r1 على القيمة الجديدة من واصف الدالة ثم استدعاء هذه الدالة.

تُعَد واصفات الدوال مفيدة للغاية كما سترى لاحقًا. يمكن أن تأخذ تعليمة الجمع add في المعالج IA64 قيمة فورية ذات حجم بحد أقصى 22 بتًا بسبب الطريقة التي يحزُم بها المعالج IA64 التعليمات، حيث تُوضَع ثلاثة تعليمات في كل حزمة، ولا يوجد سوى مساحة كافية للاحتفاظ بقيمة 22 بتًا للحفاظ على الحزمة مع بعضها البعض. القيمة الفورية Immediate Value هي القيمة المحددة مباشرةً وليس القيمة الموجودة في المسجل، إذ تُعَد القيمة 100 في التعليمة add r1 + 100 هي القيمة الفورية.

يمكن أن تتمكن 22 بتًا من تمثيل 4194304 بايت أو 4 ميجابايتات، وبالتالي يمكن إزاحة كل دالة مباشرة في حيّز ذاكرة كبير مقداره 4 ميجابايتات دون الحاجة إلى تحمل عناء تحميل أيّ قيمٍ في المسجل. إذا اتفق المصرِّف والرابط والمحمِّل على ما يشير إليه المؤشر العام كما هو محدد في واجهة ABI، فيمكن تحسين الأداء من خلال تقليل عمليات التحميل.

ترجمة -وبتصرُّف- للأقسام Review of executable files و Representing executable files و ELF و ABIs من فصل Behind the process من كتاب Computer Science from the Bottom Up لصاحبه Ian Wienand.

اقرأ أيضًا

#oqpahameedq

تعليقات