Programming Languages

शेल बनाने से Unix के बारे में क्या सीखते हैं

हर डेवलपर रोज़ शेल इस्तेमाल करता है। बहुत कम लोग समझते हैं कि यह असल में करता क्या है। शेल एक एप्लिकेशन जैसा दिखता है — आप कमांड टाइप करते हैं, यह उन्हें चलाता है — लेकिन असल में यह Unix प्रिमिटिव्स के एक सेट पर एक पतली परत है जो ऑपरेटिंग सिस्टम की कार्यप्रणाली का आधार हैं। शेल को स्क्रैच से बनाना प्रोसेस, फाइल डिस्क्रिप्टर, पाइप और सिग्नल को समझने के सबसे अच्छे तरीकों में से एक है — ये वो अवधारणाएँ हैं जो वेब सर्वर से लेकर Docker कंटेनर तक हर चीज़ का आधार हैं।

एक बेसिक शेल हैरानी की हद तक सरल है। मुख्य लूप है: इनपुट की एक लाइन पढ़ो, उसे कमांड और आर्ग्युमेंट्स में पार्स करो, एक चाइल्ड प्रोसेस fork करो, चाइल्ड में कमांड execute करो, और उसके खत्म होने का इंतज़ार करो। यह शायद C की 50 लाइनें हैं। जटिलता उन फीचर्स से आती है जो हम granted मानते हैं: पाइप, रीडायरेक्शन, बैकग्राउंड प्रोसेस, सिग्नल हैंडलिंग और जॉब कंट्रोल।

Read-Eval-Print लूप

अपने मूल में, शेल एक REPL है। इनपुट पढ़ो, evaluate (execute) करो, रिज़ल्ट प्रिंट करो, लूप करो। 'प्रिंट' वाला हिस्सा कमांड्स खुद संभालती हैं — शेल बस उन्हें चलने के लिए environment देता है।

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
// The simplest possible shell
int main(void) {
char line[1024];
while (1) {
printf("$ ");
if (!fgets(line, sizeof(line), stdin))
break;  // EOF (Ctrl+D)
// Remove trailing newline
line[strcspn(line, "\n")] = '\0';
// Fork a child process
pid_t pid = fork();
if (pid == 0) {
// Child: execute the command
execlp(line, line, NULL);  // This only handles single-word commands
perror("exec");
exit(1);
}
// Parent: wait for child to finish
waitpid(pid, NULL, 0);
}
return 0;
}

यह 25-लाइन का शेल वाकई काम करता है — यह ls, pwd, और date जैसे कमांड चला सकता है। यह आर्ग्युमेंट्स, पाइप, रीडायरेक्शन या कोई अन्य अपेक्षित फीचर हैंडल नहीं करता। लेकिन यह मूल पैटर्न दिखाता है: fork, exec, wait।

Fork और Exec: Unix प्रोसेस मॉडल

fork/exec का विभाजन Unix का सबसे विशिष्ट डिज़ाइन निर्णय है, और शेल बनाने से आप समझते हैं कि यह क्यों मौजूद है।

fork() मौजूदा प्रोसेस की एक हूबहू कॉपी बनाता है। चाइल्ड के पास वही मेमोरी, वही ओपन फाइल्स, वही environment variables होते हैं। exec() चाइल्ड के प्रोग्राम को एक नए प्रोग्राम से बदल देता है। ये अलग-अलग ऑपरेशन हैं क्योंकि इनके बीच का गैप — fork के बाद लेकिन exec से पहले — वह जगह है जहाँ शेल चाइल्ड का environment सेट अप करता है।

यही सबसे अहम बात है। जब आप ls > output.txt टाइप करते हैं, तो शेल fork करता है, फिर चाइल्ड में (exec से पहले) output.txt खोलता है और stdout को उसमें रीडायरेक्ट करता है, फिर ls exec करता है। ls प्रोग्राम को रीडायरेक्शन के बारे में कुछ पता नहीं — यह हमेशा की तरह stdout पर लिखता है, और fork और exec के बीच किया गया फाइल डिस्क्रिप्टर मैनिपुलेशन उस आउटपुट को फाइल में भेज देता है।

// How 'ls > output.txt' works
pid_t pid = fork();
if (pid == 0) {
// Child process — between fork and exec
// Open the output file
int fd = open("output.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
// Redirect stdout (fd 1) to the file
dup2(fd, STDOUT_FILENO);  // Now fd 1 points to output.txt
close(fd);                 // Close the original fd (no longer needed)
// exec ls — it writes to stdout, which now goes to output.txt
execlp("ls", "ls", NULL);
perror("exec");
exit(1);
}
waitpid(pid, NULL, 0);

यह डिज़ाइन इसलिए शानदार है क्योंकि यह compose होता है। चाइल्ड exec से पहले कोई भी environment सेट कर सकता है — फाइल्स रीडायरेक्ट करना, डायरेक्टरी बदलना, environment variables में बदलाव करना, resource limits सेट करना, user IDs बदलना — और executed प्रोग्राम उस environment को inherit करता है बिना इसके बारे में कुछ जाने। हर कमांड को एक पहले से कॉन्फ़िगर किया हुआ environment मिलता है, और शेल वह चीज़ है जो उसे कॉन्फ़िगर करता है।

पाइप: प्रोसेस को जोड़ना

पाइप Unix में सबसे शक्तिशाली composition मैकेनिज़्म हैं, और इन्हें implement करने से पता चलता है कि underlying मैकेनिज़्म कितना सरल है।

pipe() सिस्टम कॉल फाइल डिस्क्रिप्टर की एक जोड़ी बनाता है: एक पढ़ने के लिए, एक लिखने के लिए। write end पर लिखा डेटा read end पर दिखाई देता है। ls | grep foo implement करने के लिए, शेल एक पाइप बनाता है, दो बार fork करता है, ls के stdout को write end से और grep के stdin को read end से जोड़ता है।

// How 'ls | grep foo' works
int pipefd[2];
pipe(pipefd);  // pipefd[0] = read end, pipefd[1] = write end
pid_t pid1 = fork();
if (pid1 == 0) {
// First child: ls
close(pipefd[0]);              // Don't need read end
dup2(pipefd[1], STDOUT_FILENO); // stdout → pipe write end
close(pipefd[1]);
execlp("ls", "ls", NULL);
exit(1);
}
pid_t pid2 = fork();
if (pid2 == 0) {
// Second child: grep
close(pipefd[1]);              // Don't need write end
dup2(pipefd[0], STDIN_FILENO);  // stdin → pipe read end
close(pipefd[0]);
execlp("grep", "grep", "foo", NULL);
exit(1);
}
// Parent: close both pipe ends and wait
close(pipefd[0]);
close(pipefd[1]);
waitpid(pid1, NULL, 0);
waitpid(pid2, NULL, 0);

अप्रयुक्त फाइल डिस्क्रिप्टर ends को सावधानी से बंद करने पर ध्यान दें। यह बेहद ज़रूरी है: अगर parent दोनों pipe ends बंद नहीं करता, तो grep को अपने stdin पर कभी EOF नहीं दिखेगा (क्योंकि write end अभी भी parent में खुला है) और यह हमेशा के लिए अटक जाएगा। पाइप चेन में फाइल डिस्क्रिप्टर लीक शेल बनाते समय सबसे आम बग्स में से एक है।

Built-in कमांड्स

कुछ कमांड्स बाहरी प्रोग्राम नहीं हो सकतीं। cd इसका क्लासिक उदाहरण है। अगर शेल एक चाइल्ड fork करे और चाइल्ड chdir() कॉल करे, तो सिर्फ चाइल्ड की working directory बदलती है — parent की directory अप्रभावित रहती है, और चाइल्ड के exit होने के बाद शेल अभी भी उसी directory में होता है। cd काम करे इसके लिए, शेल को इसे बिना fork किए अपनी खुद की प्रोसेस में execute करना होता है।

अन्य built-in कमांड्स में export (शेल के environment को modify करना), exit (शेल प्रोसेस को terminate करना), और source (मौजूदा शेल context में स्क्रिप्ट execute करना) शामिल हैं। ये सभी शेल की अपनी state modify करती हैं, जो सिर्फ शेल की अपनी प्रोसेस में ही हो सकता है।

यह समझना कि कौन सी कमांड्स built-in हैं और क्यों, आपको प्रोसेस isolation के बारे में कुछ बुनियादी सिखाता है। एक चाइल्ड प्रोसेस अपने parent को modify नहीं कर सकती। यह एक security फीचर है, एक reliability फीचर है, और कभी-कभी एक असुविधा — लेकिन यह Unix प्रोसेस की कार्यप्रणाली का मूल है।

सिग्नल और जॉब कंट्रोल

अपने टर्मिनल में Ctrl+C दबाएँ और चल रहा कमांड रुक जाता है। यह सरल लगता है लेकिन इसमें टर्मिनल, शेल, सिग्नल और प्रोसेस ग्रुप्स के बीच एक हैरानी की हद तक जटिल interaction शामिल है।

Ctrl+C foreground प्रोसेस ग्रुप को SIGINT भेजता है। टर्मिनल ड्राइवर इसे हैंडल करता है — शेल नहीं। शेल का काम है हर कमांड को उसके अपने प्रोसेस ग्रुप में डालना ताकि SIGINT कमांड को जाए, शेल को नहीं। अगर शेल प्रोसेस ग्रुप्स सही से सेट अप नहीं करता, तो Ctrl+C चल रहे कमांड की बजाय शेल को kill कर देता है।

जॉब कंट्रोल — बैकग्राउंड प्रोसेस (&), fg, bg, Ctrl+Z — एक और परत जोड़ता है। शेल को ट्रैक करना होता है कि कौन सी प्रोसेस किस जॉब से संबंधित है, foreground बनाम background ग्रुप्स मैनेज करने होते हैं, और SIGTSTP (Ctrl+Z, suspend) और SIGCHLD (चाइल्ड प्रोसेस terminate हुई) जैसे सिग्नल हैंडल करने होते हैं। इसे सही तरीके से implement करना शेल बनाने का सबसे कठिन हिस्सा है।

आप क्या सीखते हैं

शेल बनाना आपको ऐसी अवधारणाएँ सिखाता है जो सॉफ्टवेयर डेवलपमेंट में लगातार सामने आती हैं, भले ही आप दोबारा कभी सिस्टम्स कोड न लिखें।

  • फाइल डिस्क्रिप्टर universal interface हैं। फाइल्स, पाइप, सॉकेट, टर्मिनल — ये सब फाइल डिस्क्रिप्टर हैं। रीडायरेक्शन, पाइपिंग और नेटवर्क कम्युनिकेशन सभी एक ही underlying मैकेनिज़्म का उपयोग करते हैं। इसे समझने से Docker नेटवर्किंग से लेकर Unix domain sockets से लेकर Linux सिस्टम APIs तक सब कुछ स्पष्ट हो जाता है।
  • प्रोसेस isolation बुनियादी है। एक चाइल्ड प्रोसेस अपने parent को modify नहीं कर सकती। Environment variables, working directory और open files inherited कॉपीज़ हैं, shared references नहीं। इसीलिए subshell में cd parent को affect नहीं करता, और इसीलिए Docker कंटेनर host के environment को inherit करते हैं लेकिन share नहीं करते।
  • Composition फीचर्स से बेहतर है। Unix के पास 'पैटर्न मैच करने वाली फाइलें खोजो और उन्हें गिनो' जैसा कोई कमांड नहीं है। इसके पास find, grep, और wc हैं, जो पाइप से जुड़े हैं। शेल का पाइप मैकेनिज़्म सरल टूल्स को जटिल workflows में compose करने योग्य बनाता है। यह डिज़ाइन दर्शन — छोटे टूल्स जो standard interfaces से जुड़े हों — microservices, Unix sockets और API डिज़ाइन का बौद्धिक पूर्वज है।
  • Error handling मूलतः फाइल डिस्क्रिप्टर के बारे में है। जब पाइप टूटता है, जब चाइल्ड प्रोसेस crash होती है, जब stdin खत्म हो जाता है — underlying मैकेनिज़्म हमेशा फाइल डिस्क्रिप्टर का बंद होना, सिग्नल का deliver होना, या प्रोसेस का status codes के साथ exit होना है। एक बार फाइल डिस्क्रिप्टर मॉडल समझ लें, तो Unix सिस्टम्स में error handling पैटर्न सहज हो जाते हैं।

कहाँ से शुरू करें

अगर आप शेल बनाना चाहते हैं, तो ऊपर दिए गए 25-लाइन वर्शन से शुरू करें और धीरे-धीरे फीचर्स जोड़ें। पहले: आर्ग्युमेंट पार्सिंग (इनपुट को spaces पर split करें)। फिर: I/O रीडायरेक्शन (> और <)। फिर: पाइप। फिर: built-in कमांड्स (cd, exit)। फिर: environment variables। हर फीचर एक नई Unix अवधारणा सिखाता है, और हर एक अपने आप में एक स्वतंत्र अभ्यास है।

सिस्टम कॉल्स से सबसे सीधे मैपिंग के लिए C इस्तेमाल करें। आप Python या Rust में भी शेल बना सकते हैं, लेकिन C implementation underlying सिस्टम कॉल्स को स्पष्ट बनाता है — आप बिल्कुल देख सकते हैं कि fork(), exec(), dup2(), और pipe() क्या करते हैं क्योंकि आप उन्हें सीधे कॉल कर रहे हैं।

आपको production शेल बनाने की ज़रूरत नहीं। एक toy शेल भी जो बेसिक कमांड्स, पाइप और रीडायरेक्शन हैंडल करे, आपको Unix की कार्यप्रणाली के बारे में सालों तक शेल इस्तेमाल करने से ज़्यादा सिखाता है। शेल सबसे सरल प्रोग्राम है जो सबसे महत्वपूर्ण ऑपरेटिंग सिस्टम interfaces का उपयोग करता है — और उन interfaces को समझना आपको एक बेहतर डेवलपर बनाता है, चाहे आप किसी भी भाषा या प्लेटफ़ॉर्म पर काम करें।