Build a Shell
Build a Shell
fork/exec/wait দিয়ে শুরু করে pipeline, redirection, আর job control পর্যন্ত — একটা কাজ করা Unix shell। প্রতিটা ফিচার আসলে দুই-তিনটা syscall-এর সঠিক ক্রম, আর সেই ক্রমটা ভুল হলে ঠিক কী ভাঙে সেটা হাতে-কলমে দেখা।
কেন এই প্রজেক্ট
একটা shell দেখতে সরল — prompt, command, output। কিন্তু ভেতরে এটা Level 4-এর প্রায় প্রতিটা ধারণার সমাবেশ: process creation, file descriptor, pipe, signal, process group, terminal control।
আর সবচেয়ে গুরুত্বপূর্ণ — shell-এর প্রতিটা ফিচার আসলে কয়েকটা syscall-এর একটা নির্দিষ্ট ক্রম। ক্রমটা ভুল হলে জিনিসটা কাজ করে না, কিন্তু error message-ও দেয় না — শুধু ঝুলে থাকে বা অদ্ভুত আচরণ করে। সেই ব্যর্থতাগুলো নিজে ঘটিয়ে দেখাই এই প্রজেক্টের আসল শিক্ষা।
আপনি প্রতিদিন ls | grep foo > out.txt লেখেন। এই প্রজেক্টের
পর আপনি জানবেন সেই একটা লাইনের পেছনে ঠিক কয়টা syscall চলে,
আর কোন ক্রমে।
ধাপে ধাপে
১. REPL
while (1) {
printf("mysh$ ");
fflush(stdout);
if (!fgets(line, sizeof line, stdin)) break; /* Ctrl-D → EOF */
/* tokenise, dispatch */
}
cd কে built-in হতেই হবে — কেন? কারণ chdir process-এর নিজের
অবস্থা বদলায়। Child-এ chdir করলে child মরে গেলেই সেটা হারিয়ে
যায়। এটা একটা চমৎকার প্রশ্ন যেটা অনেকে প্রথমে ধরতে পারে না।
২. Fork + exec
pid_t pid = fork();
if (pid == 0) {
execvp(argv[0], argv);
perror("execvp"); /* এখানে পৌঁছানো মানে exec ব্যর্থ */
_exit(127); /* exit() নয় — _exit() */
}
int status;
waitpid(pid, &status, 0);
_exit বনাম exit লক্ষ্য করুন: exit() atexit handler চালায় আর
stdio buffer flush করে — fork করা child-এ সেটা parent-এর buffered
output দ্বিতীয়বার ছাপাতে পারে।
৩. Redirection
int fd = open("out.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
dup2(fd, STDOUT_FILENO);
close(fd); /* dup2-এর পর মূলটা আর দরকার নেই */
dup2 করার পরে close(fd) — কারণ dup2 fd 1 কে একই open
file description-এ point করিয়ে দিয়েছে, তাই আসল fd অতিরিক্ত।
৪. Pipe — যেখানে সবাই আটকায়
int fds[2];
pipe(fds); /* fds[0] read, fds[1] write */
if (fork() == 0) { /* বাঁ পাশ */
dup2(fds[1], STDOUT_FILENO);
close(fds[0]); /* ⚠ অব্যবহৃত read end */
close(fds[1]);
execvp(...);
}
if (fork() == 0) { /* ডান পাশ */
dup2(fds[0], STDIN_FILENO);
close(fds[0]);
close(fds[1]); /* ⚠ অব্যবহৃত write end */
execvp(...);
}
close(fds[0]); /* ⚠ parent-ও বন্ধ করবে */
close(fds[1]);
তিন জায়গায় close করতে হয়। একটাও বাদ পড়লে pipe-এর write end
খোলা থেকে যায়, তাই reader কখনো EOF পায় না — grep চিরকাল
অপেক্ষা করবে।
এই bug-টা ইচ্ছে করে ঘটান। একটা close মন্তব্য করে দিয়ে
ls | head -1 চালান — hang হবে। তারপর অন্য terminal থেকে
ls -l /proc/<pid>/fd দেখুন, খোলা fd-টা চোখে পড়বে।
৫. Job control
সবচেয়ে কঠিন অংশ, আর সবচেয়ে কম বোঝা।
setpgid(pid, pid); /* child কে নিজের process group-এ */
tcsetpgrp(STDIN_FILENO, pid); /* terminal-এর foreground group */
Terminal driver Ctrl-C পেলে SIGINT পাঠায় foreground process
group-কে। তাই shell-কে নিজেকে SIGINT থেকে বাঁচাতে হবে
(signal(SIGINT, SIG_IGN)) কিন্তু job-কে পেতে দিতে হবে।
Background job terminal থেকে পড়তে চাইলে SIGTTIN পায় আর থেমে যায় —
এটাই কেন sleep 100 & ঠিক আছে কিন্তু cat & থেমে যায়।
নিজেকে চ্যালেঞ্জ করুন
2>&1— stderr কে stdout-এ merge, আর ক্রম কেন গুরুত্বপূর্ণ (cmd > f 2>&1বনামcmd 2>&1 > f— দুটো ভিন্ন জিনিস, কেন?)- Here-document (
\<\<EOF) — একটা temp file বা pipe দিয়ে $?,$$, environment variable expansion, আরexport- Command substitution
$(...)— recursion লাগবে - Tab completion —
readlinelibrary দিয়ে, বা নিজে termios raw mode-এ &&আর||— exit status দেখে short-circuit- History আর
!!
যা এখানে শিখলেন, পরে যেখানে লাগবে
| এখানে | পরে |
|---|---|
| fork/exec/wait | Level 12 — container runtime-ও ঠিক এটাই করে, শুধু namespace flag সহ |
| fd hygiene | Level 7 — socket server-এ fd leak একই ভাবে ধরা পড়ে |
| Process group ও signal | Level 12 — Kubernetes-এ PID 1 আর signal forwarding |
| Pipeline | Level 8 — query executor-ও একটা pipeline, একই backpressure ধারণা |