واجهات نظام التشغيل
1. واجهات نظام التشغيل
مهمّة نظام التشغيل هي مشاركة الحاسوب بين برامج متعدّدة، وتوفير مجموعةٍ من الخدمات أنفع من تلك التي يدعمها العتاد وحده. ويدير نظام التشغيل العتاد منخفض المستوى ويجرّده، حتّى إنّ معالجَ كلماتٍ، مثلًا، لا يضطرّ إلى الاهتمام بأيّ نوعٍ من عتاد القرص يُستخدم. كما يشارك نظام التشغيل العتاد بين برامج متعدّدة حتّى تعمل (أو يبدو أنّها تعمل) في الوقت نفسه. وأخيرًا، توفّر نظم التشغيل طرائق مضبوطة لتتفاعل البرامج بها، حتّى تستطيع مشاركة البيانات أو العمل معًا.
ويمدّ نظام التشغيل برامج المستخدم بالخدمات عبر واجهة.
ويتّضح أنّ تصميم واجهةٍ حسنةٍ أمرٌ عسير. فمن جهةٍ، نحبّ أن تكون الواجهة بسيطةً وضيّقةً، لأنّ ذلك يسهّل إنجاز التنفيذ الصحيح. ومن جهةٍ أخرى، قد نميل إلى تقديم ميزاتٍ كثيرةٍ متطوّرةٍ للتطبيقات. والحيلة في حلّ هذا التوتّر هي تصميم واجهاتٍ تعتمد على آليّاتٍ قليلةٍ يمكن تركيبها لتوفير عموميّةٍ كبيرة.
ويستخدم هذا الكتاب نظام تشغيلٍ واحدًا كمثالٍ ملموسٍ لتوضيح مفاهيم نظم التشغيل. فذلك النظام، xv6، يوفّر الواجهات الأساسيّة التي قدّمها كن طومسون ودينيس ريتشي في نظام تشغيل Unix (ريتشي وطومسون 1974)، كما يحاكي التصميم الداخليّ لـUnix. وتوفّر Unix واجهةً ضيّقةً تتضافر آليّاتها تضافرًا حسنًا، فتمنحها درجةً مدهشةً من العموميّة. ولقد نجحت هذه الواجهة نجاحًا كبيرًا، حتّى إنّ نظم التشغيل الحديثة—BSD، Linux، ماك أو إس، سولاريس، وحتّى مايكروسوفت ويندوز بدرجةٍ أقلّ—صارت ذات واجهاتٍ تشبه Unix. وفهم xv6 بدايةٌ طيّبةٌ لفهم أيًّا من تلك النظم وغيرها كثير.
فكما يبيّنه الشكل 1.1، فإنّ xv6 يتّخذ الصورة التقليديّة لنواة (kernel)، وهي برنامجٌ خاصّ يقدّم خدمات للبرامج العاملة. وكلّ برنامجٍ عامل، يُسمّى عمليّة (process)، له ذاكرةٌ تحتوي تعليماتٍ وبياناتٍ ومكدّسًا. والتعليمات تنفّذ حساب البرنامج. والبيانات هي المتغيّرات التي يعمل عليها الحساب. والمكدّس ينظّم نداءات الإجراءات في البرنامج. والحاسوب المُعطى له عادةً عمليّاتٌ كثيرة، لكنّه لا يملك إلّا نواةً واحدة.
وحين تحتاج عمليّة إلى استدعاء خدمةٍ من النواة، فإنّها تستدعي نداء نظام (system call)، أي واحدًا من النداءات في واجهة نظام التشغيل. ويدخل نداء النظام إلى النواة؛ فتنفّذ النواة الخدمة ثمّ تعود. وبذلك تتناوب العمليّة بين التنفيذ في فضاء المستخدم (user space) وفي فضاء النواة (kernel space).
وكما سنفصّل في الفصول التالية، تستخدم النواة آليّات الحماية التي يوفّرها المعالج CPU[1] للتأكّد من أنّ كلّ عمليّةٍ تنفّذ في فضاء المستخدم لا تستطيع الوصول إلّا إلى ذاكرتها هي. وتنفّذ النواة بالصلاحيّات العتاديّة اللازمة لتنفيذ هذه الحمايات؛ أمّا برامج المستخدم فتنفّذ دون تلك الصلاحيّات. وحين يستدعي برنامجٌ من برامج المستخدم نداء نظام، يرفع العتاد مستوى الصلاحية ويبدأ تنفيذ دالّةٍ مُعدّةً سلفًا في النواة.

الشكل 1.1: نواة وعمليّتا مستخدم.
ومجموعة نداءات النظام التي توفّرها النواة هي الواجهة التي تراها برامج المستخدم. وتوفّر نواة xv6 مجموعةً فرعيّةً من الخدمات ونداءات النظام التي تقدّمها نوى Unix تقليديًّا. ويعرض الشكل 1.2 جميع نداءات النظام في xv6.
ويستعرض باقي هذا الفصل خدمات xv6—العمليّات، والذاكرة، وواصفات الملفّات، والأنابيب، ونظام الملفّات—ويوضّحها بمقاطع شيفرةٍ ونقاشاتٍ حول كيفيّة استخدام الصدفة (shell)، وهي واجهة مستخدم سطر أوامر Unix، لها. ويُبرِز استخدام الصدفة لنداءات النظام مدى حرص تصميمها.
والصدفة برنامجٌ عاديّ يقرأ الأوامر من المستخدم وينفّذها. وحقيقة أنّ الصدفة برنامجٌ من برامج المستخدم، وليست جزءًا من النواة، تُبرِز قوة واجهة نداء النظام: فلا شيء مميّز في الصدفة. ويعني ذلك أيضًا أنّ الصدفة سهلة الاستبدال؛ ونتيجةً لذلك، فإنّ أنظمة Unix الحديثة فيها تشكيلةٌ من الصدفات للاختيار من بينها، لكلٍّ منها واجهة مستخدمها وميزاتها في كتابة السكربتات. وصدفة xv6 تنفيذٌ مبسّطٌ لجوهر صدفة Bourne في Unix.
ويُمكن العثور على تنفيذ صدفة xv6 في user/sh.c. وهو رابطٌ فائقيّ إلى الشيفرة المصدرية ذات الصلة على https://github.com/mit-pdos/xv6-riscv/، أمّا الرقم المحدّد فيشير إلى رقم الورقة ورقم السطر في xv6-src-booklet.pdf، كما في تعليق ليونز على الإصدار السادس من UNIX (ليونز 2000). ومن الممارسات الحسنة أن تحاول قراءة الشيفرة المصدرية بنفسك أوّلًا (في بيئة التطوير المفضّلة لديك، أو على GitHub، أو في قارئ PDF) ثمّ تعود إلى هذا الكتاب. وبحلول نهاية هذا الكتاب ينبغي أن تكون قادرًا على فهم كلّ سطرٍ من شيفرة xv6 المصدرية دون الحاجة إلى الرجوع إلى هذا الكتاب.
1.1 العمليّات والذاكرة
تتكوّن عمليّة xv6 من ذاكرةٍ في فضاء المستخدم (تعليمات، وبيانات، ومكدّس) ومن حالةٍ مرتبطةٍ بتلك العمليّة خاصّةٌ بالنواة. وتشارك xv6 العمليّات زمنيًّا (time-share): فتبدّل وحدات المعالجة المركزيّة المتاحة شفّافةً بين مجموعة العمليّات المنتظرة للتنفيذ. وحين لا تكون عمليّةٌ في وضع التنفيذ، تحفظ xv6 مسجّلات المعالج الخاصّة بها، وتستعيدها عند تشغيلها مرّةً أخرى. وتربط النواة معرّف عمليّة، أو PID، بكلّ عمليّة.
الشكل 1.2: نداءات النظام في xv6. إن لم يُنصّ على خلاف ذلك، فتُعيد هذه النداءات 0 عند عدم وجود خطأ، و-1 عند حدوث خطأ.
| نداء النظام | الوصف |
|---|---|
| int fork() | إنشاء عمليّة، وإعادة معرّف العمليّة الابن. |
| int exit(int status) | إنهاء العمليّة الحاليّة؛ والحالة تُبلَّغ إلى wait(). بلا عودة. |
| int wait(int *status) | الانتظار حتّى تنتهي عمليّة ابن؛ وحالة الخروج في *status؛ وتُعيد معرّف العمليّة الابن. |
| int kill(int pid) | إنهاء العمليّة PID. تُعيد 0، أو -1 عند الخطأ. |
| int getpid() | إعادة معرّف العمليّة الحاليّة. |
| int pause(int n) | التوقّف لمدة n من نبضات الساعة. |
| int uptime() | إعادة عدد نبضات الساعة منذ الإقلاع. |
| int exec(char *file, char *argv[]) | تحميل ملفّ وتنفيذه بمعطياتٍ؛ ولا تعودة إلّا عند الخطأ. |
| char *sbrk(int n) | تنمية ذاكرة العمليّة بمقدار n بايتًا صفريًّا. تُعيد بداية الذاكرة الجديدة. |
| int open(char *file, int flags) | فتح ملفّ؛ وتُبيّن الرّايات القراءة/الكتابة؛ وتُعيد واصف ملفّ (fd). |
| int write(int fd, char *buf, int n) | كتابة n بايتًا من buf إلى واصف الملفّ fd؛ وتُعيد n. |
| int read(int fd, char *buf, int n) | قراءة n بايتًا إلى buf؛ وتُعيد العدد المقروء؛ أو 0 عند نهاية الملفّ. |
| int close(int fd) | تحرير واصف الملفّ المفتوح fd. |
| int dup(int fd) | إعادة واصف ملفّ جديد يشير إلى الملفّ نفسه الذي يشير إليه fd. |
| int pipe(int p[]) | إنشاء أنبوب، ووضع واصفَي قراءة/كتابة في p[0] وp[1]. |
| int chdir(char *dir) | تغيير المجلّد الحاليّ. |
| int mkdir(char *dir) | إنشاء مجلّد جديد. |
| int mknod(char *file, int, int) | إنشاء ملفّ جهاز. |
| int fstat(int fd, struct stat *st) | وضع معلومات عن ملفّ مفتوح في *st. |
| int link(char *file1, char *file2) | إنشاء اسمٍ آخر (file2) للملفّ file1. |
| int unlink(char *file) | إزالة ملفّ. |
| int sync() | الانتظار حتّى تكون تعديلات نظام الملفّات الأسبق على القرص. |
وقد تنشئ عمليّةٌ عمليّة جديدةً باستخدام نداء النظام fork. ويمنح fork العمليّة الجديدة نسخةً مطابقةً من ذاكرة العمليّة المنادِيَة: فـfork ينسخ تعليمات العمليّة المنادِيَة وبياناتها ومكدّسها إلى ذاكرة العمليّة الجديدة. ويعود fork في العمليّة الأصليّة والجديدة على حدٍّ سواء. ففي العمليّة الأصليّة يعيد fork معرّف العمليّة الجديدة. وفي العمليّة الجديدة يعيد fork صفرًا. وكثيرًا ما تُسمّى العمليّتان الأصليّة والجديدة الأب (parent) والابن (child).
فمثلًا، انظر مقطوعة البرنامج التالية المكتوبة بلغة البرمجة C (Kernighan 1988):
int pid = fork();
if(pid > 0){
printf("parent: child=%d\n", pid);
pid = wait((int *) 0);
printf("child %d is done\n", pid);
} else if(pid == 0){
printf("child: exiting\n");
exit(0);
} else {
printf("fork error\n");
}
ويسبّب نداء النظام exit توقّف العمليّة المنادِيَة عن التنفيذ وتحريرها موارد مثل الذاكرة والملفّات المفتوحة. ويأخذ Exit معطى حالةٍ صحيحًا، يُتّفق تقليديًّا على أن يكون 0 للدلالة على النجاح و1 للدلالة على الفشل. ويعيد نداء النظام wait معرّف عمليّة ابنٍ انتهت (أو قُتلت) من عمليّة العمليّة الحاليّة، وينسخ حالة خروج الابن إلى العنوان الممرَّر إلى wait؛ فإن لم يكن أحد أبناء المنادِي قد انتهى، فإنّ wait ينتظر حتّى يفعل أحدهم ذلك. وإذا لم يكن للمنادِي أبناء، فإنّ wait يعيد -1 فورًا. وإذا كان الأب لا يبالي بحالة خروج ابنٍ، فيمكنه تمرير العنوان 0 إلى wait.
ففي المثال، قد تأتي أسطر المخرجات
parent: child=1234
child: exiting
بأيّ من الترتيبين (أو حتى متداخلة)، اعتمادًا على أيّهما يصل إلى نداء printf أوّلًا، الأب أمّ الابن. وبعد خروج الابن، يعود wait في الأب، فيدفع ذلك الأب إلى طباعة
parent: child 1234 is done
ومع أنّ الابن يبدأ بنسخةٍ من ذاكرة الأب، فإنّ الأب والابن ينفّذان بذاكرةٍ منفصلةٍ وبمسجّلاتٍ منفصلة: فتغيير متغيّرٍ في أحدهما لا يؤثّر في الآخر. فمثلًا، حين تُخزَّن قيمة عودة wait في pid في العمليّة الأب، فإنّ ذلك لا يغيّر المتغيّر pid في الابن. وستبقى قيمة pid في الابن صفرًا.
ويستبدل نداء النظام exec ذاكرة العمليّة المنادِيَة بصورة ذاكرةٍ جديدةٍ تُحمَّل من ملفٍّ مخزَّن في نظام الملفّات. وينبغي أن يكون للملفّ صيغةٌ خاصّة، تحدّد أيّ جزءٍ من الملفّ يحتوي التعليمات، وأيّ جزءٍ هو البيانات، وعند أيّ تعليمةٍ يبدأ التنفيذ، إلى آخره. ويستخدم xv6 صيغة ELF، التي يناقشها الفصل 3 بتفصيلٍ أكبر. والملفّ هو عادةً نتيجة تصريف شيفرة مصدريّةٍ لبرنامج. وحين ينجح exec، فإنّه لا يعود إلى البرنامج المنادِي؛ بل تبدأ التعليمات المحمَّلة من الملفّ بالتنفيذ عند نقطة الدخول المعلنة في ترويسة ELF. ويأخذ exec معطيين: اسم الملفّ الذي يحتوي الملفّ التنفيذيّ، ومصفوفةً من معطياتٍ نصيّة. فمثلًا:
char *argv[3];
argv[0] = "echo";
argv[1] = "hello";
argv[2] = 0;
exec("/bin/echo", argv);
printf("exec error\n");
وهذه المقطوعة تستبدل البرنامج المنادِي بنسخةٍ من البرنامج /bin/echo تعمل بقائمة المعطيات echo hello. وتتجاهل معظم البرامج العنصر الأوّل من مصفوفة المعطيات، وهو اصطلاحًا اسم البرنامج.
تستخدم صدفة xv6 النداءات الواردة أعلاه لتشغيل البرامج نيابةً عن المستخدمين. والبنية الرئيسة للصدفة بسيطة؛ انظر main. فالحلقة الرئيسة تقرأ سطرًا من المُدخلات من المستخدم باستخدام getcmd. ثمّ تنادي fork، الذي ينشئ نسخةً من عمليّة الصدفة. وينادي الأب wait، بينما ينفّذ الابن الأمر. فمثلًا، لو كتب المستخدم “echo hello” للصدفة، لَعُرِّضَ runcmd بـ“echo hello” معطىً. وruncmd ينفّذ الأمر الفعليّ. وفي حالة “echo hello” ينادي exec. فإذا نجح exec نفّذ الابن تعليماتٍ من echo بدلًا من runcmd. وفي لحظةٍ ما سينادي echo نداء exit، فيعيد ذلك الأب من wait في main.
وقد تتساءل لماذا لم يُدمج fork وexec في نداءٍ واحد؛ وسنرى لاحقًا أنّ الصدفة تستثمر هذا الفصل في تنفيذها لإعادة توجيه الإدخال والإخراج. وحتّى نُجَنَّب هدر إنشاء عمليّةٍ مكرّرةٍ ثمّ استبدالها فورًا (بـexec)، تحسّن النوى العاملّة تنفيذ fork لهذه الحالة الاستخداميّة باستخدام تقنيّات الذاكرة الافتراضيّة مثل النسخ عند الكتابة (copy-on-write) (انظر الفصل 5).
وتخصّص xv6 معظم ذاكرة فضاء المستخدم ضمنيًّا: فـfork يخصّص الذاكرة اللازمة لنسخة الابن من ذاكرة الأب، وexec يخصّص ذاكرةً كافيةً لاستيعاب الملفّ التنفيذيّ. والعمليّة التي تحتاج ذاكرةً أكثر أثناء التشغيل (ربّما لِـmalloc) تستطيع مناداة sbrk(n) لتنمية ذاكرة بياناتها بمقدار n بايتًا صفريًّا؛ ويعيد sbrk موضع الذاكرة الجديدة.
1.2 الإدخال والإخراج وواصفات الملفّات
وواصف الملفّ (file descriptor) عددٌ صحيحٌ صغير يمثّل كائنًا تديره النواة وتستطيع العمليّة القراءة منه أو الكتابة إليه. وقد تحصل العمليّة على واصف ملفٍّ بفتح ملفّ، أو مجلّد، أو جهاز، أو بإنشاء أنبوب، أو بمضاعفة واصفٍ قائم. وللبساطة سنشير كثيرًا إلى الكائن الذي يشير إليه واصف الملفّ باسم «ملفّ»؛ فبواجهة واصف الملفّ تُجرَّد الفروق بين الملفّات والأنابيب والأجهزة، فتبدو جميعها مجرّدات تيّارات من البايتات. وسنشير إلى الإدخال والإخراج بـ I/O.
وداخليًّا، تستخدم نواة xv6 واصف الملفّ فهرسًا في جدولٍ خاصٍّ بالعمليّة، بحيث يكون لكلّ عمليّة فضاءٌ خاصٌّ من واصفات الملفّات يبدأ من الصفر. واصطلاحًا، تقرأ العمليّة من واصف الملفّ 0 (المُدخل القياسيّ)، وتكتب مخرجاتها إلى واصف الملفّ 1 (المُخرج القياسيّ)، وتكتب رسائل الخطأ إلى واصف الملفّ 2 (الخطأ القياسيّ). وكما سنرى، تستثمر الصدفة هذا الاصطلاح لتنفيذ إعادة توجيه الإدخال والإخراج وخطوط الأنابيب. وتحرص الصدفة على أنّ لديها دائمًا ثلاثة واصفات ملفّات مفتوحة (sh.c:152)، وهي افتراضيًّا واصفات ملفّات لوحة التحكّم.
ويقرأ نداءا النظام read وwrite بايتاتٍ من الملفّات المفتوحة التي تسمّيها واصفات الملفّات ويكتبان إليها. فيقرأ النداء read(fd، buf، n) بحدٍّ أقصى n بايتًا من واصف الملفّ fd، وينسخها إلى buf، ويعيد عدد البايتات المقروءة. ولكلّ واصف ملفٍّ يشير إلى ملفّ إزاحةٌ مرتبطةٌ به. فيقرأ read البيانات من إزاحة الملفّ الحاليّة ثمّ يُقدّم تلك الإزاحة بعدد البايتات المقروءة: فإنّ نداء read لاحقًا سيُعيد البايتات التالية لتلك التي أعادها نداء read الأوّل. وحين لا تبقى بايتاتٌ لقراءتها، يعيد read صفرًا للدلالة على نهاية الملفّ.
ويكتب النداء write(fd، buf، n) مقدار n بايتًا من buf إلى واصف الملفّ fd ويعيد عدد البايتات المكتوبة. ولا يُكتب إلّا عددٌ من البايتات أقلّ من n عند حدوث خطأ. وشأن read، يكتب write البيانات عند إزاحة الملفّ الحاليّة ثمّ يُقدّم تلك الإزاحة بعدد البايتات المكتوبة: فكلّ write ينطلق من حيث توقّف سابقه.
وتنسخ مقطوعة البرنامج التالية (وهي جوهر البرنامج cat) البيانات من مُدخلها القياسيّ إلى مخرجها القياسيّ. فإن حدث خطأ، كتبت رسالةً إلى الخطأ القياسيّ.
char buf[512];
int n;
for(;;){
n = read(0, buf, sizeof buf);
if(n == 0)
break;
if(n
والأمر الجوهريّ الذي ينبغي ملاحظته في مقطوعة الشيفرة هو أنّ cat لا تدري هل تقرأ من ملفّ، أمّ من لوحة تحكّم، أمّ من أنبوب. وكذلك لا تدري cat هل تطبع إلى لوحة تحكّم، أمّ إلى ملفّ، أمّ إلى أيّ شيءٍ آخر. ويمكّن استخدام واصفات الملفّات، مع الاصطلاح القائل بأنّ واصف الملفّ 0 هو المُدخل وواصف الملفّ 1 هو المخرج، من تنفيذٍ مبسّطٍ لـcat.
ويحرّر نداء النظام close واصف ملفّ، فيصبح متاحًا لإعادة استخدامه في نداء open أو pipe أو dup مستقبليّ (انظر أدناه). وواصف الملفّ المخصّص حديثًا هو دائمًا أقلّ واصفٍ غير مستخدمٍ رقمًا في العمليّة الحاليّة.
وتتفاعل واصفات الملفّات مع fork لتجعل تنفيذ إعادة توجيه الإدخال والإخراج سهلًا. فـfork ينسخ جدول واصفات الملفّات الخاصّ بالأب إلى جانب ذاكرته، حتّى يبدأ الابن بالملفّات المفتوحة نفسها التي لدى الأب بالضبط. واستدعاء النظام exec يستبدل ذاكرة العمليّة المنادِيَة لكنّه يحفظ جدول ملفّاتها. ويسمح هذا السلوك للصدفة بتنفيذ إعادة توجيه الإدخال والإخراج (I/O redirection) بالتفرّع، وبإغلاق واصفات الملفّات المختارة وإعادة فتحها في الابن، ثمّ بمناداة exec لتشغيل البرنامج الجديد. وها هو إصدارٌ مبسّطٌ من الشيفرة التي تشغّلها الصدفة للأمر cat < input.txt:
char *argv[2];
argv[0] = "cat";
argv[1] = 0;
if(fork() == 0) {
close(0);
open("input.txt", O_RDONLY);
exec("cat", argv);
}
وبعد أن يغلق الابن واصف الملفّ 0، يضمن open استخدام ذلك واصف الملفّ للملفّ input.txt المفتوح حديثًا: فسيكون 0 أصغر واصف ملفّ متاح. ثمّ ينفّذ cat بواصف الملفّ 0 (المُدخل القياسيّ) الذي يشير إلى input.txt. أمّا واصفات الملفّات الخاصّة بالعمليّة الأب فلا تتغيّر بهذه المتوالية، بما أنّها تعدّل واصفات الابن وحده.
وتعمل الشيفرة الخاصّة بإعادة توجيه الإدخال والإخراج في صدفة xv6 بهذه الطريقة بالضبط (sh.c:83). وتذكّر أنّ الصدفة، عند هذه النقطة من الشيفرة، قد فرّعت عمليّة الصدفة الابن سلفًا، وأنّ runcmd سينادي exec لتحميل البرنامج الجديد.
ويتكوّن المعامل الثاني لـopen من مجموعة رايات (flags)، مُعبَّر عنها بصيغة بتّات، تتحكّم فيما يفعله open. والقيم الممكنة معرَّفةٌ في ترويسة التحكّم بالملفّات (fcntl) (fcntl.h:1-5): O_RDONLY، وO_WRONLY، وO_RDWR، وO_CREATE، وO_TRUNC، التي تأمر open بفتح الملفّ للقراءة، أو للكتابة، أو للقراءة والكتابة معًا، وبإنشاء الملفّ إن لم يكن موجودًا، وباقتضاء الملفّ إلى طولٍ صفريّ.
وصار من الواضح الآن لماذا يفيد أن يكون fork وexec نداءين منفصلين: فبينهما، تتاح للصدفة فرصةٌ لإعادة توجيه إدخال الابن وإخراجه دون إزعاج تهيئة الإدخال والإخراج للصدفة الرئيسة. ويمكن بدلًا من ذلك تصوّر نداءٍ مركّبٍ افتراضيّ باسم forkexec، لكنّ الخيارات لفعل إعادة توجيه الإدخال والإخراج بنداءٍ كهذا تبدو حرجة. فيمكن للصدفة تعديل تهيئة إدخالها وإخراجها قبل مناداة forkexec (ثمّ التراجع عن تلك التعديلات)؛ أو أن يأخذ forkexec تعليمات إعادة توجيه الإدخال والإخراج معطياتٍ؛ أو (وهو الأقلّ جاذبيّةً) أن يُدرَّب كلّ برنامجٍ مثل cat على فعل إعادة توجيه الإدخال والإخراج بنفسه.
ومع أنّ fork ينسخ جدول واصفات الملفّات، فإنّ إزاحة كلّ ملفّ كامنةٍ مشتركةٌ بين الأب والابن. انظر هذا المثال:
if(fork() == 0) {
write(1, "hello ", 6);
exit(0);
} else {
wait(0);
write(1, "world\n", 6);
}
وفي نهاية هذه المقطوعة، سيحتوي الملفّ المرتبط بواصف الملفّ 1 على البيانات hello world. فالكتابة write في الأب (التي تُنفَّذ، بفضل wait، بعد انتهاء الابن فقط) تنطلق من حيث توقّفت write الخاصّة بالابن. ويساعد هذا السلوك على إنتاج مخرجات متتاليةٍ من متواليات أوامر الصدفة، مثل (echo hello; echo world) >output.txt.
ويكرّر نداء النظام dup واصف ملفّ قائم، فيعيد واصفًا جديدًا يشير إلى الكائن نفسه في الإدخال والإخراج. ويتشارك واصفا الملفّات إزاحةً، تمامًا كما يفعل واصفا الملفّات المكرَّران بـfork. وهذه طريقة أخرى لكتابة hello world في ملفّ:
fd = dup(1);
write(1, "hello ", 6);
write(fd, "world\n", 6);
ويتشارك واصفا ملفّاتٍ إزاحةً إذا اشتُقّا من واصف الملفّ الأصليّ نفسه بواسطة متواليةٍ من نداءات fork وdup. وإلّا فإنّ واصفات الملفّات لا تتشارك الإزاحات، حتّى لو نشأت من نداءات open للملفّ نفسه. ويسمح dup للصدفات بتنفيذ أوامر مثل: ls existing-file non-existing-file > tmp1 2>&1. و2>&1 تخبر الصدفة بأن تعطي الأمر واصف ملفٍّ برقم 2 مكرّرًا للواصف 1. وسيظهر اسم الملفّ القائم ورسالة الخطأ الخاصّة بالملفّ غير القائم كلاهما في الملفّ tmp1. ولا تدعم صدفة xv6 إعادة توجيه الإدخال والإخراج لواصف ملفّ الخطأ، لكنّك تعرف الآن كيف تنفّذها.
وواصفات الملفّات تجريدٌ قويّ، لأنّها تخفي تفاصيل ما هي متّصلةٌ به: فالعمليّة التي تكتب إلى واصف الملفّ 1 قد تكون تكتب في ملفّ، أو في جهازٍ مثل لوحة التحكّم، أو في أنبوب.
1.3 الأنابيب
والأنبوب (pipe) مخزَنٌ صغيرٌ في النواة يُعرَّض للعمليّات بوصفه زوجًا من واصفات الملفّات، واحد للقراءة وواحد للكتابة. فكتابة البيانات إلى أحد طرفَي الأنبوب تجعلها متاحةً للقراءة من الطرف الآخر للأنبوب. وتوفّر الأنابيب طريقةً لتتواصل العمليّات.
ويشغّل مثال الشيفرة التاليّ البرنامج wc بمُدخلٍ قياسيٍّ متّصلٍ بطرف القراءة لأنبوب.
int p[2];
char *argv[2];
argv[0] = "wc";
argv[1] = 0;
pipe(p);
if(fork() == 0) {
close(0);
dup(p[0]);
close(p[0]);
close(p[1]);
exec("/bin/wc", argv);
} else {
close(p[0]);
write(p[1], "hello world\n", 12);
close(p[1]);
}
وينادي البرنامج pipe، الذي ينشئ أنبوبًا جديدًا ويسجّل واصفَي القراءة والكتابة في المصفوفة p. وبعد fork، يكون لدى الأب والابن واصفات ملفّاتٍ تشير إلى الأنبوب. فينادي الابن close وdup لجعل واصف الملفّ صفرًا يشير إلى طرف القراءة للأنبوب، ويغلق واصفات الملفّات في p، وينادي exec لتشغيل wc. وحين يقرأ wc من مُدخله القياسيّ، فإنّه يقرأ من الأنبوب. ويغلق الأب طرف القراءة من الأنبوب، ويكتب إلى الأنبوب، ثمّ يغلق طرف الكتابة.
وإذا لم تتوفّر بيانات، فإنّ read على أنبوب تنتظر إمّا أن تُكتب بيانات أو أن تُغلق جميع واصفات الملفّات التي تشير إلى طرف الكتابة؛ وفي الحالة الثانية، سيعيد read صفرًا، تمامًا كما لو كان قد بلغ نهاية ملفّ بيانات. وحقيقة أنّ read تحت حتّى يستحيل وصول بيانات جديدة، هي أحد الأسباب التي تجعل من المهمّ أن يغلق الابن طرف الكتابة من الأنبوب قبل تنفيذ wc أعلاه: فلو كان أحد واصفات wc يشير إلى طرف الكتابة من الأنبوب، لما رأى wc نهاية الملفّ أبدًا.
وتنفّذ صدفة xv6 خطوط أنابيب مثل grep fork sh.c | wc -l بطريقةٍ مشابهةٍ للشيفرة أعلاه (sh.c:101). فينشئ الابن أنبوبًا ليربط الطرف الأيسر من خطّ الأنابيب بالطرف الأيمن. ثمّ ينادي fork وruncmd للطرف الأيسر من خطّ الأنابيب، وfork وruncmd للطرف الأيمن، وينتظر انتهاء كليهما. وقد يكون الطرف الأيمن من خطّ الأنابيب أمرًا يتضمّن أنبوبًا هو نفسه (مثلًا، a | b | c)، فيفرّع هو نفسه عمليّتَي ابنٍ جديدتين (واحدةً لـb وأخرى لـc). وبذلك، قد تنشئ الصدفة شجرةً من العمليّات. وأوراق هذه الشجرة هي الأوامر، وعقدها الداخليّة عمليّاتٌ تنتظر حتّى يكمل الابن الأيسر والأيمن.
وقد لا تبدو الأنابيب أقوى من الملفّات المؤقّتة: فخطّ الأنابيب
echo hello world | wc
يُمكن تنفيذه دون أنابيب على الشكل
echo hello world >/tmp/xyz; wc
وللأنابيب مزايا ثلاثةٌ على الأقلّ على الملفّات المؤقّتة في هذا الموقف. أوّلًا، تنظّف الأنابيب نفسها تلقائيًّا؛ أمّا مع إعادة توجيه الملفّ، فكان على الصدفة أن تحرص على إزالة /tmp/xyz عند الانتهاء. ثانيًا، تستطيع الأنابيب تمرير تيّارات بيانات طويلةٍ اعتباطيًّا، بينما تتطلّب إعادة توجيه الملفّ مساحةً حرّةً كافيةً على القرص لتخزين جميع البيانات. ثالثًا، تسمح الأنابيب بالتنفيذ المتوازي لمراحل خطّ الأنابيب، بينما يتطلّب أسلوب الملفّ أن ينتهي البرنامج الأوّل قبل أن يبدأ الثاني.
1.4 نظام الملفّات
ويوفّر نظام ملفّات xv6 ملفّات بيانات، تحتوي مصفوفات بايتاتٍ غير مُفسَّرة، ومجلّدات، تحتوي مراجع مُسمّاةً إلى ملفّات البيانات ومجلّداتٍ أخرى. والمجلّدات تشكّل شجرة، تبدأ بمجلّدٍ خاصٍّ يُسمّى الجذر (root). والمسار (path) مثل /a/b/c يشير إلى الملفّ أو المجلّد المسمّى c داخل المجلّد المسمّى b داخل المجلّد المسمّى a في المجلّد الجذر /. والمسارات التي لا تبدأ بـ / تُقيَّم نسبةً إلى المجلّد الحاليّ (current directory) للعمليّة المنادِيَة، والذي يمكن تغييره بنداء النظام chdir. وكلا مقطوعتَي الشيفرة هاتين تفتحان الملفّ نفسه (بافتراض أنّ جميع المجلّدات المعنيّة موجودة):
chdir("/a");
chdir("b");
open("c", O_RDONLY);
open("/a/b/c", O_RDONLY);
وتغيّر المقطوعة الأولى المجلّد الحاليّ للعمليّة إلى /a/b؛ أمّا الثانية فلا تشير إلى المجلّد الحاليّ للعمليّة ولا تغيّره.
وهناك نداءات نظامٌ لإنشاء ملفّات ومجلّدات جديدة: فـmkdir ينشئ مجلّدًا جديدًا، وopen مع الراية O_CREATE ينشئ ملفّ بياناتٍ جديدًا، وmknod ينشئ ملفّ جهازٍ جديدًا. وهذا المثال يوضّح الثلاثة كلّها:
mkdir("/dir");
fd = open("/dir/file", O_CREATE|O_WRONLY);
close(fd);
mknod("/console", 1, 1);
وينشئ mknod ملفًّا خاصًّا يشير إلى جهاز. ويرتبط بملفّ الجهاز رقم الجهاز الرئيسيّ ورقم الجهاز الثانويّ (وهما المعطيّان لـmknod)، وهما يحدّدان جهاز نواةٍ تحديدًا فريدًا. وحين تفتح عمليّةٌ ملفّ جهازٍ لاحقًا، يحوّل النواة نداءَي النظام read وwrite إلى تنفيذ جهاز النواة بدلًا من تمريرهما إلى نظام الملفّات.
واسم الملفّ متميّزٌ عن الملفّ نفسه؛ فالملفّ الكامن ذاته، المسمّى عقدة الفهرس (inode)، قد يكون له أسماءٌ متعدّدة، تُسمّى الارتباطات (links). ويتكوّن كلّ ارتباط من قيدٍ في مجلّد؛ ويحتوي القيد على اسم ملفٍّ ومرجعٍ إلى عقدة فهرس. وتحمل عقدة الفهرس بيانات وصفيّة (metadata) عن ملفّ، تشمل نوعه (ملفّ أو مجلّد أو جهاز)، وطوله، وموضع محتوى الملفّ على القرص، وعدد الارتباطات إلى ملفّ.
ويستعيد نداء النظام fstat معلومات من عقدة الفهرس التي يشير إليها واصف الملفّ. فهو يملأ struct stat المعرَّف في stat.h كما يلي:
#define T_DIR 1 // Directory
#define T_FILE 2 // File
#define T_DEVICE 3 // Device
struct stat {
int dev; // File system's disk device
uint ino; // Inode number
short type; // Type of file
short nlink; // Number of links to file
uint64 size; // Size of file in bytes
};
وينشئ نداء النظام link اسمًا آخر في نظام الملفّات يشير إلى عقدة الفهرس نفسها التي يشير إليها ملفّ قائم. وهذه المقطوعة تنشئ ملفًّا جديدًا مسمّى a وb كلاهما.
open("a", O_CREATE|O_WRONLY);
link("a", "b");
فالقراءة من a أو الكتابة إليها هي ذاتها القراءة من b أو الكتابة إليه. وتُعرَّف كلّ عقدة فهرسٍ برقمٍ فريدٍ لها. وبعد متوالية الشيفرة أعلاه، يُمكن تحديد أنّ a وb يشيران إلى المحتوى الكامن نفسه بفحص نتيجة fstat: فكلاهما سيُعيد رقم عقدة الفهرس (ino) نفسه، وسيُضبط عدّاد nlink على 2.
ويحذف نداء النظام unlink اسمًا من نظام الملفّات. ولا تُحرَّر عقدة الفهرس الخاصّة بالملفّ والمساحة القرصيّة التي تحتوي محتواه إلّا عندما يكون عدّاد ارتباطات الملفّ صفرًا ولا تشير إليه أيّ واصفات ملفّات. وبذلك، فإنّ إضافة
unlink("a");
إلى متوالية الشيفرة الأخيرة يُبقي عقدة الفهرس ومحتوى الملفّ متاحين بوصفهما b. وأيضًا،
fd = open("/tmp/xyz", O_CREATE|O_RDWR);
unlink("/tmp/xyz");
هي طريقة اصطلاحيّة لإنشاء عقدة فهرسٍ مؤقّتةٍ بلا اسمٍ ستُنظَّف عندما تغلق العمليّة fd أو تخرج.
وتوفّر Unix أدوات ملفّاتٍ قابلة للمناداة من الصدفة بوصفها برامج على مستوى المستخدم، مثل mkdir وln وrm. ويسمح هذا التصميم لأيّ شخصٍ بتوسيع واجهة سطر الأوامر بإضافة برامج جديدة على مستوى المستخدم. وبالنظر إلى الوراء، يبدو هذا المخطّط بديهيًّا، لكنّ أنظمةً أُخرى صُمّمت في زمن Unix كانت تبني مثل هذه الأوامر داخل الصدفة (وتبني الصدفة داخل النواة) غالبًا.
واستثناءٌ واحدٌ هو cd، المبنيّ في الصدفة (sh.c:166). فـcd لا بدّ أن يغيّر مجلّد العمل الحاليّ للصدفة نفسها. ولو شُغِّل cd كأمرٍ عاديّ، لفرّعت الصدفة عمليّة ابن، وتنفّذ العمليّة الابن cd، فيغيّر cd مجلّد عمل الابن. أمّا مجلّد عمل الأب (أي الصدفة) فلم يكن ليتبدّل.
1.5 العالم الواقعيّ
وكان اجتماع واصفات الملفّات «القياسيّة» والأنابيب وقواعد الصدفة الملائمة للعمليّات عليها، في Unix، خطوةً كبرى في كتابة برامج عامّةٍ قابلةٍ لإعادة الاستخدام. وأشعلت الفكرة ثقافة «أدوات البرمجيّات» التي كانت مسؤولةً عن كثيرٍ من قوّة Unix وشعبيّته، وكانت الصدفة أوّل ما سُمّي «لغة البرمجة النصيّة» (scripting language). وتستمرّ واجهة نداءات نظام Unix اليوم في نظمٍ مثل BSD، وLinux، وماك أو إس.
وقُيِّست واجهة نداءات النظام في Unix بموجب معيار الواجهة المحمولة لأنظمة التشغيل. أمّا xv6 فهو غير متوافقٍ مع هذا المعيار: إذ يفتقد العديد من نداءات النظام (منها نداءات أساسيّة مثل lseek)، وكثيرٌ من نداءات النظام التي يوفّرها يختلف عن المعيار. وأهدافنا الرئيسة من xv6 هما البساطة والوضوح، مع توفير واجهة نداءات نظامٍ بسيطةٍ تشبه UNIX. وقد أضاف عدة أشخاص إلى xv6 بضعة نداءات نظام إضافيّة وبمكتبة C بسيطة، لتشغيل برامج Unix أساسيّة. أمّا النوى الحديثة فتوفّر نداءات نظامٍ أكثر بكثير، وأنواعًا أكثر من خدمات النواة، من xv6. فهي تدعم مثلًا الشبكات ونظم النوافذ والخيوث على مستوى المستخدم ومشغّلات لأجهزة عتاديّة كثيرة، وغير ذلك. وتتطوّر النوى الحديثة تطوّرًا متصلًا وسريعًا، وتقدّم ميزاتٍ كثيرةً تتجاوز هذا المعيار.
ووحّدت Unix الوصول إلى أنواع متعدّدة من الموارد (الملفّات، والمجلّدات، والأجهزة) بمجموعةٍ واحدةٍ من واجهات أسماء الملفّات وواصفات الملفّات. ويمكن توسيع هذه الفكرة إلى أنواعٍ أكثر من الموارد؛ ومثالٌ حسَنٌ على ذلك Plan 9 (Presotto et al. 1991)، الذي طبّق مفهوم «الموارد ملفّات» على الشبكات والرسوميّات وغيرها. لكنّ معظم نظم التشغيل المشتقّة من Unix لم تسلك هذا المسار.
وكان نظام الملفّات وواصفات الملفّات تجريدَين قويّين. ومع ذلك، فهناك نماذج أخرى لواجهات نظم التشغيل. فـMultics، وهو سلف Unix، جرّد تخزين الملفّات بطريقةٍ جعلته يشبه الذاكرة، فأنتج نكهةً مختلفةً جدًّا للواجهة. وكان تعقيد تصميم Multics مؤثّرًا مباشرًا في مصمّمي Unix، الذين سعوا إلى بناء شيءٍ أبسط.
ولا يوفّر xv6 مفهومًا للمستخدمين ولا لحماية مستخدمٍ من آخر؛ فبلغة Unix، تعمل جميع عمليّات xv6 بوصفها root.
ويفحص هذا الكتاب كيف ينفّذ xv6 واجهته الشبيهة بـUnix، لكنّ الأفكار والمفاهيم تنطبق على أكثر من Unix وحده. فأيّ نظام تشغيلٍ لا بدّ أن يُوزّع العمليّات على العتاد الكامن، ويعزل العمليّات بعضها عن بعض، ويوفّر آليّات للتواصل المضبوط بين العمليّات. وبعد دراسة xv6، ينبغي أن تكون قادرًا على النظر إلى نظم تشغيلٍ أخرى أكثر تعقيدًا ورؤية المفاهيم الكامنة وراء xv6 في تلك النظم أيضًا.
1.6 التمارين
- اكتب برنامجًا يستخدم نداءات نظام UNIX لـ«ترجيع الكرة» بايتًا بين عمليّتين عبر زوجٍ من الأنابيب، واحد لكلّ اتجاه. وقِس أداء البرنامج، بوحدة التبادلات في الثانية.
يشير هذا النصّ عمومًا إلى العنصر العتاديّ الذي ينفّذ الحساب بمصطلح CPU، وهو اختصارٌ لوحدة المعالجة المركزيّة. وتستخدم وثائق أخرى (مثل مواصفة RISC-V) أيضًا كلمات processor وcore وhart بدلًا من CPU. ↩︎