Apa yang Diajarkan Membangun Shell tentang Unix
Membangun shell dari nol mengungkap cara kerja Unix: proses, file descriptor, pipe, dan sinyal. Panduan singkat konsep intinya.

Setiap developer memakai shell setiap hari. Tapi sedikit yang benar-benar paham apa yang sebenarnya dilakukannya. Shell kelihatan seperti aplikasi biasa: kamu mengetik perintah, lalu perintah itu dijalankan. Padahal ia sebenarnya lapisan tipis di atas sekumpulan primitif Unix yang menjadi fondasi cara kerja sistem operasi. Membangun shell dari nol adalah salah satu cara terbaik untuk memahami proses, file descriptor, pipe, dan sinyal, konsep-konsep yang mendasari segalanya, dari web server sampai container Docker.
Shell dasar ternyata sangat sederhana. Loop intinya: baca satu baris input, urai menjadi perintah dan argumen, fork proses anak, jalankan perintah di proses anak, lalu tunggu sampai selesai. Kira-kira 50 baris C. Kerumitannya datang dari fitur-fitur yang sering kita anggap biasa saja: pipe, redirection, proses latar belakang, penanganan sinyal, dan job control.
Loop Read-Eval-Print
Pada intinya, shell itu REPL. Baca input, evaluasi (jalankan), cetak hasilnya, lalu ulangi. Bagian 'print' sebenarnya ditangani oleh perintahnya sendiri. Shell hanya menyediakan lingkungan tempat perintah itu berjalan.
#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;
}
Shell 25 baris ini benar-benar bisa jalan, bisa menjalankan perintah seperti ls, pwd, dan date. Tapi ia tidak menangani argumen, pipe, redirection, atau fitur lain yang biasa kamu harapkan. Meski begitu, ia sudah menunjukkan pola dasarnya: fork, exec, wait.
Fork dan Exec: Model Proses Unix
Pemisahan fork/exec adalah keputusan desain Unix yang paling khas, dan membangun shell membuatmu paham alasan keberadaannya.
fork() membuat salinan persis dari proses saat ini. Proses anak punya memori, file terbuka, dan variabel environment yang sama. exec() menggantikan program proses anak dengan program baru. Keduanya dipisah karena celah di antara mereka, yaitu setelah fork tapi sebelum exec, adalah tempat shell menyiapkan environment untuk proses anak.
Ini inti pemahamannya. Saat kamu mengetik ls > output.txt, shell melakukan fork, lalu di proses anak (sebelum exec) ia membuka output.txt dan mengarahkan stdout ke sana, baru kemudian mengeksekusi ls. Program ls tidak tahu soal redirection itu. Ia tetap menulis ke stdout seperti biasa, dan manipulasi file descriptor di antara fork dan exec yang mengarahkan output ke file.
// 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);
Desain ini elegan karena bisa dikomposisikan. Proses anak bisa menyiapkan environment apa pun sebelum exec: mengarahkan file, mengganti direktori, mengubah variabel environment, mengatur batas resource, atau mengganti user ID. Program yang dijalankan mewarisi environment itu tanpa perlu tahu apa-apa soal prosesnya. Setiap perintah mendapat environment yang sudah disiapkan, dan shell-lah yang menyiapkannya.
Pipe: Menghubungkan Proses
Pipe adalah mekanisme komposisi paling kuat di Unix, dan mengimplementasikannya memperlihatkan betapa sederhananya mekanisme dasarnya.
System call pipe() membuat sepasang file descriptor: satu untuk membaca, satu untuk menulis. Data yang ditulis ke ujung tulis akan muncul di ujung baca. Untuk mengimplementasikan ls | grep foo, shell membuat pipe, melakukan fork dua kali, lalu menghubungkan stdout ls ke ujung tulis dan stdin grep ke ujung baca.
// 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);
Perhatikan penutupan ujung file descriptor yang tidak terpakai. Ini krusial: jika proses induk tidak menutup kedua ujung pipe, grep tidak akan pernah menerima EOF di stdin-nya (karena ujung tulis masih terbuka di induk) dan akan hang selamanya. Kebocoran file descriptor dalam rantai pipe adalah salah satu bug paling umum saat membangun shell.
Perintah Built-in
Beberapa perintah tidak bisa berupa program eksternal. cd adalah contoh klasiknya. Jika shell melakukan fork dan proses anak memanggil chdir(), hanya direktori kerja proses anak yang berubah. Direktori induk tidak terpengaruh, dan setelah proses anak selesai, shell tetap berada di direktori yang sama. Agar cd berfungsi, shell harus menjalankannya di dalam prosesnya sendiri tanpa fork.
Perintah built-in lainnya antara lain export (mengubah environment shell), exit (mengakhiri proses shell), dan source (menjalankan skrip di konteks shell saat ini). Semuanya memodifikasi state shell itu sendiri, dan itu hanya bisa terjadi di dalam proses shell.
Memahami perintah mana yang built-in dan alasannya akan mengajarkanmu sesuatu yang mendasar tentang isolasi proses. Proses anak tidak bisa memodifikasi induknya. Ini fitur keamanan, fitur keandalan, dan kadang merepotkan, tapi inilah inti cara kerja proses Unix.
Sinyal dan Job Control
Tekan Ctrl+C di terminal dan perintah yang sedang berjalan akan berhenti. Kelihatannya sederhana, tapi di baliknya ada interaksi yang cukup rumit antara terminal, shell, sinyal, dan process group.
Ctrl+C mengirim SIGINT ke process group foreground. Yang menanganinya adalah terminal driver, bukan shell. Tugas shell adalah menempatkan setiap perintah ke process group-nya sendiri supaya SIGINT sampai ke perintah, bukan ke shell. Jika shell tidak mengatur process group dengan benar, Ctrl+C justru membunuh shell, bukan perintah yang sedang berjalan.
Job control (proses latar belakang &, fg, bg, Ctrl+Z) menambah lapisan kompleksitas lagi. Shell perlu melacak proses mana milik job mana, mengelola grup foreground dan background, serta menangani sinyal seperti SIGTSTP (Ctrl+Z, suspend) dan SIGCHLD (proses anak berakhir). Mengimplementasikannya dengan benar adalah bagian tersulit dalam membangun shell.
Apa yang Kamu Pelajari
Membangun shell mengajarkan konsep-konsep yang terus muncul dalam pengembangan perangkat lunak, meskipun kamu tidak pernah menulis kode sistem lagi.
- File descriptor adalah antarmuka universal. File, pipe, socket, terminal, semuanya file descriptor. Redirection, piping, dan komunikasi jaringan memakai mekanisme dasar yang sama. Memahami ini membuat hal-hal seperti networking Docker, Unix domain socket, sampai API sistem Linux terasa lebih jelas.
- Isolasi proses itu fundamental. Proses anak tidak bisa memodifikasi induknya. Variabel environment, direktori kerja, dan file terbuka diwarisi sebagai salinan, bukan referensi bersama. Inilah sebabnya
cddi subshell tidak memengaruhi induknya, dan mengapa container Docker mewarisi environment host tapi tidak membagikannya. - Komposisi mengalahkan fitur. Unix tidak punya perintah 'cari file yang cocok dengan pola lalu hitung jumlahnya'. Yang ada adalah
find,grep, danwc, yang dihubungkan dengan pipe. Mekanisme pipe di shell membuat alat-alat sederhana bisa dirangkai menjadi alur kerja yang kompleks. Filosofi desain ini, yaitu alat kecil yang dihubungkan lewat antarmuka standar, adalah nenek moyang intelektual dari microservices, Unix socket, dan desain API. - Penanganan error pada dasarnya soal file descriptor. Ketika pipe putus, proses anak crash, atau stdin habis, mekanisme di baliknya selalu berupa file descriptor yang ditutup, sinyal yang dikirim, atau proses yang berakhir dengan kode status. Setelah memahami model file descriptor, pola penanganan error di sistem Unix mana pun akan terasa intuitif.
Mulai dari Mana
Kalau kamu ingin membangun shell, mulailah dari versi 25 baris di atas lalu tambahkan fitur secara bertahap. Pertama: parsing argumen (pisahkan input berdasarkan spasi). Lalu: redirection I/O (> dan <). Lalu: pipe. Lalu: perintah built-in (cd, exit). Lalu: variabel environment. Setiap fitur mengajarkan konsep Unix baru, dan masing-masing adalah latihan yang berdiri sendiri.
Gunakan C agar pemetaannya ke system call paling langsung. Kamu bisa membangun shell dengan Python atau Rust, tapi implementasi C membuat system call di baliknya terlihat jelas. Kamu bisa langsung melihat apa yang dilakukan fork(), exec(), dup2(), dan pipe() karena kamu memanggilnya secara langsung.
Kamu tidak perlu membangun shell untuk production. Bahkan shell mainan yang hanya menangani perintah dasar, pipe, dan redirection sudah mengajarkanmu lebih banyak tentang cara kerja Unix daripada bertahun-tahun memakai shell yang sudah jadi. Shell adalah program paling sederhana yang sekaligus menguji antarmuka sistem operasi terpenting, dan memahami antarmuka itu membuatmu menjadi developer yang lebih baik, apa pun bahasa atau platform yang kamu pakai.


