Build mini-shell: fork, exec, pipe и builtins за 200 строк
Bash, zsh, fish — все они в основе одинаковы. Главный цикл: прочитать строку, разобрать на токены, для каждой команды fork-нуть, у ребёнка вызвать exec, у родителя wait. Всё остальное — обёртки и фичи. И этот цикл удивительно короткий: ~200 строк на Python.
В этом уроке мы построим shell, который умеет: запускать команды (ls -la), pipe (ps -ef | head), redirection (echo hi > /tmp/x, wc < /etc/passwd), builtins (cd /tmp, pwd, exit). Это уже 90% того, что вы используете на bash для quick interactive work.
Если умеете C — в конце урока покажу C-версию того же. fork/exec на C — это «классика». На Python это то же самое, но без segfault-ов.
Что должно получиться
$ python mini_shell.py
mini> pwd
/home/alice
mini> cd /tmp
mini> pwd
/tmp
mini> echo hello > out.txt
mini> cat out.txt
hello
mini> ps -ef | head -5
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 09:00 ? 00:00:01 /sbin/init
root 2 0 0 09:00 ? 00:00:00 [kthreadd]
...
mini> exit
$
Команды, pipe, redirection, builtins. То же, что bash, но в 200 раз короче кода.
Архитектура
REPL: read -> parse -> exec -> wait, repeat
Шаг 1: REPL и токенизация
import shlex
import os
import sys
def main():
while True:
try:
line = input('mini> ')
except EOFError:
print()
break
except KeyboardInterrupt:
print() # newline после Ctrl+C, возвращаемся к prompt
continue
line = line.strip()
if not line:
continue
try:
tokens = shlex.split(line)
except ValueError as e:
print(f'parse error: {e}', file=sys.stderr)
continue
if not tokens:
continue
try:
execute(tokens)
except Exception as e:
print(f'exec error: {e}', file=sys.stderr)
if __name__ == '__main__':
main()
shlex.split — модуль stdlib, разбивает строку как shell: уважает кавычки, escape. 'echo "hello world"' -> ['echo', 'hello world']. Не нужно писать свой парсер.
KeyboardInterrupt — обработка Ctrl+C. По умолчанию Python бросает исключение. Bash печатает ^C и даёт новый prompt — то же делаем мы.
EOF — Ctrl+D. Питон бросает EOFError. Печатаем \n для красоты и выходим.
Шаг 2: разбор pipe и redirection
def split_pipes(tokens: list[str]) -> list[list[str]]:
"""['ps','-ef','|','head'] -> [['ps','-ef'], ['head']]."""
segments = []
current = []
for tok in tokens:
if tok == '|':
if not current:
raise ValueError('empty command before |')
segments.append(current)
current = []
else:
current.append(tok)
if not current:
raise ValueError('empty command after |')
segments.append(current)
return segments
def extract_redirects(argv: list[str]) -> tuple[list[str], str | None, str | None, bool]:
"""Extract <, >, >> from argv. Returns (clean_argv, stdin_file, stdout_file, append).
Example: ['echo', 'hi', '>', 'out.txt'] -> (['echo', 'hi'], None, 'out.txt', False)
"""
stdin_file = None
stdout_file = None
append = False
clean = []
it = iter(argv)
for tok in it:
if tok == '<':
stdin_file = next(it, None)
if stdin_file is None:
raise ValueError("'<' requires a filename")
elif tok == '>':
stdout_file = next(it, None)
append = False
if stdout_file is None:
raise ValueError("'>' requires a filename")
elif tok == '>>':
stdout_file = next(it, None)
append = True
if stdout_file is None:
raise ValueError("'>>' requires a filename")
else:
clean.append(tok)
return clean, stdin_file, stdout_file, append
Простая итерация: видим >, берём следующий токен как имя файла. Удаляем оба из argv.
Шаг 3: builtins
cd, pwd, exit — это не внешние программы. Если бы мы fork+exec их, cd сменил бы cwd в ребёнке, а ребёнок умер бы, и cwd shell-а остался прежним. cd обязан выполняться в shell-процессе.
def run_builtin(argv: list[str]) -> bool:
"""Try to run as builtin. Return True if handled."""
cmd = argv[0]
if cmd == 'exit':
code = int(argv[1]) if len(argv) > 1 else 0
sys.exit(code)
if cmd == 'cd':
target = argv[1] if len(argv) > 1 else os.environ.get('HOME', '/')
try:
os.chdir(target)
except OSError as e:
print(f'cd: {e}', file=sys.stderr)
return True
if cmd == 'pwd':
print(os.getcwd())
return True
return False
os.chdir — это syscall chdir(). Меняет cwd текущего процесса (shell). После выхода из этого вызова все последующие fork-нутые дети унаследуют новый cwd.
В реальном bash builtins много: alias, export, set, source, history, kill, jobs… В нашем mini-shell — три минимальных. Расширение — в Lab-03.
Шаг 4: одна команда без pipe
Это базовый случай: один segment, никаких pipe.
def exec_single(argv: list[str], stdin_file: str | None,
stdout_file: str | None, append: bool) -> int:
"""Fork, exec, wait. Return exit status."""
pid = os.fork()
if pid == 0:
# CHILD
try:
# redirections
if stdin_file is not None:
fd = os.open(stdin_file, os.O_RDONLY)
os.dup2(fd, 0) # stdin
os.close(fd)
if stdout_file is not None:
flags = os.O_WRONLY | os.O_CREAT
flags |= os.O_APPEND if append else os.O_TRUNC
fd = os.open(stdout_file, flags, 0o644)
os.dup2(fd, 1) # stdout
os.close(fd)
# exec — заменяет образ процесса. После успешного exec
# этот код больше не выполняется
os.execvp(argv[0], argv)
except FileNotFoundError:
print(f'{argv[0]}: command not found', file=sys.stderr)
os._exit(127)
except OSError as e:
print(f'exec error: {e}', file=sys.stderr)
os._exit(126)
# PARENT
_, status = os.waitpid(pid, 0)
return os.waitstatus_to_exitcode(status)
Пошагово:
-
os.fork()— kernel создаёт точную копию текущего процесса (copy-on-write для памяти). Возвращает0ребёнку, PID ребёнка родителю. -
У ребёнка:
- Открываем файл для redirect, делаем
dup2(fd, 0/1)— это атомарная операция «закрыть текущий 0/1 и сделать так, чтобы fd 0/1 теперь указывали туда же, куда fd file». Закрываем оригинальный fd, он больше не нужен. os.execvp(argv[0], argv)— kernel выполняет syscallexecve. Текущий образ процесса (.text, .data, heap, stack) заменяется на новый из argv[0]. fd таблица сохраняется, поэтому redirects работают.- Если exec не нашёл программу —
FileNotFoundError. Печатаем иos._exit(127)(127 — bash convention для «command not found»). - Важно: используем
os._exit, неsys.exit.sys.exitбросаетSystemExit, который мог бы быть пойман и shell-код продолжил бы как «второй родитель» — катастрофа.os._exit— это syscall_exit, мгновенный, без cleanup.
- Открываем файл для redirect, делаем
-
У родителя —
waitpid(pid, 0). Блокирующее ожидание, пока ребёнок не закончится.waitstatus_to_exitcodeизвлекает код возврата из status.
fork делит процесс пополам, exec заменяет ребёнка
Шаг 5: pipe — cmd1 | cmd2
Самая интересная часть. Нужно: запустить два процесса, stdout первого соединить со stdin второго.
def exec_pipeline(segments: list[list[str]]) -> int:
"""Run cmd1 | cmd2 | cmd3. Each segment is [argv, stdin_file, stdout_file, append] tuple."""
# parse segments, extract redirects
parsed = []
for seg in segments:
argv, stdin_f, stdout_f, append = extract_redirects(seg)
if not argv:
raise ValueError('empty command in pipeline')
parsed.append((argv, stdin_f, stdout_f, append))
n = len(parsed)
pipes = [os.pipe() for _ in range(n - 1)] # n-1 pipes between n processes
pids = []
for i, (argv, stdin_f, stdout_f, append) in enumerate(parsed):
pid = os.fork()
if pid == 0:
# CHILD
try:
# connect stdin from previous pipe (if not first)
if i > 0:
os.dup2(pipes[i - 1][0], 0)
# connect stdout to next pipe (if not last)
if i < n - 1:
os.dup2(pipes[i][1], 1)
# close all pipe fds in child (after dup2 we don't need originals)
for r, w in pipes:
os.close(r)
os.close(w)
# explicit redirects (>, <) override pipe — bash тоже так делает
if stdin_f is not None:
fd = os.open(stdin_f, os.O_RDONLY)
os.dup2(fd, 0)
os.close(fd)
if stdout_f is not None:
flags = os.O_WRONLY | os.O_CREAT
flags |= os.O_APPEND if append else os.O_TRUNC
fd = os.open(stdout_f, flags, 0o644)
os.dup2(fd, 1)
os.close(fd)
os.execvp(argv[0], argv)
except FileNotFoundError:
print(f'{argv[0]}: command not found', file=sys.stderr)
os._exit(127)
except OSError as e:
print(f'exec error: {e}', file=sys.stderr)
os._exit(126)
else:
pids.append(pid)
# PARENT: close all pipes (children have their copies now)
for r, w in pipes:
os.close(r)
os.close(w)
# wait all children, return status of LAST one (bash convention)
last_status = 0
for pid in pids:
_, status = os.waitpid(pid, 0)
last_status = os.waitstatus_to_exitcode(status)
return last_status
Ключевые детали:
-
Pipe — это пара fd.
os.pipe()возвращает(read_fd, write_fd). Что пишут вwrite_fd, читается черезread_fd. Kernel держит небольшой буфер (обычно 64 KB на Linux). Когда буфер полон,writeблокируется доread. -
n процессов — n-1 pipes.
cmd1 | cmd2 | cmd3— два pipe: pipe[0] между cmd1 и cmd2, pipe[1] между cmd2 и cmd3. -
Каждый ребёнок видит ВСЕ pipes, потому что они созданы до fork (наследуются). Поэтому в каждом ребёнке нужно
dup2нужные иcloseвсе остальные. Иначе родительскийclose()не освободит pipe — на нём останутся references из других детей. -
Родитель тоже закрывает все pipes. Если родитель оставит write-end pipe-а открытым, тогда reader (последний процесс в pipeline или ребёнок-reader) никогда не получит EOF и будет ждать бесконечно. Это самый частый bug при реализации shell — забыть close в родителе.
-
Explicit redirects > pipe. Если есть
cmd1 > file | cmd2, то stdout cmd1 идёт в file, а не в cmd2 (cmd2 получает на stdin пустоту, тогда пытается читать — EOF). Это поведение bash. Реализовали через «pipe dup2 -> file dup2 override».
Буфер ~64 KB, write блокирует когда полно, read блокирует когда пусто
Шаг 6: собираем всё
def execute(tokens: list[str]) -> int:
"""Execute one shell line. Returns exit status."""
# split by |
segments = split_pipes(tokens)
# if only one segment and no pipe, try builtin first
if len(segments) == 1:
argv, stdin_f, stdout_f, append = extract_redirects(segments[0])
if argv and run_builtin(argv):
return 0
return exec_single(argv, stdin_f, stdout_f, append)
# multi-segment pipeline
return exec_pipeline(segments)
split_pipes, extract_redirects, run_builtin, exec_single, exec_pipeline — всё уже есть. Главный execute склеивает.
Edge cases
Zombie processes
После exit ребёнка его task_struct остаётся «зомби» до тех пор, пока родитель не сделает waitpid(). У нас в коде везде есть waitpid — поэтому zombies не накапливаются.
Что если родитель умрёт между fork и wait? Ребёнок становится «orphan», его подбирает init (PID 1) и сам делает wait. Поэтому в production-grade shell это не проблема. Но в долгоживущем процессе, который форкает много детей и забывает wait — каждый из них становится zombie на десятилетия (до смерти родителя). Мы не в этой ситуации.
Signals: Ctrl+C во время команды
Когда мы fork ребёнка, он наследует signal handlers shell-а. Если shell ставит SIGINT-handler — ребёнок тоже его получит. Это нежелательно: Ctrl+C должен прибить ребёнка, а не вернуть shell в prompt.
В простом случае работает: SIGINT в Python поднимает KeyboardInterrupt, мы его поймали в main(). Но это не идеально — реальный bash делает signal(SIGINT, SIG_DFL) в ребёнке через subprocess.Popen(..., preexec_fn=...) или setpgid для process group management. У нас базовая версия работает: Ctrl+C прибивает ребёнка, контроль возвращается shell-у, который продолжает ждать в waitpid, потом возвращается к prompt.
Empty input, parser errors
shlex.split бросает ValueError при unclosed quote: 'echo "hi'. Мы это ловим. Бесконечный цикл с error не валит shell.
exit status
Bash имеет переменную $? — статус последней команды. У нас её нет, но статус возвращается из execute. Расширение в Lab-03: добавить $? через словарь environment.
Версия на C (опционально)
Та же логика на C. Тот же fork, exec, pipe, dup2 — это POSIX-функции, и Python их просто оборачивает. Для тех, кто хочет увидеть «как было задумано»:
// mini_shell.c — простейшая версия одной команды + одного pipe
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#include <fcntl.h>
#define MAX_TOKENS 64
#define MAX_LINE 1024
int tokenize(char *line, char **tokens) {
int n = 0;
char *tok = strtok(line, " \t\n");
while (tok && n < MAX_TOKENS - 1) {
tokens[n++] = tok;
tok = strtok(NULL, " \t\n");
}
tokens[n] = NULL;
return n;
}
void exec_single(char **argv) {
pid_t pid = fork();
if (pid == 0) {
execvp(argv[0], argv);
perror("execvp");
_exit(127);
}
int status;
waitpid(pid, &status, 0);
}
void exec_pipe(char **argv1, char **argv2) {
int fds[2];
pipe(fds);
pid_t p1 = fork();
if (p1 == 0) {
dup2(fds[1], 1); // stdout to pipe
close(fds[0]); close(fds[1]);
execvp(argv1[0], argv1);
_exit(127);
}
pid_t p2 = fork();
if (p2 == 0) {
dup2(fds[0], 0); // stdin from pipe
close(fds[0]); close(fds[1]);
execvp(argv2[0], argv2);
_exit(127);
}
close(fds[0]); close(fds[1]);
waitpid(p1, NULL, 0);
waitpid(p2, NULL, 0);
}
int main(void) {
char line[MAX_LINE];
char *tokens[MAX_TOKENS];
while (1) {
printf("mini> ");
fflush(stdout);
if (!fgets(line, sizeof(line), stdin)) { printf("\n"); break; }
// detect single pipe
char *pipe_pos = strchr(line, '|');
if (pipe_pos) {
*pipe_pos = 0;
char *left[MAX_TOKENS], *right[MAX_TOKENS];
tokenize(line, left);
tokenize(pipe_pos + 1, right);
if (left[0] && right[0]) exec_pipe(left, right);
} else {
tokenize(line, tokens);
if (!tokens[0]) continue;
if (strcmp(tokens[0], "exit") == 0) break;
if (strcmp(tokens[0], "cd") == 0) {
chdir(tokens[1] ? tokens[1] : getenv("HOME"));
continue;
}
exec_single(tokens);
}
}
return 0;
}
Компиляция: gcc -o myshell mini_shell.c. Запуск: ./myshell. ~80 строк, делает то же, что 200 строк Python (плюс минус features). Сами syscalls — те же.
Попробуй сам
-
Сохраните Python-версию в
mini_shell.py. Запустите. Поработайте:ls,cd /tmp,pwd,cat /etc/hostname. Должно работать. -
Проверьте pipe:
ps -ef | head -5. Должно показать 5 строк. -
Проверьте redirect:
echo "hello world" > /tmp/test.txt, потомcat /tmp/test.txt. И append:echo "second" >> /tmp/test.txt, проверьте, что обе строки. -
Сравните с bash: что-нибудь сложное, чего не умеет mini-shell. Например,
for i in 1 2 3; do echo $i; done— это shell-конструкции (loop), мы их не реализовали. Или$VAR— переменные. Это всё расширения для Lab-03. -
Расширьте: добавьте
$?— exit status последней команды. Где хранить? В словареenv. Подставлять в токены при их обнаружении. -
Расширьте: добавьте
&для background. После argv поставить&— fork без wait. Список background PIDs хранить вbg_jobs. Builtinjobsпоказывает их.
KnowledgeCheck
Итог
mini-shell готов. ~200 строк Python, fork + exec + pipe + dup2 + redirect + builtins. Главное:
fork()создаёт ребёнка, возвращает 0 у ребёнка / PID у родителя.execvp()заменяет образ. После него код в ребёнке не выполняется.pipe()— пара fd. Что пишут в[1], читается из[0].dup2(fd, target)— атомарно делаетtargetуказывающим туда же, кудаfd. Базовый механизм redirect.- Родитель ОБЯЗАН закрыть все pipe-fd после fork. Иначе deadlock на reader.
- Builtins (
cd,pwd,exit) выполняются БЕЗ fork, прямо в shell-процессе. Иначеcdне сохранит cwd.
В уроке 4 — resource monitor. Перейдём от создания процессов к их наблюдению: top, vmstat, iostat — всё это парсеры /proc.
Pipes и композиция команд: то, что вы только что реализовали