Что создание шелла учит нас о Unix
Создание шелла с нуля показывает, как устроен Unix: процессы, файловые дескрипторы, пайпы и сигналы. Разбор ключевых концепций.

Каждый разработчик ежедневно пользуется шеллом, но мало кто понимает, что он на самом деле делает. Шелл выглядит как обычное приложение: вы вводите команды, он их выполняет. По сути же это тонкий слой поверх набора примитивов Unix, которые лежат в основе работы операционных систем. Написание шелла с нуля — один из лучших способов разобраться в процессах, файловых дескрипторах, пайпах и сигналах. Эти концепции лежат в основе всего: от веб-серверов до контейнеров Docker.
Базовый шелл на удивление прост. Основной цикл такой: прочитать строку ввода, разобрать её на команду и аргументы, создать дочерний процесс через fork, выполнить команду в нём и дождаться завершения. Это примерно 50 строк на C. Сложность появляется из-за функций, которые мы воспринимаем как должное: пайпов, перенаправления ввода-вывода, фоновых процессов, обработки сигналов и управления заданиями.
Цикл REPL: чтение, вычисление, вывод
По сути шелл — это REPL: читаем ввод, выполняем его, выводим результат и повторяем. За «вывод» отвечают сами команды, а шелл лишь создаёт окружение, в котором они работают.
#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.
Модель процессов в Unix: fork и exec
Разделение на fork и exec — самое характерное архитектурное решение Unix, и написание шелла наглядно показывает, зачем оно нужно.
fork() создаёт точную копию текущего процесса: та же память, те же открытые файлы, те же переменные окружения. exec() заменяет программу дочернего процесса на новую. Это две отдельные операции, потому что промежуток между ними, то есть после fork и до exec, как раз нужен шеллу, чтобы настроить окружение потомка.
Это ключевая идея. Когда вы вводите ls > output.txt, шелл делает fork, а затем в дочернем процессе (до exec) открывает output.txt, перенаправляет туда stdout и запускает ls. Программа 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);
Этот подход элегантен, потому что он компонуется. Потомок может настроить любое окружение перед exec: перенаправить файлы, сменить каталог, изменить переменные окружения, задать лимиты ресурсов, сменить UID. Запускаемая программа унаследует всё это, не зная ни о чём. Каждая команда получает заранее настроенное окружение, а настраивает его шелл.
Пайпы: соединяем процессы
Пайпы — самый мощный механизм композиции в Unix, и их реализация показывает, насколько простым на самом деле является лежащий в основе механизм.
Системный вызов pipe() создаёт пару файловых дескрипторов: один для чтения, другой для записи. Данные, записанные в конец для записи, появляются на конце для чтения. Чтобы реализовать ls | grep foo, шелл создаёт пайп, дважды делает fork и соединяет stdout ls с концом для записи, а stdin grep с концом для чтения.
// 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);
Обратите внимание на аккуратное закрытие неиспользуемых концов файловых дескрипторов. Это критично: если родитель не закроет оба конца пайпа, grep никогда не получит EOF на stdin (потому что конец для записи всё ещё открыт у родителя) и будет висеть вечно. Утечки файловых дескрипторов в цепочках пайпов — одна из самых частых ошибок при написании шелла.
Встроенные команды
Некоторые команды не могут быть внешними программами. Классический пример — cd. Если шелл создаёт дочерний процесс, и тот вызывает chdir(), меняется рабочий каталог только у потомка. У родителя он остаётся прежним, и после завершения потомка шелл всё ещё находится в той же директории. Чтобы cd работал, шелл должен выполнить её в собственном процессе, без fork.
К встроенным командам относятся также export (изменяет окружение шелла), exit (завершает процесс шелла) и source (выполняет скрипт в текущем контексте шелла). Все они меняют собственное состояние шелла, а это возможно только в его собственном процессе.
Понимание того, какие команды встроенные и почему, многое говорит об изоляции процессов. Дочерний процесс не может изменить родителя. Это и свойство безопасности, и средство надёжности, и иногда неудобство, но именно это лежит в основе работы процессов в Unix.
Сигналы и управление заданиями
Нажмите Ctrl+C в терминале, и выполняемая команда остановится. Кажется, всё просто, но за этим стоит довольно сложное взаимодействие терминала, шелла, сигналов и групп процессов.
Ctrl+C отправляет SIGINT группе процессов переднего плана. Этим занимается драйвер терминала, а не шелл. Задача шелла — поместить каждую команду в собственную группу процессов, чтобы SIGINT попадал в команду, а не в сам шелл. Если шелл неправильно настроит группы процессов, Ctrl+C убьёт шелл вместо запущенной команды.
Управление заданиями (фоновые процессы через &, fg, bg, Ctrl+Z) добавляет ещё один слой. Шеллу нужно отслеживать, какие процессы принадлежат каким заданиям, управлять группами переднего и фонового плана и обрабатывать сигналы вроде SIGTSTP (Ctrl+Z, приостановка) и SIGCHLD (завершение дочернего процесса). Корректно реализовать это — самая сложная часть создания шелла.
Что вы получаете
Написание шелла учит концепциям, которые постоянно встречаются в разработке, даже если вы больше никогда не будете писать системный код.
- Файловые дескрипторы — универсальный интерфейс. Файлы, пайпы, сокеты, терминалы — всё это файловые дескрипторы. Перенаправление, пайпы и сетевое взаимодействие используют один и тот же базовый механизм. Понимание этого делает более понятными всё: от сетевого стека Docker до Unix-доменных сокетов и системных API Linux.
- Изоляция процессов — фундаментальный принцип. Дочерний процесс не может изменить родителя. Переменные окружения, рабочий каталог и открытые файлы наследуются как копии, а не как общие ссылки. Поэтому
cdв подшелле не влияет на родителя, а контейнеры Docker наследуют окружение хоста, но не разделяют его. - Композиция важнее функциональности. В Unix нет команды «найти файлы по шаблону и подсчитать их». Есть
find,grepиwc, соединённые пайпами. Механизм пайпов в шелле превращает простые утилиты в сложные рабочие процессы. Эта философия, то есть маленькие инструменты, связанные стандартными интерфейсами, — интеллектуальный предок микросервисов, Unix-сокетов и дизайна API. - Обработка ошибок в основном связана с файловыми дескрипторами. Порвался пайп, упал дочерний процесс, закончился stdin — в основе всегда лежит закрытие файловых дескрипторов, доставка сигналов или завершение процессов с кодами возврата. Когда вы поняли модель файловых дескрипторов, паттерны обработки ошибок в Unix-системах становятся интуитивными.
С чего начать
Если хотите написать шелл, начните с версии из 25 строк выше и добавляйте функции постепенно. Сначала разбор аргументов (разбиение ввода по пробелам). Затем перенаправление ввода-вывода (> и <), потом пайпы, потом встроенные команды (cd, exit) и, наконец, переменные окружения. Каждая функция раскрывает новую концепцию Unix, и каждая из них — самостоятельное упражнение.
Используйте C: это даёт самое прямое соответствие системным вызовам. Шелл можно написать на Python или Rust, но реализация на C делает базовые системные вызовы явными: вы видите, что делают fork(), exec(), dup2() и pipe(), потому что вызываете их напрямую.
Не нужно писать производственный шелл. Даже игрушечный шелл, который умеет базовые команды, пайпы и перенаправление, научит вас большему о работе Unix, чем годы использования готового. Шелл — самая простая программа, которая задействует важнейшие интерфейсы операционной системы, и понимание этих интерфейсов делает вас лучшим разработчиком, на каком бы языке или платформе вы ни работали.


