نوشتن یک Shell به زبان C

در دروس دانشگاهی، معمولا نخستین زبان برنامه‌نویسی که تدریس می‌شود زبان C بوده و یکی از تکالیف متداول این درس، طراحی محیطی برای درستور‌نویسی همچون ترمینال (که Shell نامیده می‌شود) است. در این مقاله به بررسی چگونگی پیاده‌سازی چنین نرم‌افزاری خواهیم پرداخت.

30 اردیبهشت‌ماه 1396
محمد فرهمند

محیط دستور نویسی POSIXـی یکی از محبوب‌ترین ابزار‌های سیستم‌عامل‌های خانواده Linux است. اگرچه این برنامه متشکل از ده‌ها هزار خط دستور است، ایده کلی پشت آن نسبتا ساده است. در این مقاله می‌خواهیم یک محیط دستور‌نویسی ساده را با زبان C پیاده کنیم.

عملکرد یک Shell یا محیط دستور نویسی

کارهایی که یک Shell در مدت زمان اجرای خود انجام می‌دهد را می توان در سه دسته کلی زیر قرار داد:

  • آغاز اجرا: این مرحله که با خواندن پرونده های مربوط به پیکربندی (Configuration) محیط دستور نویسی همراه است رفتار آن را مشخص می سازد.
  • ترجمه: برنامه هر بار از کاربر دستوری خوانده و کدهای مربوط به آن را اجرا می کند.
  • پایان کار: پس از آنکه اجرای دستورات به پایان رسید و کاربر دستور مربوط به خروج را اجرا کرد برنامه بایستی دستورهای پیش از خروج را اجرا کرده و حافظه مصرفی را آزاد کند.

مراحل بالا به قدری کلی بیان شده اند که در مورد برنامه های بسیاری صدق می کنند. محیط دستور نویسی ما به قدری ساده است که به خواندن پرونده Config و یا اجرای دستورات خاص در هنگام اتمام برنامه احتیاجی ندارد. بنابرین تنها قسمت باقی مانده چرخه مربوط به خواندن دستورات است:

در دستور فوق از تابع lsh_loop استفاده شده است که وظیفه انجام یک چرخه برای خواندن و ترجمه (Interpret) دستورات وارد‌شده توسط کاربر را دارد. در ادامه پیاده‌سازی (Implementation) این تابع را نیز بررسی خواهیم کرد.

حلقه اصلی برنامه

همانطور که گفته شد حلقه اصلی وظیفه خواندن دستورات و ترجمه آن‌ها را بر عهده دارد. اما برنامه در هر تکرار (Iteration) حلقه موظف به انجام چه کاریست؟ به طور ساده، این مراحل را می‌توان به سه مرحله‌ی خواندن دستور، تکه‌تکه‌سازی آن و اجرای دستور تقسیم کرد. این مراحل را به صورت زیر پیاده می‌کنیم:

چند خط اول این تابع که تنها تعریف متغیر‌هاست. پس از آن از چرخه do while استفاده می‌کنیم زیرا یک بار اجرا شده و پس از آن status را چک می‌کند. سپس علامتی را برای مشخص‌کردن آغاز خط دستور (Prompt) نمایش داده (که معمولا ؟ است) و تابعی مناسب برای خواندن خط وارد شده را فراخوانی می‌کنیم. سپس مرحله تکه‌تکه‌سازی جمله و تقسیم آن به قطعاتی برای درک ساده‌تر و بهتر آن توسط توابع را نیز به کمک تابعی دیگر انجام داده و نهایتا آن را برای اجرا به تابع تعبیه‌شده پاس می‌دهیم. پس از آن حافظه دینامیک اختصاص‌داده‌شده به این متغیر‌ها را آزاد می‌کنیم.

خواندن دستور

خواندن یک خط از کاربر به نظر ساده می‌رسد اما در C می‌تواند یک کابوس باشد! مشکل اصلی بر سر راه آن است که شما نمی‌دانید خطی که کاربر وارد می‌کند چه طولی داشته و به چه مقدار حافظه برای ذخیره‌سازی نیاز دارد. نمی‌توان یک بلاک حافظه با اندازه دلخواهی اختصاص داده و امیدوار بود کاربر عبارتی بزرگ‌تر از آن وارد نکند! به جای آن از یک استراتژی متداول در C استفاده می‌کنیم؛ بلاکی از حافظه اختصاص داده و اگر کاربر عبارتی بزرگ‌تر از آن وارد کرد، حافظه مربوطه را Reallocate کرده و بلاک اختصاص‌یافته را بزرگ‌تر می‌کنیم:

در تابع فوق، طبق معمول ابتدا متغیر‌ها را تعریف می‌کنیم. اصل کار تابع درون چرخه (به ظاهر) ابدی while صورت می‌پذیرد. درون چرخه یک کاراکتر را از stdin (ورودی کاربر) خوانده و درون متغیری از نوع int ذخیره می‌کنیم. این کار برای شناسایی EOF انجام می‌شود و نهایتا کاراکتر را به صورت char ذخیره خواهیم کرد. به همین ترتیب یک یک کاراکتر‌ها را به رشته اضافه می‌کنیم. هنگامی که به کاراکتر خط تازه (n\) و یا EOF بر می‌خوریم رشته را با کاراکتر NULL به پایان رسانده و آن را باز می‌گردانیم.
در هر مرحله بررسی می‌‌کنیم که آیا کاراکتر بعدی از بازه اختصاص یافته حافظه خارج می‌شود یا نه. در این صورت حافظه مربوط به رشته را Reallocate کرده و افزایش می‌دهیم. شاید بپرسید با وجود توابعی همچون getline متحمل‌شدن این همه دردسر برای چه بود؟! خب، با این کار ما می‌توانیم تمام کاراکتر‌هایی که کاربر وارد می‌کند را خوانده و چیزی را جا نیندازیم. تابع getline اگرچه با گرفتن اندازه بافر از Overflow جلوگیری می‌کند اما ممکن است بخشی از عبارتی که کاربر وارد کرده است را از دست بدهد.

تکه‌تکه‎سازی خط خوانده‌شده

حال که خط وارد شده توسط کاربر را خواندیم باید آن را به کلمات تشکیل‌دهنده آن تقسیم کنیم تا بتوانیم از آن‌ها به عنوان آرگومان‌های دستورات استفاده کنیم. برای سادگی کار تنها White Space‌ها را به عنوان جدا‌کننده کلمات در نظر می‌گیریم و از اصول معمولی همچون یک تکه کردن آرگومان‌ها با ” و یا کاراکتر‌های Backslash Escaping صرف نظر می‌کنیم. اتکه‌تکه‌سازی رشته را به کمک تابع strtok از کتابخانه string.h انجام داد:

اگر به نظرتان کد بالا شباهت زیادی به lsh_read_line دارد برای این است که همینطور است! در این تابع از استراتژی مشابهی برای مدیریت حافظه و خواندن تمامی آرگومان‌های وارد شده استفاده کرده‌ایم. با این تفاوت که به جای آرایه‌ای از کاراکتر‌ها که به NULL ختم شده است آرایه‌ای از اشاره‌گر‌ها (Pointers) که به NULL ختم شده است داریم. تابع strtok اشاره‌گری به یک رشته حاوی بخشی از رشته اصلی که بین دو Delimiter قرار گرفته است و طبق استاندارد C به NULL ختم شده است بر می‌گرداند که درون آرایه‌مان ذخیره می‌کنیم.

حال ما آرایه‌ای از آرگومان‌هایمان داریم و آماده اجرای دستورات هستیم. این مسئله سوالی را به همراه دارد: چطور این کار را انجام دهیم؟!

انجام پردازش

هر یک از دستورهای Shell می‌بایست برنامه یا مجموعه‌ای از برنامه‌ها را در قالب یک پردازش انجام دهد. پس پیش از آنکه بتوانیم این بخش را طراحی کنیم، لازم است اندکی درباره پردازش‌ها در Unix توضیح دهیم.

در Unix دو روش برای آغاز یک پردازش وجود دارد. روش یک (که تقریبا هیچ کاربردی ندارد) Init نام دارد. این سبک از پردازش تنها توسط Kernel سیستم‌عامل و برنامه‌های بسیار معدودی صورت می‌پذیرد و در تمام زمان روشن‌بودن سیستم فعال است. پردازش دیگر fork است؛ هنگامی که این تابع فراخوانی می‌شود، سیستم‌عامل پردازش فعلی را تکثیر کرده و پردازش دیگری که فرزند آن نامیده می‌شود ایجاد می‌کند. این تابع به پردازش فرزند 0 و به پردازش والد PID یا Proccess ID number را باز می‌گرداند. در نگاه اول، شاید فکر کنید این عمل با خواست ما مغایرت دارد. ما به پردازشی تازه نیاز داریم نه یک کپی از برنامه فعلی. اینجاست که تابع سیستمی exec به کار می‌آید. این تابع پردازشی فعلی را متوقف ساخته، برنامه‌ای تازه باز کرده و پردازش تازه را در آن آغاز می‌کند.
حال بایستی با ساختمان برنامه‌های Unix آشنا شده باشید؛ ابتدا یک پردازش موجود fork شده و کپی از خود تولید می‌کند. سپس پردازش فرزند از exec استفاده می‌کند تا خود را جایگزین برنامه اصلی کند. در این حین پردازش والد می‌تواند به کار خود ادامه دهد و در صورت نیاز پردازش‌های فرزند خود را به کمک تابع سیستمی wait مدیریت کند.

با توضیحات فوق، تابع بالا باید واضح باشد. قصد داریم هر دستور را درون یک پردازش فرزند انجام دهیم. برای اجرای هر دستور، از نگارش‌های گوناگون تابع exec تابع execvp را استفاده کرده‌ایم که نام برنامه و آرگومان‌های آن را گرفته، در میان برنامه‌های خود به دنبال آن برنامه جستجو کرده و سپس در صورت وجود آن را با آرگومان‌هایی که به برنامه داده‌ایم اجرا خواهد کرد. در صورتی که این تابع مقدار -1 را بازگرداند یعنی خطایی رخ داده که به کمک تابع perror قابل نمایش است. این تابع یک رشته به عنوان آرگومان دریافت می‌کند که پیش از متن هر خطا همراه با علامت : نمایش می‌یابد.
شرط دوم موفق‌بودن Forking را بررسی کرده و در صورت بروز خطا (که با برگرداندن مقداری منفی اطلاع داده می‌شود) پیام مناسب را به کمک perror چاپ می‌کند. امکان مدیریت این خطا ورای نمایش یک پیام وجود ندارد و بستگی به کاربر دارد که پس از آن تصمیم به خروج بگیرد و یا از خطای پیش آمده صرف نظر کند.

شرط سوم در صورتی اجرا خواهد شد که Forking موفق بوده باشد. در این صورت waitpit و ماکروهای آن پردازش والد را تا زمان به اتمام رسیدن پردازش فرزند (که ممکن است به صورت عادی Exit کرده و یا با ارسال یک سیگنال Terminate شده باشد) متوقف می‌کنیم. در آخر تابع مقدار 1 را به تابع فراخواننده باز می‌گرداند که به معنای آن است که باید دستور بعدی را دریافت کنیم.

دستورات درون‌سازی‌شده (Built-in)

برخی از دستورات هستند که نباید در قالب پردازشی مجزا صورت پذیرند چرا که در عملکرد برنامه اصلی موثر هستند. همچون دستور cd که برای تغییر Directory اجرای برنامه صورت می‌پذیرد و در صورتی که در پردازشی مجزا رخ دهد، بدون آنکه در عملکرد برنامه تاثیری بگذارد، دیرکتوری پردازش فرزند را تغییر می‌دهد که با پایان آن پردازش عملا بی‌ثمر می‌شود. همچنین دستوری مانند exit باید برنامه را خاتمه دهد و نه پردازش فرزندی را؛ بنابرین لازم است تعدادی از دستورات را خودمان پیاده‌سازی کنیم. در این مثال سه دستور cd، exit و help را درون‌سازی خواهیم کرد:

کد بالا شامل سه بخش است: Forward Declaration توابع مربوط به دستورات، آرایه‌ای از نام دستورات و آرایه‌ای از اشاره‌گر به تابع مربوط به هر دستور با ترتیب یکسان، و در آخر تعریف هر یک از این توابع است. روش استفاده شده برای ارتباط دستورات و توابع آن‌ها Check List نامیده می‌شود. در این روش افزودن دستورات نسبت به روش‌هایی همچون Switch Case ساده‌تر است. در ادامه شیوه کار با Check List را نیز بررسی خواهیم کرد.

اتصال دستورات درون‌سازی‌شده به توابع مربوط به آن‌ها

آخرین قطعه برنامه ما که به ازای هر دستور، تابعی درون‌سازی‌شده را فراخوانی کرده و یا پردازشی تازه ایجاد می‌کند و برنامه‌ای از سیستم را در آن اجرا می‌کند تابع ساده‌ی زیر است:

این تابع در ابتدا خالی نبودن دستور وارد شده توسط کاربر را بررسی می‌کند و پس از آن سعی در تطابق دستور وارد شده با یکی از دستورات درون‌سازی‌شده می‌کند. در صورتی که هیچ دستور درونی برای آن نیافت، با استفاده از تابع launch پردازشی ایجاد کرده و برنامه سیستمی مربوطه را فراخوانی و اجرا می‌کند.

سر هم کردن برنامه

اگر مقاله را تا اینجا دنبال کرده باشید، می‌دانید که برنامه چطور کار می‌کند. برای آزمایش برنامه (به روی سیستم‌عامل Linux) تمامی توابع فوق را درون یک فایل با پسوند c با نامی مثل shell.c ذخیره کرده و پس از Include کتاب‌خانه‌های مورد نیاز (که در ادامه لیست شده‌اند) فایل را با دستوری مانند gcc -o shell shell.c کامپایل کرده و سپس با ./shell اجرا کنید.

  • #include <sys/wait.h>

    • waitpid() و ماکرو‌های مربوط به آن

  • #include <unistd.h>

    • chdir()

    • fork()

    • exec()

    • pid_t

  • #include <stdlib.h>

    • malloc()

    • realloc()

    • free()

    • exit()

    • execvp()

    • EXIT_SUCCESS, EXIT_FAILURE

  • #include <stdio.h>

    • fprintf()

    • printf()

    • stderr

    • getchar()

    • perror()

  • #include <string.h>

    • strcmp()

    • strtok()

همچنین می‌توانید کد کامل برنامه را در GitHub مطالعه کنید.

ترجمه‌شده از: Tutorial – Write a Shell in C

دیدگاهتان را بنویسید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *