Антон Долганин

Я инженер, который решает задачи, а не пишет на языке. Архитектура, разработка, DevOps — подбираю инструменты под цель, строю решения, которые работают в проде и масштабируются без боли.

Многие думают, что командная строка (bash, zsh), в которую мы вводим команды — это какая-то глубоко встроенная, магическая часть операционной системы.

На самом деле, шелл — это лишь стартовая программа на вашем сервере. Когда вы логинитесь по SSH, система заглядывает в файл конфигурации пользователя и запускает то, что там указано. Если вместо /bin/bash там прописать путь к Python, вы попадете в интерпретатор Python. Пропишете /usr/bin/top — откроется диспетчер задач, а при выходе из него ваш SSH-сеанс сразу закроется.

Но как устроен сам шелл изнутри? Фундаментально — это просто бесконечный цикл. Вот как выглядит его минималистичный скелет на современном C.

Минимальный шелл (на базе паттерна из UNIX)


#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>

#define MAXLINE 4096

int main(void) {
    char buf[MAXLINE];

    printf("%% ");

    while (fgets(buf, MAXLINE, stdin) != NULL) {
        size_t len = strlen(buf);
        if (len > 0 && buf[len - 1] == '\n') {
            buf[len - 1] = '\0';
        }

        pid_t pid = fork();

        if (pid < 0) {
            perror("fork error");
            exit(EXIT_FAILURE);
        } else if (pid == 0) { /* child process */
            execlp(buf, buf, (char *)NULL);

            /* if execlp returns control, then an error occurred. */
            fprintf(stderr, "couldn't execute: %s\n", buf);
            exit(127);
        }

        /* parent process */
        int status;
        if (waitpid(pid, &status, 0) < 0) {
            perror("waitpid error");
            exit(EXIT_FAILURE);
        }

        printf("%% ");
    }

    exit(EXIT_SUCCESS);
}

Как это работает под капотом

Вся суть классического UNIX-шелла сводится к паттерну fork-exec-wait, который можно описать пятью простыми шагами:

  1. В цикле считываем строку от пользователя.
  2. Отрезаем символ переноса, оставшийся после нажатия Enter.
  3. Форкаем процесс — обращаемся к ядру ОС с просьбой создать точную копию нашей программы.
  4. Если это ребенок — подменяем себя на ту программу, путь к которой ввел пользователь.
  5. Если это родитель (наш исходный шелл) — просто ждем завершения работы ребенка, чтобы затем снова вывести приглашение %.

В чем заключается «магия форка»?

После вызова функции fork() операционная система клонирует процесс. Оба процесса продолжают работу буквально с одной и той же строчки кода (возврата из функции fork). Отличаются они только одним: в дочернем процессе функция возвращает 0, а родитель получает ID свежесозданного клона. Это значение работает как развилка, заставляя процессы пойти по разным веткам if-else.

Важная оговорка:

Этот код намеренно «деревянный» и служит только для учебных целей. Он не умеет обрабатывать параметры (команда вроде ls -l выдаст ошибку, так как система будет искать один файл с пробелом в названии), не поддерживает встроенные команды вроде cd или exit, а также пайпы. Настоящий шелл перед вызовом exec проводит огромную работу по разбиению вашей строки на токены и настройке файловых дескрипторов. Тем не менее, абсолютно любой терминал базируется именно на этом элегантном механизме.

А вы знали, что шелл — это просто обычная программа?