بناء شل بسيط
درس 3 من 3
3بناء شل بسيط (C - Simple Shell)
أعقد مشروع في هذا المسار: بناء برنامج شل من الصفر يقرأ أوامر ويشغّلها، وهو تطبيق مباشر لكل ما تعلمته عن العمليات (Processes) في نظام التشغيل.
ماذا تشاهد في الرسم: الشل يطبع المحثّ ويقرأ سطرًا، ثم ينسخ نفسه بـ fork، فيتحوّل الابن إلى البرنامج بـ execve، وينتظره الأب ثم يعود.
طريقان إلى نفس الفكرة
الشل الذي كتبت فيه الأوامر طوال الرحلة ليس سحرًا: إنه برنامج يكرر ثلاث خطوات للأبد: يقرأ ما كتبت، يجد البرنامج المطلوب، ويطلب من النظام تشغيله ثم ينتظر. في هذا المشروع تكتب شلّك الصغير بنفسك.
الشل حلقة REPL: يقرأ السطر بـgetline، ويقسّمه إلى كلمات، ويبحث في PATH، ثم fork لإنشاء عملية ابن وexecve لتشغيل البرنامج فيها وwait في الأب. وتضيف أوامر مدمجة مثل exit وenv، وتتعامل مع نهاية الملف EOF والأخطاء.
قبل أن تبدأ
🖥️أساسيات الشل
راجع الجزء الثالث من دليل الأساسيات (الشل كواجهة للنظام) قبل هذا المشروع.
الفكرة الجوهرية: ثلاث عمليات نظام تبني أي شل
شكل 1، هذه الحلقة الثلاثية (fork → exec → wait) هي جوهر أي شل، من bash إلى أبسط شل تعليمي.
الشل في جوهره حلقة تقرأ نصًا وتحتاج تحويله لبرنامج يعمل فعليًا، بدون أن تتحول هي نفسها لذلك البرنامج، وبدون أن تفقد السيطرة بعد انتهائه. ثلاث استدعاءات نظام منفصلة تمامًا هي ما يجعل هذا ممكنًا، وكل واحدة تحل جزءًا من اللغز لا تستطيع الاستدعاءات الأخرى حلّه.
fork() تبدأ المسار: تُنشئ نسخة طبق الأصل كاملة من العملية الحالية. لكن "طبق الأصل" هنا لا يعني أن النظام ينسخ فعليًا كل بايت من ذاكرة العملية الأم لحظة الاستدعاء، هذا سيكون مكلفًا جدًا لعملية قد تستدعي exec وتتخلى عن كل تلك الذاكرة بعد أجزاء من الثانية. ما يحدث عمليًا على لينكس هو آلية النسخ عند الكتابة (Copy-on-Write): النظام ينسخ جداول صفحات الذاكرة (Page Tables) فقط ويُنشئ بنية عملية جديدة، لكن صفحات الذاكرة الفعلية تبقى مشتركة بين الأم والابنة حتى تحاول إحداهما الكتابة في صفحة معيّنة، فقط في تلك اللحظة يُنسَخ تلك الصفحة بالتحديد لتصبح للعملية التي كتبت فيها نسخة خاصة بها. والنتيجة العملية الأهم لك كمبرمج: fork() هي الاستدعاء الوحيد في لغة C الذي "يُرجع مرتين"، مرة بقيمة PID الابنة في العملية الأم، ومرة أخرى بقيمة 0 في العملية الابنة نفسها (أو -1 في الأم وحدها لو فشل الاستدعاء بالكامل ولم تُنشأ أي عملية ابنة إطلاقًا). كلا العمليتين، في اللحظة التي يعود فيها fork، متطابقتان تمامًا في كل شيء آخر، نفس الكود، نفس قيم المتغيرات، نفس موضع التنفيذ بالضبط، وهذا تحديدًا سبب أن فحص pid == 0 هو نقطة الانقسام الوحيدة: قيمة pid نفسها هي الإشارة الوحيدة المتاحة لمعرفة أي نسخة أنت فيها الآن.
execve() (وعائلتها الأوسع من دوال exec) تحل مشكلة مختلفة تمامًا: العملية الابنة موجودة الآن، لكنها لا تزال تشغّل كود الشل نفسه، تحتاج أن "تصبح" الأمر المطلوب تشغيله. execve تستبدل صورة العملية بالكامل (الكود القابل للتنفيذ، قطاع البيانات، المكدس) ببرنامج آخر تمامًا، بينما تحتفظ العملية بنفس PID ونفس جدول الملفات المفتوحة. ولأن الاستبدال شامل لكل شيء ينتمي للبرنامج القديم، فإن execve لا تُرجِع قيمة أبدًا عند نجاحها، ليس لأن المصمِّمين اختاروا ذلك، بل لأنه لا يوجد فعليًا "كود قديم" تعود إليه؛ قطعة الذاكرة التي كان المعالج سيُكمل التنفيذ فيها لم تعد موجودة أصلًا بعد الاستبدال. هذا سبب أن أي كود يلي execve في المصدر (كاستدعاء perror) لا يُنفَّذ إلا في الحالة الوحيدة التي فشلت فيها execve فعلًا ولم يحدث أي استبدال. والنقطة الدقيقة التي يسهل الخلط فيها: execve لا تبحث في PATH إطلاقًا، تتطلب مسارًا كاملًا جاهزًا (مطلقًا كـ/bin/ls أو نسبيًا لمجلد العمل الحالي)؛ مرّر لها السلسلة "ls" مباشرة وستفشل لأنه لا يوجد ملف بهذا الاسم الحرفي في مجلد العمل الحالي. آلية البحث في PATH التي تعتمد عليها فعليًا حين تكتب ls في الطرفية ليست جزءًا من execve على الإطلاق، إنها مسؤولية الشل نفسه (وهذا بالضبط ما يُطلب من شلّك الخاص فعله: يمشي خلال PATH، يبني كل مسار مرشّح، وفقط حين يجد الملف الفعلي يستدعي execve بذلك المسار الكامل الذي وجده)، أو، إن استخدمت execvp/execlp من عائلة exec بدل execve المباشرة، مكتبة C نفسها تنفّذ ذلك المسح داخليًا نيابةً عنك وتستدعي execve بنفسها بمجرد أن تجد تطابقًا.
waitpid() تحل الجزء الأخير: توقف العملية الأم حتى تنتهي عملية ابنة محدَّدة، فتحصل على رمز خروجها. بدون هذا الاستدعاء، ليس الأمر مجرد "عرض غير مرتّب"، فالعملية الابنة التي تنتهي قبل أن تجمع الأم رمز خروجها عبر wait/waitpid تتحول فعليًا إلى "عملية زومبي": يبقى سجلّها محجوزًا في جدول العمليات بالنظام يحمل رمز الخروج فقط، في انتظار أن تأتي الأم وتجمعه. هذا ليس مجرد أمر "غير مرتّب بصريًا"، كل عملية زومبي تشغل فعليًا خانة في جدول عمليات محدود المساحة، فلو شغّل شل آلاف الأوامر دون أن يجمع رموز الخروج أبدًا، يمكن نظريًا أن يستهلك ذلك الجدول بالكامل. في شل بسيط يستدعي waitpid مباشرة بعد كل fork لكل أمر، تبقى الزومبي موجودة فقط للحظة القصيرة بين انتهاء الابنة فعليًا واكتمال استدعاء waitpid في الأم، لا أكثر.
وأخيرًا، البحث في PATH نفسه، وهو تحديدًا ما يملأ الفجوة التي تركتها execve بعدم البحث فيها: متغيّر PATH نص واحد يحتوي مسارات مجلدات مفصولة بفواصل منقوطة (:) مثل /usr/bin:/bin:/usr/local/bin. الشل يأخذ كل مسار على الترتيب، يلصق به اسم الأمر المكتوب (فيصبح مثلًا /usr/bin/ls)، ويتحقق هل يوجد ملف بهذا المسار فعليًا وله صلاحية تنفيذ؛ فإن وُجد، يستدعي execve بذلك المسار الكامل بالضبط ويتوقف عن البحث فورًا دون فحص بقية المجلدات؛ وإن انتهت كل المسارات المذكورة في PATH بلا أي نتيجة، يطبع رسالة "command not found" بدل استدعاء execve بسلسلة غير موجودة أصلًا.
شكل 2، البحث في PATH يفحص كل مجلد بالترتيب حتى يجد أول ملف تنفيذي مطابق، ويتوقف فورًا عند أول نتيجة.
شكل 3، execve لا تبحث في PATH إطلاقًا وتتطلب مسارًا كاملًا جاهزًا؛ البحث الذي يُظهره الرسم السابق يجب أن يكتبه الشل بنفسه قبل استدعائها: وهو تمامًا ما تفعله execvp/execlp داخليًا نيابةً عنك لو استُخدِمت بدل execve المباشرة.
كيف يعمل، خطوة بخطوة: حلقة الشل الأساسية
#include <unistd.h>
#include <sys/wait.h>
void shell_loop(void)
{
char *line;
char **args;
pid_t pid;
int status;
while (1)
{
printf("$ ");
line = read_line(); /* اقرأ سطر أمر من المستخدم */
args = parse_line(line); /* قسّمه لكلمات: الأمر ووسائطه */
pid = fork();
if (pid == 0)
{
/* هذا الكود يُنفَّذ فقط في العملية الابنة */
execve(args[0], args, NULL);
perror("execve failed"); /* لا يصل هنا إلا لو فشل execve */
exit(1);
}
else
{
/* هذا الكود يُنفَّذ فقط في العملية الأم (الشل) */
waitpid(pid, &status, 0); /* انتظر انتهاء الابنة قبل عرض الموجّه التالي */
}
}
}
شكل 4، لاحظ فحص pid == 0: هذا هو الفرق الوحيد بين مسار الكود في العملية الأم ومسار الابنة بعد fork: نفس الكود المصدري، لكن قيمة pid تحدد أي فرع يُنفَّذ في كل عملية.
لنتتبّع ما يحدث فعليًا حين يكتب المستخدم ls في هذا الشل. عند استدعاء fork()، تنقسم عملية واحدة لحظيًا إلى عمليتين متطابقتين تمامًا تقفان عند النقطة نفسها بالضبط بعد الاستدعاء، الفرق الوحيد بينهما هو القيمة المُعادة: العملية الابنة تستقبل 0، والعملية الأم تستقبل قيمة PID الابنة الفعلية (رقم صحيح موجب). الشرط if (pid == 0) هو ما يستخدم هذا الفرق الوحيد لتوجيه كل عملية لمسارها الصحيح: الابنة تدخل هذا الفرع وتستدعي execve(args[0], args, NULL)، وبما أن args[0] هنا هو السلسلة "ls" كما كتبها المستخدم حرفيًا، وليس مسارًا كاملًا، فإن execve ستفشل فورًا (لأنها لا تبحث في PATH كما شرحنا أعلاه) إلا لو كان شلّك نفسه قد حلّ args[0] لمسار كامل كـ/bin/ls قبل هذا الاستدعاء بالضبط، وهذا تحديدًا الدور الذي يُفترض أن تلعبه خطوة البحث في PATH قبل الوصول لهذا السطر. لو نجحت execve فعليًا، فإن كل ما يلي استدعائها في الكود (استدعاء perror وما بعده) أصبح كودًا ميتًا في عملية لم تعد موجودة بصورتها القديمة أصلًا، لا يُنفَّذ أبدًا. أما العملية الأم، فتأخذ الفرع else وتستدعي waitpid(pid, &status, 0) فورًا، فتتجمّد عند هذا السطر بالضبط حتى تنتهي العملية الابنة (سواء بتنفيذ ls بنجاح أو بالفشل في execve نفسها والخروج برمز 1)، وفقط بعد عودة waitpid، تتابع حلقة while (1) دورتها التالية فتطبع موجّه $ جديدًا. غياب هذا الاستدعاء بالضبط هو ما يجعل الفرق بين شل يعمل بشكل صحيح وشل يطبع موجّهات متراكبة بينما أوامره السابقة لا تزال تعمل في الخلفية دون أي تنسيق.
waitpid في العملية الأم. بدونه، الشل يطبع الموجّه التالي فورًا بينما الأمر السابق لا يزال يعمل في الخلفية، وقد تتراكم "عمليات زومبي" (Zombie Processes) لم يُجمَع رمز خروجها بعد.
تعمّق: كيف يعمل الشل من الداخل
الشل برنامج واحد يجمع كل ما تعلمته عن العمليات والبيئة والملفات. نفصّل أركانه واحدًا واحدًا، مع مخططات تشرح التدفق، دون أي شيفرة جاهزة للتمارين.
تاريخ مختصر
وُلد Unix في مختبرات Bell على يد Ken Thompson و Dennis Ritchie، وكتب Thompson أول شل لـUnix (شل Thompson)، كما أنه مبتكر لغة B التي سبقت C مباشرة. معرفة هذا التسلسل جزء من الأهداف التعليمية: اللغة والنظام والشل وُلدوا معًا تقريبًا.
العمليات: PID و PPID
العملية نسخة من برنامج قيد التشغيل ولها رقم فريد (PID). لكل عملية أب (PPID)، هو العملية التي أنشأتها. كل أمر تشغّله من الشل يصبح عملية جديدة أبوها الشل. ولهذا إن شغّلت برنامجًا يطبع PPID مرارًا داخل نفس الشل ستحصل على الرقم نفسه كل مرة، بينما يتغير PID في كل تشغيل.
الثلاثية: fork ثم execve ثم wait
fork تنسخ العملية الحالية فتصبح عمليتين تتابعان من نفس النقطة: تعيد fork القيمة 0 للابن، ورقم الابن للأب. يستدعي الابن execve ليستبدل نفسه ببرنامج آخر بالكامل (ولا تعود execve إلا عند الفشل). ويستدعي الأب wait ليتوقف حتى ينتهي ابنه ويعرف كيف انتهى.
شكل 5، دورة تشغيل أمر واحد: الأب ينسخ، والابن يتحوّل، والأب ينتظر.
نسيان أن الأب والابن يشتركان في الشيفرة نفسها بعد fork: إن لم تفرّق بينهما بقيمة الإرجاع فسينفّذ الاثنان الجزء نفسه. وفشل execve في الابن يستلزم إنهاء الابن وإلا ظل نسخة ثانية من الشل.
كيف يجد الشل البرنامج؟ المتغير PATH
إذا كتب المستخدم مسارًا فيه شرطة مائلة (مثل /bin/ls) فالشل يستخدمه كما هو. وإذا كتب اسمًا مجردًا (مثل ls) فالشل يمرّ على المجلدات المذكورة في متغير PATH (مفصولة بنقطتين رأسيتين) بالترتيب، ويدمج كل مجلد مع الاسم، ويتحقق هل الملف موجود وقابل للتنفيذ (بنداءات مثل stat أو access)، ويشغّل أول نتيجة. وإن لم يجد شيئًا يطبع رسالة "not found" باسم برنامجك ورقم السطر.
شكل 6، خوارزمية البحث عن البرنامج: مسار مباشر أو بحث في PATH.
البيئة ووسائط main
لكل عملية بيئة: مصفوفة سلاسل بصيغة NAME=value تنتهي بـNULL، وتُورَّث نسخة منها للأبناء. تصلك البيئة بطريقتين: المتغير العام environ، أو الوسيط الثالث لـmain. ولهذا لـmain ثلاث صيغ: بلا وسائط، أو (ac, av)، أو (ac, av, env). و av مصفوفة منتهية بـNULL، وفيها av[0] اسم البرنامج كما استُدعي، وهو ما تستخدمه في رسائل الخطأ. وعند استدعاء execve مرّر البيئة للابن كي يرث PATH وغيره.
الأوامر الداخلية مقابل الخارجية
| النوع | أمثلة | كيف يُنفَّذ | لماذا |
|---|---|---|---|
| خارجي | ls, cat | ابن جديد ثم execve | برنامج مستقل على القرص |
| داخلي | cd, exit, env | داخل عملية الشل نفسها | يجب أن يغيّر حالة الشل نفسه |
انظر لسبب كون cd داخليًا: لو نفّذته في ابن لغيّر مجلد الابن فقط ثم مات الابن، فيبقى الشل في مكانه. تغيير مجلد العمل يخص العملية التي تنفّذه، فلا بد أن تنفّذه عملية الشل. والمنطق نفسه ينطبق على تعديل متغيرات البيئة (setenv و unsetenv) والخروج.
الوضع التفاعلي وغير التفاعلي و EOF
في الوضع التفاعلي يطبع الشل موجّهًا وينتظر كتابتك. وفي غير التفاعلي تصله الأوامر من أنبوب أو ملف (مثل echo "ls" | ./hsh) ولا يُطبع موجّه. تفرّق بينهما الدالة isatty. ويجب أن يتصرف الشل في الحالتين بالمخرجات نفسها. أما EOF (نهاية الإدخال، وتُرسل بـ Ctrl+D) فتظهر كفشل قراءة السطر، وعندها يجب أن يخرج الشل بأناقة، مع تحرير الذاكرة.
قراءة السطر وتجزئته
تقرأ الدالة الجاهزة getline سطرًا كاملًا وتعيد -1 عند EOF. وإن طُلب منك كتابة دالتك: كل نداء read مكلف، فاقرأ كتلة كبيرة في مخزن ثابت (static) ثم وزّعها سطرًا سطرًا، لتقلل عدد النداءات. بعد القراءة تُجزَّأ الأسطر إلى كلمات عند المسافات (strtok مثلًا) لتكوين مصفوفة الوسائط التي تُمرَّر إلى execve.
حالة الخروج و wait
عند انتهاء الابن يضع wait حالته في متغير تمرره لها، وبه تعرف هل انتهى طبيعيًا ورمز خروجه. الرمز 0 يعني نجاحًا وأي رقم آخر يعني خطأ. وهذه الحالة هي ما تقوم عليه المعاملات المنطقية في الميزات المتقدمة. وإن مات الابن دون أن ينتظره الأب بقي عملية "زومبي" تشغل مدخلًا في جدول العمليات.
الميزات المتقدمة بنظرة مفهومية
| الميزة | الفكرة |
|---|---|
; | نفّذ الأوامر بالتتابع مهما كانت النتيجة |
&& | نفّذ التالي فقط إذا نجح السابق (رمز 0) |
|| | نفّذ التالي فقط إذا فشل السابق |
| alias | اسم بديل يستبدل بنصّ أمر قبل التنفيذ |
| المتغيرات | $? رمز خروج آخر أمر، $$ رقم عملية الشل، و $PATH قيمة متغير بيئة |
| التعليقات | ما بعد # يُهمل |
| history | سجل بالأوامر السابقة |
| ملف كمدخل | قراءة الأوامر من ملف يُمرَّر كوسيط |
شكل 7، حلقة الشل الكاملة: قراءة، تجزئة، توسيع، ثم تنفيذ داخلي أو خارجي.
جرّب بنفسك
ما الذي يميّز fork عن بقية نداءات C؟
بعد fork تتطابق العمليتان في كل شيء إلا القيمة المعادة، فهي الإشارة الوحيدة لمعرفة أيّ نسخة أنت.
ماذا يحدث للأسطر التي تلي execve عندما تنجح؟
execve تستبدل صورة العملية بالبرنامج الجديد ولا تعود إلا عند الفشل؛ لذلك يُكتب بعدها معالجة الخطأ فقط.
نسي شل تعليمي استدعاء waitpid في الأب. ماذا تتوقع؟
waitpid توقف الأب حتى ينتهي الابن. بدونها تتداخل المخرجات وقد تبقى عمليات يتيمة.
بطاقات مراجعة سريعة
تحقق من فهمك
لو فحصت pid == 0 لكن كتبت الكود بترتيب معكوس (execve في else بدل if)، ماذا يحدث؟
سيُنفَّذ execve في العملية الأم بدل الابنة، الشل نفسه سيُستبدَل ببرنامج الأمر ويختفي، بدل أن تبقى الأم حيّة تنتظر وتعرض موجّهًا جديدًا لاحقًا.
تشغّل أمرًا ويبدو أن الشل يعيد طباعة الموجّه قبل انتهاء الأمر؛ ما السبب المحتمل؟
الأب لا ينتظر ابنه، أي غاب wait أو استُدعي في مكان خاطئ.
مصادر هذا الدرس
مرتّبة بالنوع. ابدأ بالتوثيق الرسمي إذا تبي المرجع الدقيق، وبالمقال أو الفيديو إذا تبي الشرح أولًا.
توثيق رسمي2
خلّصت الدرس؟
سجّل الدخول ليُحفظ تقدّمك.