Learning Platform
Глоссарий Troubleshooting
Урок 17.03 · 35 мин
Начальный
shellforkexecpipedup2waitPythonC

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 раз короче кода.


Архитектура

Главный цикл shell

REPL: read -> parse -> exec -> wait, repeat

prompt + inputПечатаем 'mini> ', читаем строку через input(). Если ввели пусто или EOF (Ctrl+D) -- выход
tokenizeshlex.split(line) разбивает строку на токены, уважая кавычки. 'echo \\'hello world\\' > out' -> ['echo', 'hello world', '>', 'out']
split by | Делим список токенов по '|' на segments. ['ps', '-ef', '|', 'head', '-5'] -> [['ps','-ef'], ['head','-5']]. Каждый segment -- отдельная команда
extract <, >, >>Из каждой command-segment выделяем перенаправления: token '>' значит next token -- output file. Удаляем из argv. Получаем (argv, stdin_file, stdout_file, append)
builtin?Если argv[0] in {'cd', 'pwd', 'exit'} -- не fork-аем. cd должен поменять cwd ТЕКУЩЕГО процесса (shell-а), не ребёнка. Иначе после ребёнка cwd не сохранится
fork + exec + waitСоздаём pipes между segments. Для каждого segment: fork. У ребёнка dup2 для pipe in/out и redirects. exec. У родителя collect pids. После всех fork -- waitpid каждого
loopВозвращаемся к prompt. Sig-INT (Ctrl+C) -- не убиваем shell, а возвращаемся к prompt с новой строкой

Шаг 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.

TIP

В реальном 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)

Пошагово:

  1. os.fork() — kernel создаёт точную копию текущего процесса (copy-on-write для памяти). Возвращает 0 ребёнку, PID ребёнка родителю.

  2. У ребёнка:

    • Открываем файл для redirect, делаем dup2(fd, 0/1) — это атомарная операция «закрыть текущий 0/1 и сделать так, чтобы fd 0/1 теперь указывали туда же, куда fd file». Закрываем оригинальный fd, он больше не нужен.
    • os.execvp(argv[0], argv) — kernel выполняет syscall execve. Текущий образ процесса (.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.
  3. У родителя — waitpid(pid, 0). Блокирующее ожидание, пока ребёнок не закончится. waitstatus_to_exitcode извлекает код возврата из status.

Что физически происходит при exec single

fork делит процесс пополам, exec заменяет ребёнка

Parent (shell)
Kernel
Child
fork()create new PID 5678return 5678return 0dup2(file_fd, 1)execvp(ls, [ls -la])waitpid(5678)exit(0)return status

Шаг 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

Ключевые детали:

  1. Pipe — это пара fd. os.pipe() возвращает (read_fd, write_fd). Что пишут в write_fd, читается через read_fd. Kernel держит небольшой буфер (обычно 64 KB на Linux). Когда буфер полон, write блокируется до read.

  2. n процессов — n-1 pipes. cmd1 | cmd2 | cmd3 — два pipe: pipe[0] между cmd1 и cmd2, pipe[1] между cmd2 и cmd3.

  3. Каждый ребёнок видит ВСЕ pipes, потому что они созданы до fork (наследуются). Поэтому в каждом ребёнке нужно dup2 нужные и close все остальные. Иначе родительский close() не освободит pipe — на нём останутся references из других детей.

  4. Родитель тоже закрывает все pipes. Если родитель оставит write-end pipe-а открытым, тогда reader (последний процесс в pipeline или ребёнок-reader) никогда не получит EOF и будет ждать бесконечно. Это самый частый bug при реализации shell — забыть close в родителе.

  5. Explicit redirects > pipe. Если есть cmd1 > file | cmd2, то stdout cmd1 идёт в file, а не в cmd2 (cmd2 получает на stdin пустоту, тогда пытается читать — EOF). Это поведение bash. Реализовали через «pipe dup2 -> file dup2 override».

pipe внутри ядра -- два fd, один буфер

Буфер ~64 KB, write блокирует когда полно, read блокирует когда пусто

cmd1 processНапример ps -ef. Его stdout (fd 1) после dup2 указывает на pipe write end
pipe buffer (kernel)Ring buffer ~64 KB в kernel memory. Связан с pipe inode. Write копирует данные в buffer, read извлекает. Когда buffer полон -- writer блокируется. Когда буфер пуст -- reader блокируется
cmd2 processНапример head -5. Его stdin (fd 0) после dup2 указывает на pipe read end. read() из 0 -> kernel читает из buffer

Шаг 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 — те же.


Попробуй сам

  1. Сохраните Python-версию в mini_shell.py. Запустите. Поработайте: ls, cd /tmp, pwd, cat /etc/hostname. Должно работать.

  2. Проверьте pipe: ps -ef | head -5. Должно показать 5 строк.

  3. Проверьте redirect: echo "hello world" > /tmp/test.txt, потом cat /tmp/test.txt. И append: echo "second" >> /tmp/test.txt, проверьте, что обе строки.

  4. Сравните с bash: что-нибудь сложное, чего не умеет mini-shell. Например, for i in 1 2 3; do echo $i; done — это shell-конструкции (loop), мы их не реализовали. Или $VAR — переменные. Это всё расширения для Lab-03.

  5. Расширьте: добавьте $? — exit status последней команды. Где хранить? В словаре env. Подставлять в токены при их обнаружении.

  6. Расширьте: добавьте & для background. После argv поставить & — fork без wait. Список background PIDs хранить в bg_jobs. Builtin jobs показывает их.


KnowledgeCheck

Проверка знанийKnowledge check
Junior пишет shell. Реализует pipe: создаёт pipe, fork ребёнка1 (writer), fork ребёнка2 (reader), оба дёргают exec. Не закрывает pipe-fd в РОДИТЕЛЕ. Запускает 'cat large.txt | head -5'. Что произойдёт?
ОтветAnswer

Итог

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 и композиция команд: то, что вы только что реализовали

Проверьте понимание

Результат: 0 из 0
Аналитический
Вопрос 1 из 4. Junior пишет shell. Реализует pipe: создаёт pipe, fork ребёнка1 (writer), fork ребёнка2 (reader), оба делают exec. Не закрывает pipe-fd в РОДИТЕЛЕ. Запускает 'cat large.txt | head -5'. Что произойдёт?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 4