اذهب إلى المحتوى

استدعاءات النظام والصلاحيات في نظام التشغيل


Ola Abbas

استدعاءات النظام system calls هي كيفية تفاعل برامج مجال المستخدِم Userspace مع نواة النظام Kernel، إذ سنشرح فيما يلي المبدأ العام لكيفية عمل هذه الاستدعاءات، وسنتعرّف على الصلاحيات في نظام التشغيل للوصول إلى الموارد.

أرقام استدعاءات النظام

لكل استدعاء نظام رقم يعرفه مجال المستخدِم والنواة، إذ يعرِف كلاهما أنّ رقم استدعاء النظام 10 هو الاستدعاء open()‎ ورقم استدعاء النظام 11 هو الاستدعاء read()‎ على سبيل المثال.

تُعَدّ واجهة التطبيق الثنائية Application Binary Interface -أو ABI اختصارًا- مشابهةً جدًا لواجهة برمجة التطبيقات API، ولكنها مُخصَّصة للعتاد بدلًا من أن تكون خاصةً بالبرمجيات، إذ ستحدّد واجهة برمجة التطبيقات API المسجّل Register الذي يجب إدخال رقم استدعاء النظام فيه لتتمكّن النواة من العثور عليه عندما يُطلب منها إجراء استدعاء النظام.

الوسائط Arguments

لا تكون استدعاءات النظام جيدةً بدون الوسائط، فالاستدعاء open()‎ مثلًا يحتاج إلى إعلام النواة بالضبط بالملف الذي يجب فتحه، وستحدّد واجهة ABI أيًا من وسائط المسجّلات التي يجب وضعها لاستدعاء النظام.

المصيدة Trap

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

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

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

مكتبة libc

يمكنك تنفيذ كل ما سبق يدويًا لكل استدعاء نظام، لكن تنفّذ مكتبات النظام معظم العمل نيابةً عنك عادةً، والمكتبة القياسية التي تتعامل مع استدعاءات النظام على أنظمة يونيكس هي مكتبة libc.

تحليل استدعاء النظام

بما أنّ مكتبات النظام تجعل الأنظمة تستدعي نيابة عنك، فيجب تطبيق اختراق منخفض المستوى لتوضيح كيفية عمل استدعاءات النظام، وسنوضح كيفية عمل أبسط استدعاء نظام getpid()‎ الذي لا يأخذ أيّ وسيط ويعيد معرّف البرنامج أو العملية التي تكون قيد التشغيل حاليًا.

#include <stdio.h>

/* ‫خاصة باستدعاء النظام syscall()‎ */
#include <sys/syscall.h>
#include <unistd.h>

/* أرقام استدعاءات النظام */
#include <asm/unistd.h>

void function(void)
{
  int pid;

  pid = __syscall(__NR_getpid);
}    

نبدأ بكتابة برنامج صغير بلغة C لتوضيح آلية عمل استدعاءات النظام، وأول شيء يجب ملاحظته هو وجود الوسيط syscall الذي توفّره مكتبات النظام لإجراء استدعاءات النظام مباشرةً، إذ يوفِّر هذا الوسيط طريقةً سهلةً للمبرمجين لإجراء استدعاءات النظام مباشرةً دون الحاجة إلى معرفة إجراءات لغة التجميع الدقيقة لإجراء الاستدعاء على عتادهم.

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

تُعرَّف أرقام استدعاءات النظام في الملف asm/unistd.h من مصدر النواة في نظام لينكس، وبما أنّ هذا الملف موجود في المجلد الفرعي asm، فسيختلف ذلك لكل معمارية يعمل عليها نظام لينكس، كما تُعطَى أرقام استدعاءات النظام اسمًا ‎#define يتكون من ‎__NR_‎، وبالتالي يمكنك رؤية أنّ شيفرتك البرمجية ستجري استدعاء النظام getpid ويخزّن القيمة في المعرِّف pid.

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

معمارية PowerPC

يُعَدّ نظام PowerPC معماريةَ RISC شائعة في حواسيب Apple القديمة، وهو جوهر أجهزة أحدث إصدار من Xbox مثلًا، وفيما يلي مثال عن استدعاء نظام PowerPC:

/* ‫يتلِف استدعاءُ النظام المسجّلاتِ نفسها لاستدعاء الدالة في نظام powerpc،
* ‫باستثناء المسجّل LR الذي يحتاجه التسلسل "sc; bnslr"
* ‫والمسجّل CR حيث يُتلَف المسجل CR0.SO فقط الذي يشير إلى
* ‫حالة إعادة خطأ.
*/

#define __syscall_nr(nr, type, name, args...)                \
  unsigned long __sc_ret, __sc_err;                \
  {                                \
    register unsigned long __sc_0  __asm__ ("r0");        \
    register unsigned long __sc_3  __asm__ ("r3");        \
    register unsigned long __sc_4  __asm__ ("r4");        \
    register unsigned long __sc_5  __asm__ ("r5");        \
    register unsigned long __sc_6  __asm__ ("r6");        \
    register unsigned long __sc_7  __asm__ ("r7");        \
                                    \
    __sc_loadargs_##nr(name, args);                \
    __asm__ __volatile__                    \
      ("sc           \n\t"                \
        "mfcr %0      "                \
      : "=&r" (__sc_0),                \
        "=&r" (__sc_3),  "=&r" (__sc_4),        \
        "=&r" (__sc_5),  "=&r" (__sc_6),        \
        "=&r" (__sc_7)                \
      : __sc_asm_input_##nr                \
      : "cr0", "ctr", "memory",            \
        "r8", "r9", "r10","r11", "r12");        \
    __sc_ret = __sc_3;                    \
    __sc_err = __sc_0;                    \
  }                                \
  if (__sc_err & 0x10000000)                    \
  {                                \
    errno = __sc_ret;                    \
    __sc_ret = -1;                        \
  }                                \
  return (type) __sc_ret

#define __sc_loadargs_0(name, dummy...)                    \
  __sc_0 = __NR_##name
#define __sc_loadargs_1(name, arg1)                    \
  __sc_loadargs_0(name);                        \
  __sc_3 = (unsigned long) (arg1)
#define __sc_loadargs_2(name, arg1, arg2)                \
  __sc_loadargs_1(name, arg1);                    \
  __sc_4 = (unsigned long) (arg2)
#define __sc_loadargs_3(name, arg1, arg2, arg3)                \
  __sc_loadargs_2(name, arg1, arg2);                \
  __sc_5 = (unsigned long) (arg3)
#define __sc_loadargs_4(name, arg1, arg2, arg3, arg4)            \
  __sc_loadargs_3(name, arg1, arg2, arg3);            \
  __sc_6 = (unsigned long) (arg4)
#define __sc_loadargs_5(name, arg1, arg2, arg3, arg4, arg5)        \
  __sc_loadargs_4(name, arg1, arg2, arg3, arg4);            \
  __sc_7 = (unsigned long) (arg5)

#define __sc_asm_input_0 "0" (__sc_0)
#define __sc_asm_input_1 __sc_asm_input_0, "1" (__sc_3)
#define __sc_asm_input_2 __sc_asm_input_1, "2" (__sc_4)
#define __sc_asm_input_3 __sc_asm_input_2, "3" (__sc_5)
#define __sc_asm_input_4 __sc_asm_input_3, "4" (__sc_6)
#define __sc_asm_input_5 __sc_asm_input_4, "5" (__sc_7)

#define _syscall0(type,name)                        \
type name(void)                                \
{                                    \
    __syscall_nr(0, type, name);                    \
}

#define _syscall1(type,name,type1,arg1)                    \
type name(type1 arg1)                            \
{                                    \
  __syscall_nr(1, type, name, arg1);                \
}

#define _syscall2(type,name,type1,arg1,type2,arg2)            \
type name(type1 arg1, type2 arg2)                    \
{                                    \
  __syscall_nr(2, type, name, arg1, arg2);            \
}

#define _syscall3(type,name,type1,arg1,type2,arg2,type3,arg3)        \
type name(type1 arg1, type2 arg2, type3 arg3)                \
{                                    \
  __syscall_nr(3, type, name, arg1, arg2, arg3);            \
}

#define _syscall4(type,name,type1,arg1,type2,arg2,type3,arg3,type4,arg4) \
type name(type1 arg1, type2 arg2, type3 arg3, type4 arg4)        \
{                                    \
  __syscall_nr(4, type, name, arg1, arg2, arg3, arg4);        \
}

#define _syscall5(type,name,type1,arg1,type2,arg2,type3,arg3,type4,arg4,type5,arg5) \
type name(type1 arg1, type2 arg2, type3 arg3, type4 arg4, type5 arg5)    \
{                                    \
  __syscall_nr(5, type, name, arg1, arg2, arg3, arg4, arg5);    \
}    

يوضِّح جزء الشيفرة البرمجية السابق من ملف ترويسة النواة asm/unistd.h كيف يمكننا تطبيق استدعاءات النظام على نظام PowerPC، ويمكن أن يبدو الأمر معقدًا للغاية، ولكن لنشرحه خطوةً خطوة.

انتقل أولًا إلى نهاية المثال إلى تعريف وحدات الماكرو ‎_syscallN، إذ يمكنك رؤية أنّ هناك العديد من وحدات الماكرو ويأخذ كل منها وسيطًا آخر تدريجيًا، وسنركّز على أبسط إصدار وهو ‎_syscall0 للبدء به والذي لا يتطلب سوى وسيطَين هما نوع القيمة المُعادة لاستدعاء النظام مثل int أو char واسم استدعاء النظام، إذ يكون مع الاستدعاء getpid بالصورة ‎_syscall0(int,getpid)‎.

سنبدأ الآن بتفكيك الماكرو ‎__syscall_nr الذي لا يختلف عما كان عليه سابقًا، إذ سنأخذ عدد الوسائط على أنه المعامِل الأول ثم النوع والاسم والوسائط الفعلية، فالخطوة الأولى هي التصريح عن بعض الأسماء للمسجّلات، إذ يشير الاسم ‎__sc_0 إلى المسجّل r0 أي المسجّل 0، ويستخدِم المصرِّف Compiler المسجلات بالطريقة التي يريدها، لذلك يجب أن نعطيه قيودًا حتى لا يقرّر استخدام المسجّل الذي نحتاجه بطريقة مخصصة.

سنستدعي بعد ذلك sc_loadargs إلى جانب المعامِل ## الذي يُعَدّ أمر لصق يُستبدَل بالمتغير nr، وسنوسّعه إلى ‎__sc_loadargs_0(name, args);‎، ويمكننا رؤية ‎__sc_loadargs الذي يضبط ‎__sc_0 ليكون رقم استدعاء النظام، ولاحظ معامِل اللصق مرةً أخرى مع البادئة ‎__NR_‎ واسم المتغير الذي يشير إلى مسجّل معيّن، لذا تُستخدَم هذه الشيفرة البرمجية السابقة ذات المظهر الصعب لوضع رقم استدعاء النظام في المسجّل 0، كما يمكنك باتباع الشيفرة البرمجية السابقة رؤية أن وحدات الماكرو الأخرى ستضع وسائط استدعاء النظام في المسجّل r3 عبر المسجّل r7، ويمكنك فقط الحصول على 5 وسائط على أساس حد أقصى لاستدعاء النظام.

سنعالج الآن القسم __asm__، إذ لدينا هنا ما يسمّى بالتجميع المُضمَّن Inline Assembly لأنها شيفرة تجميع مختلطة مع الشيفرة المصدرية، وهذه الصيغة معقدة بعض الشيء، لذلك سنشير إلى الأجزاء المهمة منها فقط، كما عليك تجاهل البت __volatile__ حاليًا والذي يخبر المصرّف أنّ هذه الشيفرة البرمجية لا يمكن التنبؤ بها، لذا لا تحاول التعامل معها بذكاء، كما أنّ كل الأشياء الموجودة بعد النقطتين هي طريقة للتواصل مع المصرّف حول ما يفعله التجميع المضمَّن لمسجلات وحدة المعالجة المركزية، ويجب أن يعرِف المصرّف ذلك حتى لا يحاول استخدام أيّ من هذه المسجلات بطرق يمكنها التسبب في حدوث عطل.

لكن الجزء المهم هو وجود تعليمتَي التجميع في الوسيط الأول، إذ ينفِّذ الاستدعاء sc كل العمل، وهذا كل ما عليك تطبيقه لإجراء استدعاء نظام، وبالتالي يحدث ما يلي عند إجراء استدعاء النظام، إذ يعرف المعالِج المُقاطَع أنه يجب عليه نقل التحكم إلى جزء معيّن من إعداد الشيفرة البرمجية في وقت بدء تشغيل النظام لمعالجة المقاطعات، كما توجد هناك العديد من المقاطعات، وتُعَدّ استدعاءات النظام إحداها، وتبحث هذه الشيفرة البرمجية بعد ذلك في المسجل 0 للعثور على رقم استدعاء النظام، ثم تبحث في جدول لإيجاد الدالة الصحيحة للانتقال إليها لمعالجة استدعاء النظام، وتتلقى هذه الدالة وسائطها من المسجل 3 إلى المسجل 7.

يعود التحكم إلى التعليمة التالية بعد sc وهي في هذه الحالة تعليمات سور الذاكرة Memory Fence بمجرد تشغيل معالج استدعاء النظام واكتماله، كما تتأكد تعليمات سور الذاكرة من أن كل شيء ملتزم بالذاكرة، حيث تضمن هذه التعليمة أنّ كل ما نعتقد أنه مكتوب في الذاكرة قد حدث فعليًا دون المرور عبر خط أنابيب Pipeline في مكان ما.

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

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

استدعاءات نظام x86

إليك الواجهة المطبَّقة لمعالج x86:

‫/‫* ‫توجد أرقام الأخطاء المرئية للمستخدِم ضمن المجال من ‎-1 إلى ‎-124: راجع <asm-i386/errno.h> */

#define __syscall_return(type, res)                \
do {                                \
  if ((unsigned long)(res) >= (unsigned long)(-125)) {    \
    errno = -(res);                    \
    res = -1;                    \
  }                            \
  return (type) (res);                    \
} while (0)

‫/‫* ‫‎_foo يجب أن تكون __foo، بينما __NR_bar يمكن أن تكون _NR_bar */
#define _syscall0(type,name)            \
type name(void)                    \
{                        \
long __res;                    \
__asm__ volatile ("int $0x80"            \
  : "=a" (__res)                \
  : "0" (__NR_##name));            \
__syscall_return(type,__res);
}

#define _syscall1(type,name,type1,arg1)            \
type name(type1 arg1)                    \
{                            \
long __res;                        \
__asm__ volatile ("int $0x80"                \
  : "=a" (__res)                    \
  : "0" (__NR_##name),"b" ((long)(arg1)));    \
__syscall_return(type,__res);
}

#define _syscall2(type,name,type1,arg1,type2,arg2)            \
type name(type1 arg1,type2 arg2)                    \
{                                    \
long __res;                                \
__asm__ volatile ("int $0x80"                        \
  : "=a" (__res)                            \
  : "0" (__NR_##name),"b" ((long)(arg1)),"c" ((long)(arg2)));    \
__syscall_return(type,__res);
}

#define _syscall3(type,name,type1,arg1,type2,arg2,type3,arg3)        \
type name(type1 arg1,type2 arg2,type3 arg3)                \
{                                    \
long __res;                                \
__asm__ volatile ("int $0x80"                        \
  : "=a" (__res)                            \
  : "0" (__NR_##name),"b" ((long)(arg1)),"c" ((long)(arg2)),    \
                  "d" ((long)(arg3)));                    \
__syscall_return(type,__res);                        \
}

#define _syscall4(type,name,type1,arg1,type2,arg2,type3,arg3,type4,arg4)    \
type name (type1 arg1, type2 arg2, type3 arg3, type4 arg4)            \
{                                        \
long __res;                                    \
__asm__ volatile ("int $0x80"                            \
  : "=a" (__res)                                \
  : "0" (__NR_##name),"b" ((long)(arg1)),"c" ((long)(arg2)),        \
          "d" ((long)(arg3)),"S" ((long)(arg4)));                \
__syscall_return(type,__res);                            \
}

#define _syscall5(type,name,type1,arg1,type2,arg2,type3,arg3,type4,arg4,    \
          type5,arg5)                                \
type name (type1 arg1,type2 arg2,type3 arg3,type4 arg4,type5 arg5)        \
{                                        \
long __res;                                    \
__asm__ volatile ("int $0x80"                            \
  : "=a" (__res)                                \
  : "0" (__NR_##name),"b" ((long)(arg1)),"c" ((long)(arg2)),        \
          "d" ((long)(arg3)),"S" ((long)(arg4)),"D" ((long)(arg5)));        \
__syscall_return(type,__res);                            \
}

#define _syscall6(type,name,type1,arg1,type2,arg2,type3,arg3,type4,arg4,            \
          type5,arg5,type6,arg6)                                \
type name (type1 arg1,type2 arg2,type3 arg3,type4 arg4,type5 arg5,type6 arg6)            \
{                                                \
long __res;                                            \
__asm__ volatile ("push %%ebp ; movl %%eax,%%ebp ; movl %1,%%eax ; int $0x80 ; pop %%ebp"    \
  : "=a" (__res)                                        \
  : "i" (__NR_##name),"b" ((long)(arg1)),"c" ((long)(arg2)),                \
          "d" ((long)(arg3)),"S" ((long)(arg4)),"D" ((long)(arg5)),                \
          "0" ((long)(arg6)));                                    \
__syscall_return(type,__res);                                    \
}    

تختلف معمارية x86 كثيرًا عن PowerPC التي تحدّثنا عنها سابقًا، إذ يُصنَّف x86 على أنه معالِج من النوع CISC على عكس PowerPC الذي يُعَدذ من النوع RISC، ولديه مسجلات أقل بكثير.

اطّلع على أبسط ماكرو ‎_syscall0 الذي يستدعي تعليمة من النوع int والقيمة 0x80، إذ تعمل هذه التعليمة على جعل وحدة المعالجة المركزية ترفع المقاطعة 0x80 التي ستنتقل إلى الشيفرة البرمجية التي تعالج استدعاءات النظام في النواة.

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

تستند أسماء المسجّلات في معمارية x86 إلى الأحرف عوضًا عن أسماء المسجّلات الرقمية في PowerPC، إذ يمكننا رؤية من الماكرو عديم الوسائط أنّ المسجّل A يُحمَّل فقط، وبالتالي يمكننا القول أنّ رقم استدعاء النظام متوقَّع وجوده في المسجّل EAX، كما يمكنك رؤية أسماء المسجلات المختصرة في وسائط استدعاء __asm__ عندما نبدأ بتحميل المسجلات في وحدات الماكرو الأخرى.

لاحظ الماكرو ‎__syscall6 الذي يأخذ 6 وسائط، إذ تعمل التعليمتان push و pop مع المكدس في x86، بحيث تدفع إحداهما قيمةً إلى أعلى المكدس في الذاكرة وتسحب الأخرى القيمة من المكدس في الذاكرة، كما يجب تخزين قيمة المسجل ebp في الذاكرة ووضع الوسيط في التعليمة mov وإجراء استدعاء للنظام، ثم إعادة القيمة الأصلية إلى المسجل ebp في حالة وجود ستة مسجلات، كما يمكنك هنا رؤية عيوب عدم وجود مسجلات كافية، إذ يُعَدّ التخزين في الذاكرة باهظ الثمن، لذا كلما تمكنت من تجنّبها، كان ذلك أفضل.

لاحظ عدم وجود تعليمات سور الذاكرة التي رأيناها سابقًا مع PowerPC لأنّ معمارية x86 تضمن أن يكون تأثير جميع التعليمات مرئيًا عند اكتمالها، مما يسهّل البرمجة على المصرّف والمبرمج ولكنه يقلل من المرونة.

يوجد أيضًا اختلاف في القيمة المُعادة، فقد كان لدينا مسجّلَين مع قيم مُعادة من النواة في معمارية PowerPC، إحداهما هي القيمة والأخرى هي رمز الخطأ، في حين لدينا قيمة مُعادة واحدة في معمارية x86 تُمرَّر إلى الماكرو ‎__syscall_return الذي يغيّر نوع القيمة المُعادة إلى النوع unsigned long ويوازنها مع مجال من القيم السالبة تعتمد على المعمارية والنواة، حيث تمثّل هذه القيم رموز الخطأ.

لاحظ أنّ قيمة رمز الخطأ errno موجبة، مما يؤدي إلى إلغاء النتيجة السالبة من النواة، لكن يعني هذا أنّ استدعاءات النظام لا يمكنها إعادة قيم سالبة صغيرة، إذ لا يمكن تمييزها عن رموز الخطأ، كما تضيف بعض استدعاءات النظام التي لديها هذا المتطلب مثل الاستدعاء getpriority()‎ إزاحةً إلى القيمة المُعادة لإجبارها بأن تكون دائمًا موجبة، فالأمر متروك لمجال المستخدِم لإدراك ذلك وطرح هذه القيمة الثابتة للحصول على القيمة الحقيقية.

الصلاحيات

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

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

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

مستويات الصلاحيات

تُعَدّ حماية العتاد مجموعةً من الحلقات متحدة المركز حول مجموعة أساسية من العمليات.

مستويات الصلاحيات في معمارية x86

مستويات الصلاحيات في معمارية x86

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

يمكن لكل حلقة داخلية الوصول إلى أيّ تعليمات تحميها حلقة خارجية، ولكن لا يمكنها الوصول إلى تعليمة تحميها حلقة داخلية، كما لا تحتوي جميع المعماريات على مستويات متعددة من الحلقات كما في الشكل السابق، ولكن سيوفر معظمها على الأقل مستوى النواة Kernel ومستوى المستخدِم User.

نموذج الحماية 386

يحتوي نموذج الحماية 386 على أربع حلقات بالرغم من أن معظم أنظمة التشغيل مثل لينكس وويندوز تستخدِم حلقتَين فقط للحفاظ على التوافق مع المعماريات الأخرى التي تسمح الآن بأكبر عدد من مستويات الحماية المنفصلة، كما يحتفظ النموذج 386 بالصلاحيات من خلال أن يكون لكل جزء من شيفرة التطبيق البرمجية المُشغَّلة في النظام واصف صغير يسمى واصف الشيفرة البرمجية Code Descriptor الذي يصِف مستوى صلاحياتها.

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

رفع مستوى الصلاحيات

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

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

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

استدعاءات النظام السريعة

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

يتطلب فهم آلية بوابة الاستدعاءات الموضحة سابقًا التدقيق في مخطط التقطيع المبتكر والمعقد الذي يستخدمه المعالج، وقد كان السبب الأصلي لتطبيق التقطيع هو القدرة على استخدام أكثر من 16 بِتًا متوفرًا في المسجل لعنوان ما كما هو موضح في الشكل التالي:

تقطيع العنونة Segmentation Addressing في معمارية x86

تقطيع العنونة Segmentation Addressing في معمارية x86: يؤدي التقطيع إلى توسيع مساحة عناوين المعالج من خلال تقسيمه إلى أجزاء. يحتفظ المعالج بمسجلات مقاطع خاصة، ويمكن تحديد العناوين من خلال مسجّل المقطع والإزاحة. تُضاف قيمة مسجل المقطع إلى جزء الإزاحة للعثور على العنوان النهائي.

بقي مخطط التقطيع كما هو ولكن بتنسيق مختلف عندما انتقلت معمارية x86 إلى مسجّلات بحجم 32 بِتًا، إذ يُسمَح للمقاطع بأن تكون بأيّ حجم بدلًا من استخدام أحجام ثابتة، ويجب أن يتعقّب المعالج كل هذه المقاطع المختلفة وأحجامها، وهو ما يفعله باستخدام الواصفات Descriptors.

تكون واصفات المقاطع المتاحة للجميع محفوظةً في جدول الواصفات العام Global Descriptor Table أو GDT اختصارًا، كما تحتوي كل عملية على عدد من المسجلات التي توشّر إلى مدخلات في جدول GDT، وهذه المدخلات هي المقاطع التي يمكن للعملية الوصول إليها، كما توجد جداول واصفات محلية، وتتفاعل جميعها مع مقاطع حالة المهمات، لكنها ليست مهمةً حاليًا.

مقاطع x86

مقاطع x86: لاحظ كيف يمر الاستدعاء البعيد عبر بوابة الاستدعاءات التي توجّهه إلى مقطع من الشيفرة يعمل على مستوى الحلقة الأدنى. الطريقة الوحيدة لتعديل محدّد مقطع الشيفرة -المستخدَم ضمنيًا لجميع عناوين الشيفرة- هي استخدام آلية الاستدعاء، حيث تضمن آلية بوابة الاستدعاءات اختيار واصف مقطع جديد، مما يؤدي إلى تغيير مستويات الحماية التي يجب عليك الانتقال إليها عبر نقطة دخول معروفة.

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

إذا احتاج تشغيل الشيفرة البرمجية إلى إجراء استدعاءات إلى شيفرة موجودة في مقطع آخر، فستطبّق معمارية x86 ذلك كما في الحلقات Rings، إذ تكون الحلقة 0 هي الحلقة ذات الإذن الأعلى والحلقة 3 هي الأدنى، ويمكن للحلقات الداخلية الوصول إلى الحلقات الخارجية ولكن ليس العكس.

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

يسمح ذلك بتسلسل هرمي كامل للمقاطع والأذونات، ولاحظ أنّ استدعاء المقطع العرضي يشبه استدعاء النظام، فإذا سبق لك أن شاهدت لغة تجميع لينكس x86، فالطريقة القياسية لإجراء استدعاء النظام هي باستخدام int 0x80 التي ترفع المقاطعة 0x80، إذ توقِف المقاطعة المعالج وتنتقل إلى بوابة المقاطعات التي تعمل بعد ذلك بطريقة بوابة الاستدعاءات نفسها بحيث تغيّر مستوى الصلاحيات وتعيدك إلى منطقة أخرى من الشيفرة البرمجية.

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

لا يُستخدَم نظام الحلقات ذو المستويات الأربعة في تقطيع نظام x86 الحديث بفضل الذاكرة الوهمية، والشيء الوحيد الذي يحدث فعليًا مع تبديل التقطيع هو استدعاءات النظام التي تتحوّل من الوضع 3 -أي مجال المستخدِم- إلى الوضع 0 وتقفز إلى شيفرة معالج استدعاء النظام في النواة.

يوفّر المعالِج تعليمات استدعاء نظام فائقة السرعة تسمى sysentersysexit للعودة)، إذ تسرّع هذه التعليمات العملية برمتها عبر الاستدعاء int 0x80 من خلال إزالة الطبيعة العامة للاستدعاء البعيد، أي إمكانية الانتقال إلى أيّ مقطع في أيّ مستوى حلقة، وتقييد الاستدعاء للانتقال فقط إلى شيفرة الحلقة 0 في مقطع معيّن مع الإزاحة كما هي مخزّنة في المسجلات.

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

هناك طرق أخرى للتواصل مع النواة مثل ioctl وأنظمة الملفات مثل proc و sysfs و debugfs وغير ذلك.

ترجمة -وبتصرُّف- للقسمين System Calls و Privileges من الفصل The Operating System من كتاب Computer Science from the Bottom Up لصاحبه Ian Wienand.

اقرأ أيضًا


تفاعل الأعضاء

أفضل التعليقات

لا توجد أية تعليقات بعد



انضم إلى النقاش

يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.

زائر
أضف تعليق

×   لقد أضفت محتوى بخط أو تنسيق مختلف.   Restore formatting

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   جرى استعادة المحتوى السابق..   امسح المحرر

×   You cannot paste images directly. Upload or insert images from URL.


×
×
  • أضف...