xv6 — نظام يونكس تعليمي › تنظيم نظام التشغيل 3 / 15 السابق التالي

تنظيم نظام التشغيل

2. تنظيم نظام التشغيل

شرطٌ أساسيّ لنظام التشغيل هو دعم عدّة أنشطةٍ في وقتٍ واحد. فقد يستخدم المرء نداءَي النظام fork وexec الواردين في الفصل 1 لتشغيل مصرِّفٍ ومحرّر نصوصٍ بوصفهما عمليّتين. فيتعيّن على نظام التشغيل أن يُشارك مواردٍ مثل وحدات المعالجة والذاكرة بين هذه العمليّات زمنيًّا (time-share). وينبغي لنظام التشغيل أيضًا أن يرتّب للعزل (isolation) بين العمليّات. فإن كان في عمليّةٍ خللٌ فأخذت تسلك سلوكًا خاطئًا، فلا ينبغي أن يتأثّر بذلك عمليّاتٌ لا علاقة لها بها. أمّا العزل التامّ فهو مطلوبٌ مبالغٌ فيه، إذ لا بدّ أن تتاح للعمليّات إمكانيّة التفاعل المقصود؛ وخطوط الأنابيب (pipelines) مثالٌ على ذلك. لذا ينبغي لنظام التشغيل أن يحقّق ثلاثة متطلّبات: المشاركة الزمنيّة، والعزل، والتفاعل.

ويقدّم هذا الفصل نظرةً عامّةً على كيفيّة تنظيم نظم التشغيل لتحقيق هذه المتطلّبات الثلاثة. ويتّضح أنّ ثمّة طرائق كثيرة لفعل ذلك، لكنّ هذا النصّ يركّز على التصاميم السائدة المتمحورة حول النواة الأحاديّة (monolithic kernel)، وهي التي تستخدمها كثيرٌ من نظم تشغيل Unix. ويستعرض هذا الفصل أيضًا نظرةً عامّةً على عمليّة xv6، وهي وحدة العزل في xv6.

يعمل xv6 على معالج RISC-V متعدّد الأنوية[1]، وكثيرٌ من وظائفه منخفضة المستوى (مثل تنفيذ العمليّات) خاصٌّ به. وهذا المعالج 64-بتّ، وxv6 مكتوبٌ بلغة C وفق «LP64»، ما يعني أنّ long والمؤشّرات في تلك اللغة عرضهما 64 بتّ، أمّا int فعرضها 32 بتّ. ويفترض هذا الكتاب أنّ القارئ قد مارس قدرًا من البرمجة على مستوى الآلة في بنيةٍ ما، وسيُقدّم ما يخصّ تلك البنية عند وروده. ووثيقتا بنية مجموعة التعليمات للمستخدم (ووترمان وأسانوفيتش 2024) والبنية ذات الامتياز (ووترمان وآخرون 2024) هما المواصفتان الكاملتان، ويجوز الرجوع أيضًا إلى «أطلس قارئ RISC-V: بنية مفتوحة» (باترسون وووترمان 2017).

تحيط بوحدة المعالجة في الحاسوب الكامل عتادةٌ مساندة، كثيرٌ منها في صورة واجهات إدخالٍ وإخراج. وxv6 مكتوبٌ للعتادة المساندة التي يحاكيها خيار qemu ‏"-machine virt". وتشمل هذه ذاكرة الوصول العشوائي (RAM)، وذاكرة قراءة فقط (ROM) تحتوي شيفرة الإقلاع، واتصالًا تسلسليًّا بلوحة مفاتيح المستخدم وشاشته، وقرصًا للتخزين.

2.1 تجريد الموارد الفيزيائيّة

أوّل سؤالٍ قد يطرحه المرء عند مواجهة نظام تشغيلٍ هو: لماذا نملكه أصلًا؟ أي إنّ نداءات النظام الواردة في الشكل 1.2 يُمكن تنفيذها بصورة مكتبةٍ تربط بها التطبيقات. وفي هذا المخطّط، قد يكون لكلّ تطبيقٍ مكتبته الخاصّة المخصّصة لاحتياجاته. ويمكن للتطبيقات أن تتفاعل مباشرةً مع موارد العتاد وأن تستخدم تلك الموارد بأفضل طريقةٍ للتطبيق (مثلًا لتحقيق أداءٍ مرتفعٍ أو متوقّعٍ). وبعض نظم التشغيل الخاصّة بالأجهزة المدمجة أو بأنظمة الزمن الحقيقيّ مُنظَّمةٌ بهذه الطريقة.

أمّا عيب هذا الأسلوب المكتبيّ فهو أنّه، إذا كان أكثر من تطبيقٍ يعمل، فيتعيّن على التطبيقات أن تسلك سلوكًا حسنًا. فمثلًا، ينبغي لكلّ تطبيقٍ أن يتنازل دوريًّا عن وحدة المعالجة حتّى تستطيع التطبيقات الأخرى العمل. وقد يكون هذا المخطّط التشاركيّ (cooperative) للمشاركة الزمنيّة مقبولًا إذا كانت جميع التطبيقات تثق ببعضها ولا تحوي عللًا. لكنّ الأكثر شيوعًا ألّا تثق التطبيقات ببعضها وأن تحوي عللًا، لذا يُرغَب غالبًا في عزلٍ أقوى ممّا يوفّره مخطّطٌ تشاركيّ.

ولتحقيق عزلٍ قويٍّ يفيد منع التطبيقات من الوصول المباشر إلى موارد العتاد الحسّاسة، وتجريد الموارد إلى خدماتٍ بدلًا من ذلك. فمثلًا، تتفاعل تطبيقات Unix مع التخزين عبر نداءات نظام الملفّات open وread وwrite وclose فحسب، بدلًا من قراءة القرص وكتابته مباشرةً. وهذا يمنح التطبيق راحة أسماء المسارات، كما يتيح لنظام التشغيل (الذي يوفّر الواجهة) إدارة القرص. وحتّى إذا لم يكن العزل مبعثَ قلقٍ، فمن المرجّح أن تجد البرامج التي تتفاعل مقصودًا (أو تريد فقط ألّا يتعارض مسارها مع مسار غيرها) نظام الملفّات تجريدًا أرخص من الاستخدام المباشر للقرص.

وعلى نحوٍ مماثل، تبدّل Unix شفّافةً وحدات المعالجة العتاديّة بين العمليّات، فتحفظ حالة المسجّلات وتستعيدها بحسب الحاجة، حتّى لا يضطرّ التطبيقات إلى إدراك المشاركة الزمنيّة. وتتيح هذه الشفافيّة لنظام التشغيل مشاركة وحدات المعالجة حتّى لو كانت بعض التطبيقات في حلقاتٍ لا نهائيّة.

ومثالٌ آخر، تستخدم عمليّات Unix النداء exec لبناء صورة ذاكرتها، بدلًا من التفاعل المباشر مع الذاكرة الفيزيائيّة. ويتيح هذا لنظام التشغيل تحديد موضع وضع العمليّة في الذاكرة؛ فإن كانت الذاكرة ضيّقةً، فقد يخزّن نظام التشغيل بعض بيانات العمليّة على القرص حتّى. ويمنح exec المستخدمين أيضًا راحة نظام ملفّاتٍ لتخزين صور البرامج التنفيذيّة.

وتحدث صورٌ كثيرةٌ من التفاعل بين عمليّات Unix بواسطة واصفات الملفّات. فواصفات الملفّات لا تُجرّد تفاصيلَ كثيرةً فحسب (مثل موضع تخزين البيانات في أنبوبٍ أو ملفّ)، بل هي معرَّفةٌ أيضًا على نحوٍ يُيسّر التفاعل. فمثلًا، إذا خرج تطبيقٌ في خطّ أنابيبٍ أو فشل، فإنّ النواة تُنشئ تلقائيًّا إشارة نهاية ملفّ للعمليّة التالية في خطّ الأنابيب.

وواجهة نداء النظام في الشكل 1.2 مصمّمةٌ بعنايةٍ لتوفير راحة المبرمج وإمكانيّة العزل القويّ معًا. وليست واجهة Unix الطريقة الوحيدة لتجريد الموارد، لكنّها أثبتت أنّها طريقةٌ جيّدة.

2.2 وضع المستخدم، ووضع المشرف، ونداءات النظام

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

توفّر وحدات المعالجة دعمًا عتاديًّا للعزل القويّ. فمثلًا، لـRISC-V ثلاثة مستوياتٍ من الصلاحيّات تُقيّد ما تستطيع الشيفرة فعله: وضع الآلة، ووضع المشرف، ووضع المستخدم. والتعليمات المنفّذة في وضع الآلة تمتلك الصلاحيّة الكاملة؛ وتبدأ وحدة المعالجة في وضع الآلة. ووضع الآلة موجّهٌ في الأغلب لتهيئة الحاسوب أثناء الإقلاع. وتنفّذ xv6 في وضع الآلة لفترةٍ وجيزة ثمّ تنتقل إلى وضع المشرف.

في وضع المشرف يُسمح لوحدة المعالجة بتنفيذ التعليمات ذات الصلاحيّة (privileged instructions): مثل تمكين المقاطعات وتعطيلها، وقراءة المسجّل الذي يحمل عنوان جدول الصفحات والكتابة إليه، وغير ذلك. وإذا حاول تطبيقٌ في وضع المستخدم تنفيذ تعليمةٍ ذات صلاحيّة، فإنّ وحدة المعالجة لا تنفّذ تلك التعليمة، بل تقع في المصيدة لتنتقل إلى شيفرةٍ خاصّةٍ في وضع المشرف تستطيع إنهاء التطبيق. ويوضّح الشكل 1.1 في الفصل 1 هذا التنظيم. فالتطبيق لا يستطيع تنفيذ إلّا تعليمات وضع المستخدم (مثل جمع الأعداد وغيرها) ويُقال إنّه يعمل في فضاء المستخدم (user space)، بينما البرمجيّة العاملة في وضع المشرف تستطيع تنفيذ التعليمات ذات الصلاحيّة أيضًا ويُقال إنّها تعمل في فضاء النواة (kernel space). والبرمجيّة العاملة في فضاء النواة (أو في وضع المشرف) تُسمّى النواة (kernel).

وتتفاعل التطبيقات مع النواة عبر نداءات النظام مثل read. ولا يُسمح للتطبيقات بمناداة دوالّ النواة مباشرةً أو بالوصول إلى ذاكرة النواة. وتوفّر RISC-V تعليمة ecall لنداءات النظام؛ فهي تُحوّل وحدة المعالجة من وضع المستخدم إلى وضع المشرف وتقفز إلى نقطة دخولٍ تحدّدها النواة. ومتى انتقلت وحدة المعالجة إلى وضع المشرف، تستطيع النواة حينئذٍ التحقّق من صلاحيّة معطيات نداء النظام (مثل التحقّق ممّا إذا كان العنوان الممرَّر إلى نداء النظام جزءًا من ذاكرة التطبيق)، وتقرير ما إذا كان التطبيق مسموحًا له بأداء العمليّة المطلوبة (مثل التحقّق مما إذا كان مسموحًا له بكتابة الملفّ المحدّد)، ثمّ تمنعه أو تنفّذه. ومن المهمّ أن تتحكّم النواة في نقطة الدخول للانتقال إلى وضع المشرف؛ فلو كان التطبيق يقدر على تحديد نقطة دخول النواة، لتسنّى لتطبيقٍ خبيثٍ مثلًا أن يدخل النواة في نقطةٍ يُتجاوز فيها التحقّق من المعطيات.

2.3 تنظيم النواة

وسؤالٌ تصميميٌّ رئيس هو: أيّ جزءٍ من نظام التشغيل ينبغي أن يعمل في وضع المشرف؟ ويحتمل أن يقيم نظام التشغيل بأكمله في النواة، بحيث تُنفَّذ تطبيقات جميع نداءات النظام في وضع المشرف. ويُسمّى هذا التنظيم النواة الأحاديّة (monolithic kernel).

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

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

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

الشكل 2.1: نواة صغريّة مع خادم لنظام ملفّات

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

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

وفي العالم الواقعيّ، تحظى النوى الأحاديّة والنوى الصغريّة بالشعبية على حدٍّ سواء. وكثيرٌ من نوى Unix أحاديّة. فمثلًا، لـLinux نواةٌ أحاديّة، وإن كانت بعض دوالّ نظام التشغيل تعمل بوصفها خوادمَ على مستوى المستخدم (مثل نظام النوافذ). وتُقدّم Linux أداءً رفيعًا للتطبيقات كثيفة الاستخدام لنظام التشغيل، جزئيًّا لأنّ نظم النواة الفرعيّة يُمكن دمجها بإحكام.

وتُنظَّم نظم تشغيلٍ مثل Minix وL4 وQNX في صورة نواةٍ صغريّةٍ مع خوادم، وقد شاع انتشارها في الأوساط المدمجة. وأحد أشكال L4، وهو seL4، صغيرٌ بصورةٍ تكفي للتحقّق من أمان الذاكرة ومن خصائص أمنيّةٍ أخرى (كلاين وآخرون 2009).

وثمّة جدلٌ كثيرٌ بين مطوّري نظم التشغيل حول أيّ التنظيمين أفضل، لكن ليست ثمّة أدلّة قاطعة على هذا أو ذاك. وفوق ذلك، فإنّ الأمر يعتمد كثيرًا على ما يعنيه «أفضل»: أداءٌ أسرع، وحجمُ شيفرةٍ أصغر، وموثوقيّة النواة، وموثوقيّة نظام التشغيل الكامل (بما فيه الخدمات على مستوى المستخدم)، إلى آخره.

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

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

وxv6 منفَّذٌ بوصفه نواةً أحاديّةً، مثل معظم نظم تشغيل Unix. لذا تُقابل واجهة نواة xv6 واجهة نظام التشغيل، وتُنفّذ النواة نظام التشغيل الكامل. وحيث إنّ xv6 لا توفّر خدماتٍ كثيرةً، فإنّ نواتها أصغر من بعض النوى الصغريّة، لكنّ xv6 أحاديّةٌ من حيث المفهوم.

2.4 شيفرة: تنظيم xv6

الشكل 2.2: ملفّات شيفرة xv6 المصدرية.

الملفّ الوصف
الإقلاع entry.S أوّل تعليمات الإقلاع.
main.c ضبط تهيئة الوحدات الأخرى.
start.c شيفرة إقلاع أوّليّة في وضع الآلة.
العمليّات exec.c نداء النظام exec().
proc.c العمليّات والجدولة.
swtch.S تبديل الخيوط.
sysproc.c نداءات النظام المرتبطة بالعمليّات.
المصائد kernelvec.S معالجة المصائد الآتية من شيفرة النواة.
trampoline.S معالجة المصائد الآتية من شيفرة المستخدم.
trap.c شيفرة C لمعالجة المصائد والمقاطعات والعودة منها.
syscall.c توزيع نداءات النظام على دوالّ معالجتها.
الذاكرة vm.c إدارة جداول الصفحات وأفضية العناوين.
kalloc.c مخصّص الصفحات الفيزيائيّة.
الأجهزة console.c الاتصال بلوحة مفاتيح المستخدم وشاشته.
plic.c متحكّم مقاطعات RISC-V.
printk.c المخرجات المنسّقة إلى وحدة التحكّم.
uart.c مشغّل جهاز وحدة التحكّم التسلسليّة.
virtio_disk.c مشغّل جهاز القرص.
نظام الملفّات bio.c مخزن بلوكات القرص المؤقّت لنظام الملفّات.
file.c دعم واصفات الملفّات.
fs.c نظام الملفّات.
log.c تسجيل نظام الملفّات واستعادة ما بعد الانهيار.
sysfile.c نداءات النظام المرتبطة بالملفّات.
pipe.c الأنابيب.
متفرّقات sleeplock.c أقفال تُسلّم وحدة المعالجة.
spinlock.c أقفال لا تُسلّم وحدة المعالجة.
string.c مكتبة السلاسل ومصفوفات البايتات في C.

وشيفرة xv6 المصدرية في المجلّد الفرعيّ kernel/. ويعرض الشكل 2.2 الملفّات، مقسّمةً إلى مجالات المسؤوليّة الرّئيسة للنواة: بدء النظام (الإقلاع)، وإنشاء العمليّات وضبطها، ومعالجة المصائد (المقاطعات ونداءات النظام)، وتخصيص الذاكرة وضبط العناوين الافتراضيّة، والتحكّم في الأجهزة، وإدارة نظام الملفّات.

2.5 نظرة عامّة على العمليّة

ووحدة العزل في xv6 (كما في نظم تشغيل Unix الأخرى) هي العمليّة (process). ويمنع تجريد العمليّة عمليّةً من أن تُتلف ذاكرة عمليّةٍ أخرى أو تتجسّس عليها، أو وحدة معالجتها، أو واصفات ملفّاتها، إلى آخره. كما يمنع عمليّةً من إتلاف النواة ذاتها، حتّى لا تقدر عمليّةٌ على تقويض آليّات العزل في النواة. وينبغي للنواة أن تنفّذ تجريد العمليّة بعنايةٍ لأنّ تطبيقًا فيه خللٌ أو خبيثًا قد يخدع النواة أو العتاد ليفعل أمرًا سيّئًا (مثل التحايل على العزل). وتشمل الآليّات التي تستخدمها النواة لتنفيذ العمليّات راية وضع المستخدم/المشرف، وأفضية العناوين، وتقسيم زمن الخيوط.

وللمساعدة في فرض العزل، يمنح تجريد العمليّة البرنامج وهمَ أنّه يملك آلةً خاصّةً به. فتُوفّر العمليّة للبرنامج ما يبدو أنّه نظام ذاكرةٍ خاصّ، أو فضاء عناوين (address space)، لا تستطيع العمليّات الأخرى قراءته أو الكتابة فيه. وتوفّر العمليّة للبرنامج أيضًا ما يبدو أنّه وحدة معالجةٍ خاصّةٌ به لتنفيذ تعليمات البرنامج.

تستخدم xv6 جداول الصفحات (التي ينفّذها العتاد) لتمنح كلّ عمليّةٍ فضاء عناوينها الخاصّ. وجدول صفحات RISC-V يترجم (أو «يعيّن») عنوانًا افتراضيًّا (virtual address) (أي العنوان الذي تعالجه تعليمةُ RISC-V) إلى عنوانٍ فيزيائيّ (physical address) (أي العنوان الذي تُرسله وحدة المعالجة إلى الذاكرة الرئيسة).

الشكل 2.3: تخطيط فضاء العناوين الافتراضيّ لعمليّة

تحتفظ xv6 بجدول صفحاتٍ منفصلٍ لكلّ عمليّةٍ يُعرّف فضاء عناوين تلك العمليّة. فكما يوضّح الشكل 2.3، يتضمّن فضاء العناوين ذاكرة المستخدم الخاصّة بالعمليّة بدءًا من العنوان الافتراضيّ صفر. وتأتي التعليمات أوّلًا، تتبعها المتغيّرات العامّة، ثمّ المكدّس، وأخيرًا منطقة «الكومة» التي تخصّ malloc والتي تستطيع العمليّة توسيعها بحسب الحاجة. وثمّة عدّة عوامل تحدّ من الحجم الأقصى لفضاء عناوين عمليّةٍ: المؤشّرات في RISC-V عرضها 64 بتّ؛ ولا يستخدم العتاد إلّا الـ39 بتًّا المنخفضة عند البحث عن العناوين الافتراضيّة في جداول الصفحات؛ وxv6 تستخدم 38 من تلك الـ39 بتّ فقط. إذن، العنوان الأقصى هو 238−12^{38}-1 = 0x3fffffffff، وهو MAXVA. وفي قمّة فضاء العناوين تضع xv6 صفحة ترامبولين (4096 بايتًا) وصفحة إطار المصيدة. وتستخدم xv6 هاتين الصفحتين للانتقال إلى النواة والعودة؛ فصفحة ترامبولين تحتوي الشيفرة اللازمة للانتقال إلى النواة والخروج منها، وإطار المصيدة هو الموضع الذي تحفظ فيه النواة مسجّرات المستخدم الخاصّة بالعمليّة، كما يشرح الفصل 4.

وتحتفظ نواة xv6 بالكثير من أجزاء الحالة لكلّ عمليّةٍ، تجمعها في struct proc. وأهمّ أجزاء حالة النواة لعمليّةٍ هي جدول صفحاتها، ومكدّس نواتها، وحالة تشغيلها. وسنستخدم الترميز p->xxx للإشارة إلى عناصر بنية proc؛ فمثلًا، p->pagetable مؤشّرٌ إلى جدول صفحات العمليّة.

وعند هذه النقطة، يُرجّى قراءة kernel/proc.h، الذي يعرّف struct proc. فإنّ شيفرة xv6 أهمّ لأن تُفهم من هذا الكتاب؛ فينبغي أن تُقدّم الشيفرة، وأن تراجع هذا الكتاب بحسب الحاجة لتوضيح الشيفرة. وقد لا يكون الغرض من بعض الشيفرة ظاهرًا في البدء، لكنّ المطالعة والبحث أكثر في الشيفرة سيساعدان. ولا تتردّد في استكشاف الشيفرة وتعديلها.

لكلّ عمليّةٍ خيطُ ضبطٍ (أو خيط (thread) اختصارًا) يحمل الحالة اللازمة لتنفيذ العمليّة. وفي أيّ لحظةٍ، قد يكون الخيط منفّذًا على وحدة معالجة، أو مُعلّقًا (لا ينفّذ، لكنّه قادرٌ على استئناف التنفيذ مستقبلًا). ولتبديل وحدة معالجةٍ بين العمليّات، تُعلّق النواة الخيط العامل حاليًّا على تلك الوحدة وتحفظ حالته، وتستعيد حالة خيطٍ كان مُعلّقًا سابقًا لعمليّةٍ أخرى. وكثيرٌ من حالة الخيط (المتغيّرات المحليّة، وعناوين عودة نداءات الدوالّ) مخزونٌ في مكدّسات الخيط. ولكلّ عمليّةٍ مكدّسان: مكدّس مستخدمٍ ومكدّس نواة (p->kstack). وحين تنفّذ العمليّة تعليمات المستخدم، لا يُستخدم سوى مكدّس مستخدمها، ويكون مكدّس نواتها فارغًا. وحين تدخل العمليّة النواة (لنداء نظامٍ أو لمقاطعة)، تنفّذ شيفرة النواة على مكدّس نواة العمليّة؛ وبينما العمليّة في النواة، يزال مكدّس مستخدمها يحتوي بياناتٍ محفوظة، لكنّه لا يُستخدم بنشاط. وخيط العمليّة يتناوب بين الاستخدام النشط لمكدّس مستخدمها ومكدّس نواتها. ومكدّس النواة منفصلٌ (ومحميٌّ من شيفرة المستخدم) حتّى تستطيع النواة التنفيذ حتّى لو أتلفت عمليّةٌ مكدّس مستخدمها.

ويستطيع شيفرة مستخدم العمليّة إجراء نداء نظامٍ بتنفيذ تعليمة RISC-V ‏ecall. وهذه التعليمة تنتقل إلى وضع المشرف وتغيّر عدّاد البرنامج إلى نقطة دخولٍ تحدّدها النواة. والشيفرة عند نقطة الدخول تنتقل إلى مكدّس نواة العمليّة وتنفّذ تعليمات النواة التي تنفّذ نداء النظام. وحين يكتمل نداء النظام، تعود النواة إلى فضاء المستخدم بتنفيذ تعليمة sret، التي تنتقل إلى وضع المستخدم وتستأنف تنفيذ تعليمات المستخدم بعد تعليمة نداء النظام مباشرةً. ويستطيع خيط العمليّة أن يحجب نفسه في النواة انتظارًا للإدخال والإخراج، وأن يستأنف من حيث توقّف حين ينتهي الإدخال والإخراج.

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

ويحمل p->pagetable جدول صفحات العمليّة، بالصيغة التي يتوقّعها عتاد RISC-V. وتجعل xv6 عتاد الصفحات يستخدم p->pagetable عمليّةٍ ما عند تنفيذ تلك العمليّة في فضاء المستخدم. ويخدم جدول صفحات العمليّة أيضًا بوصفه سجلًّا لعناوين الصفحات الفيزيائيّة المخصّصة لتخزين ذاكرة العمليّة.

وباختصار، تجمع العمليّة فكرتي تصميمٍ اثنتين: فضاء عناوينٍ يمنح العمليّة وهم ذاكرتها الخاصّة، وخيطٌ يمنح العمليّة وهم وحدة معالجتها الخاصّة. وفي xv6، تتكوّن العمليّة من فضاء عناوينٍ واحدٍ وخيطٍ واحد. أمّا في نظم التشغيل الواقعيّة فقد يكون للعمليّة أكثر من خيطٍ للاستفادة من وحدات معالجةٍ متعدّدة.

2.6 شيفرة: إقلاع xv6، والعمليّة الأولى ونداء النظام الأوّل

ولجعل xv6 أكثر حسّيّةً، سنُوضّح موجزًا كيف تُقلِع النواة وتُشغّل العمليّة الأولى. وستصف الفصول التالية الآليّات التي تظهر في هذا الاستعراض بتفصيلٍ أكبر. ويُرجّى قراءة kernel/entry.S، وkernel/start.c، وkernel/main.c، وuser/init.c.

وحين يُشغَّل حاسوب RISC-V، فإنّه يُهيّئ نفسه ويُشغّل مُحمّل إقلاعٍ مخزونًّا في ذاكرة قراءة فقط. وينسخ محمّل الإقلاع نواة xv6 إلى الذاكرة عند العنوان الفيزيائيّ 0x80000000. وسبب وضع النواة عند 0x80000000 بدلًا من 0x0 هو أنّ مجال العناوين 0x0:0x80000000 يحتوي أجهزة إدخالٍ وإخراج.

ثمّ يقفز محمّل الإقلاع إلى xv6 بدءًا من _entry. ويبدأ RISC-V وعتاد الصفحات مُعطَّلًا: فالعناوين الافتراضيّة تُقابَل مباشرةً بالعناوين الفيزيائيّة. والتعليمات في _entry تُهيّئ مكدّسًا حتّى تستطيع xv6 تشغيل شيفرة C. وتُعلن xv6 حيّزًا لهذا المكدّس، stack0، في الملفّ start.c. والشيفرة في _entry تحمّل مسجّل مؤشّر المكدّس sp بالعنوان stack0+4096، أي قمّة المكدّس، لأنّ المكدّس في RISC-V ينمو نحو الأسفل. والآن، بعد أن صار للنواة مكدّسٌ، ينادي _entry شيفرة C عند start.

وتؤدّي الدالّة start بعض التهيئة التي لا يسمح بها وحدة المعالجة إلّا في وضع الآلة، وأهمّها برمجة رقاقة الساعة لتوليد مقاطعات المؤقّت. ثمّ تستخدم start تعليمة RISC-V ‏mret للانتقال إلى وضع المشرف والقفز إلى main. وmret تتطلّب بعض التهيئة: فتضبط start وضع الصلاحيّة السابق على المشرف في المسجّل mstatus، وتضبط عنوان الوجهة على main بكتابة عنوان main في المسجّل mepc، وتعطّل ترجمة العناوين الافتراضيّة في وضع المشرف بكتابة 0 في مسجّل جدول الصفحات satp، وتُفوّض جميع المقاطعات والاستثناءات إلى وضع المشرف.

وبعد أن تُهيّئ main عدّة أجهزةٍ وأنظمةٍ فرعيّة، تنشئ العمليّة الأولى بمناداة userinit. وجميع العمليّات المنشأة حديثًا تبدأ تنفيذها في النواة في forkret. وكاستثناءٍ خاصّ للعمليّة الأولى، ينادي forkret النداء kexec لتحميل برنامج المستخدم /init.

وبعد مناداة kexec، يعود forkret إلى فضاء المستخدم في عمليّة /init. فيُنشئ init ملفّ جهاز وحدة تحكّمٍ جديدًا إن لزم، ثمّ يفتحه بوصفه واصفات الملفّات 0، و1، و2. ثمّ يبدأ صدفةً على وحدة التحكّم. وعندها يكون النظام قد أقلع.

2.7 نموذج الأمان

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

فعلى نظام التشغيل أن يفترض أنّ شيفرة مستوى المستخدم في العمليّة ستبذل قصارى جهدها لإتلاف النواة أو العمليّات الأخرى. فقد تحاول شيفرة المستخدم الإشارة إلى مؤشّرات خارج فضاء عناوينها المسموح به؛ وقد تحاول تنفيذ تعليمات غير موجّهة لشيفرة المستخدم؛ وقد تحاول قراءة مسجّلات ضبط RISC-V والكتابة إليها؛ وقد تحاول الوصول إلى عتاد الأجهزة؛ وقد تمرّر قيمًا ماكرة إلى نداءات النظام في محاولةٍ لخداع النواة حتّى تنهار أو تفعل أمرًا سخيفًا.

وهدف النواة هو تقييد كلّ عمليّة مستخدمٍ بحيث لا تستطيع الوصول إلّا إلى ذاكرة مستخدمها هي، واستخدام مسجّرات RISC-V العامّة الـ32، والتأثير في النواة والعمليّات الأخرى بالطرق التي يُراد بنداءات النظام السماح بها. ويجب على النواة أن تمنع أيّ أفعالٍ أخرى. وهذه متطلّباتٌ مطلقة عادةً في تصميم النواة.

أمّا توقّعات الشيفرة الخاصّة بالنواة فهي مختلفة. فيُفترض بشيفرة النواة أنّها مكتوبةٌ على يد مبرمجين حَسِني النيّة ودقيقين، وأنّها خاليةٌ من العلل، ولا تحتوي شيئًا خبيثًا. ويؤثّر هذا الافتراض في كيفيّة تحليلنا لشيفرة النواة. فمثلًا، ثمّة دوالّ نواةٍ داخليّةٍ كثيرة (مثل الأقفال الدورانيّة) كانت ستُسبّب مشكلاتٍ جسيمة لو استُخدمت استعمالاً خاطئًا في شيفرة النواة. لكنّنا نفترض أنّ النواة تستخدم دوالّها هي استعمالًا صحيحًا. وعلى مستوى العتاد، يُفترض بوحدة معالجة RISC-V، وذاكرة الوصول العشوائي، والقرص، إلى آخره، أنّها تعمل كما هو مُعلنٌ في الوثائق، بلا عللٍ عتاديّة.

والواقع ليس بهذه البساطة. فمن الصعب منع برامج المستخدم المسيئة من مناداة نداءات النظام بصورةٍ تجعل النظام غير قابلٍ للاستخدام فهو يستهلك موارد محميّةً بالنواة: مساحة القرص، وزمن وحدة المعالجة، وخانات جدول العمليّات، إلى آخره. ومن المتعذّر عادةً كتابة شيفرة نواةٍ خاليةٍ من العلل 100% أو تصميم عتادٍ خالٍ من العلل؛ فإن كان كاتبو شيفرة المستخدم الخبيثة على علمٍ بعللٍ في النواة أو العتاد، فسيستغلّونها. وحتّى في النوى الناضجة واسعة الاستخدام، مثل Linux، يكتشف النّاس كثيرًا ثغراتٍ لم تكن معروفة سابقًا (الثغرات والتعرّضات الشائعة في Linux (CVEs)، بلا تاريخ). وأخيرًا، فإنّ التمييز بين شيفرة المستخدم وشيفرة النواة يكون أحيانًا ضبابيًّا: فبعض العمليّات المستخدمة ذات الامتيازات قد توفّر خدمات أساسيّةَ ويكون جزءًا فعليًّا من نظام التشغيل، وفي بعض نظم التشغيل تستطيع شيفرة المستخدم ذات الامتياز إدراج شيفرةٍ جديدةٍ في النواة (كما هو الحال مع وحدات النواة القابلة للتحميل في Linux ومع eBPF).

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

2.8 العالم الواقعيّ

ولقد تبنّت معظم نظم التشغيل مفهوم العمليّة، ومعظم العمليّات تبدو شبيهةً بعمليّات xv6. لكنّ نظم التشغيل الحديثة تدعم عدّة خيوطٍ داخل عمليّةٍ واحدة، للسمح لعمليّةٍ واحدة باستغلال وحدات معالجةٍ متعدّدة. ودعم خيوطٍ متعدّدةٍ في عمليّةٍ يتطلّب قدرًا كبيرًا من الآليّات التي لا تملكها xv6، يشمل غالبًا تغييراتٍ في الواجهة (مثل clone في Linux، وهو شكلٌ من fork)، للتحكّم في أيّ جوانب العمليّة يتشارك الخيوط.

2.9 تمارين

  1. أضف نداء نظامٍ إلى xv6 يُعيد كمّيّة الذاكرة المتاحة.

  1. يقصد هذا النصّ بعبارة «multi-core» وحدات معالجةٍ متعدّدةً تتشارك الذاكرة لكنّها تنفّذ بالتوازي، لكلٍّ منها مجموعتها الخاصّة من المسجّرات. ويستخدم هذا النصّ أحيانًا مصطلح multiprocessor كمرادفٍ لـmulti-core، وإن كان multiprocessor قد يشير أيضًا بصورةٍ أكثر تخصّصًا إلى حاسوبٍ فيه عدّة رقاقات معالجةٍ متمايزة. ↩︎