Почему я это делаю?

Недавно я начал читать книгу "Operating Systems: Three Easy Pieces" и понял, что читать теорию — это одно, а понять как оно на самом деле работает — совсем другое.

Когда я открываю терминал и пишу ls, для меня это была магия. Как компьютер понимает эти буквы? Что происходит между моим нажатием Enter и появлением списка файлов?

Я решил: построю свой терминал с нуля. Без готовых библиотек, без копипасты из интернета. Просто я, C++, и системные вызовы.

Сейчас я покажу что получилось, и объясню простыми словами, как это работает изнутри.


Что уже работает (демо)

Вот как выглядит мой myshell прямо сейчас:

$ ./myshell
myshell>> ls
main.cpp  myshell  README.md

myshell>> pwd
/home/user/projects/minishell

myshell>> echo "Привет, мир!" > test.txt

myshell>> cat test.txt
Привет, мир!

myshell>> cat несуществующий_файл 2> error.log

myshell>> cat error.log
cat: несуществующий_файл: No such file or directory

myshell>> exit
$

Выглядит просто, правда? Но под капотом творится настоящая магия системного программирования.

Как работает терминал: объяснение через ресторан

Прежде чем показывать код, объясню концепцию через метафору ресторана.

Представьте:

- Терминал (shell) = Менеджер ресторана

- Команда (например, ls) = Заказ от клиента

- Программа = Повар, который готовит блюдо

Что происходит, когда вы пишете команду:

  1. Клиент делает заказ (вы вводите "ls")

  2. Менеджер клонирует себя (fork). Теперь есть два менеджера: оригинал и копия

  3. Копия-менеджер превращается в повара (exec). Он забывает, что был менеджером, и становится поваром "ls"

  4. Повар готовит блюдо (программа ls выполняется)

  5. Оригинал-менеджер ждет готовности (wait)

  6. Блюдо готово, повар уходит. Менеджер снова принимает заказы

Звучит странно? Сейчас покажу это в коде!


Три главных системных вызова

Весь терминал строится на трёх функциях:

1. fork()клонирование процесса

pid_t pid = fork();

Что происходит:

  • Ваша программа копируется в памяти

  • Теперь работают два процесса одновременно

  • Они одинаковые, но с разными ID

Самое странное: эта функция возвращает дважды!

pid_t pid = fork();

if (pid == 0) {
    // Этот код выполнится в ребёнке (копии)
    printf("Я — копия!\n");
} else {
    // Этот код выполнится в родителе (оригинале)
    printf("Я — оригинал! Мой ребёнок имеет ID: %d\n", pid);
}

Пример из жизни: Представьте, вы читаете книгу на странице 50. Вдруг появляется ваш клон. Теперь вас двое, оба на странице 50, но вы можете читать дальше независимо.

2.exec()превращение процесса

execvp("ls", args);
// После этой строки ваша программа исчезает!

Что происходит:

  • Ваш код уничтожается

  • Процесс превращается в другую программу (например, ls)

  • Код после exec никогда не выполнится (если exec успешен)

Пример из жизни: Это как в сказке про Золушку. Тыква превратилась в карету. Она больше не тыква — она стала каретой.

3.wait() — ожидание завершения

waitpid(pid, nullptr, 0);

Что происходит:

  • Родитель замирает и ждёт

  • Пока ребёнок не закончит работу

  • Если не подождать → получится зомби-процесс!

Пример из жизни: Вы отправили ребёнка в магазин. Вы ждёте дома, пока он вернётся. Если вы уйдёте (не вызовете wait), ребёнок вернётся, а вас нет — он станет "потерянным" (зомби).


Собираем всё вместе: главный цикл терминала

Теперь смотрите, как три функции создают полноценный терминал:

while (true) {
    // 1. Читаем команду от пользователя
    std::cout << "myshell>> ";
    std::string command;
    std::getline(std::cin, command);
    
    if (command == "exit") break;
    
    // 2. клонируем процесс
    pid_t pid = fork();
    
    if (pid == 0) {
        // === мы в ребёнке ===
        
        // 3. превращаемся в программу
        execvp(args[0], args);
        
        // Если exec вернулся — значит ошибка
        perror("execvp");
        exit(1);
        
    } else {
        // === мы в родители ===
        
        // 4. ждем, пока ребёнок закончит
        waitpid(pid, nullptr, 0);
    }
}

Вот и всё! Это костяк любого терминала. Bash, zsh, fish — все они делают примерно то же самое.


Почему cd и exit — особенные?

Попробуйте угадать: можно ли сделать cd как обычную программу?

Нажмите, чтобы увидеть ответ

Нет! И вот почему:

// Представим, что cd — это внешняя программа

fork();  // создали ребёнка
  // Ребёнок:
  chdir("/home");  // ребёнок поменял директорию
  exit();          // ребёнок умер

// Родитель остался в старой директории!

Вывод: Команды, которые должны менять состояние терминала, нужно делать встроенными (built-in).

Поэтому в моём коде:

if (command == "cd") {
    // Выполняем прямо в родительском процессе
    chdir(path);
} else if (command == "exit") {
    // Тоже в родителе — выходим из цикла
    break;
} else {
    // Обычные команды — через fork + exec
    fork_and_exec(command);
}

Перенаправление: как работает > и <

Когда вы пишете:

ls > output.txt

Куда девается вывод? Почему его не видно на экране?

Секрет в "file descriptors" (файловых дескрипторах)

Каждая программа имеет три открытых "трубы":

┌─────────────┐
│  Программа  │
├─────────────┤
│ 0: stdin  ← │ (клавиатура)
│ 1: stdout → │ (экран)
│ 2: stderr → │ (экран для ошибок)
└─────────────┘

Перенаправление = переткнуть трубу в другое место:

// Было:
// stdout (1) → экран

// Делаем:
int fd = open("output.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
dup2(fd, STDOUT_FILENO);  // "переткнули" stdout в файл!

// Стало:
// stdout (1) → output.txt
```

Теперь когда программа делает `printf()`, данные идут **не на экран, а в файл**!

### Метафора: водопровод 
```
Обычная программа:
[Программа] → труба stdout → [Экран]

С перенаправлением:
[Программа] → труба stdout → [Файл output.txt]
                ↑
    (переподключили трубу!)

Самый интересный баг, который я поймал

// мой код (с багом):
if (pid == 0) {
    execvp(args[0], args);
}
// Я забыл написать else!

// что произошло:
// И родитель, и ребёнок продолжили работу! 😱
// Команда выполнилась дважды

Результат:

myshell>> echo "test" > file.txt
myshell>> cat file.txt
testtest

Файл записался дважды!

Урок: После fork() надо чётко разделять код для родителя и ребёнка с помощью if-else.

Правильный код:

if (pid == 0) {
    // Только ребёнок
    execvp(args[0], args);
    exit(1);
} else {
    // Только родитель
    waitpid(pid, nullptr, 0);
}

Что я узнал за эти дни

Инсайт #1: Процесс ≠ Программа

Процесс — это программа, которая запущена и живёт в памяти. Программа — это просто файл на диске.

fork() копирует процесс, а exec() заменяет его на другую программу.

Инсайт #2: В UNIX всё — это файлы

  • Обычный файл — это файл

  • Экран — это файл (stdout)

  • Клавиатура — это файл (stdin)

  • Даже директории — это файлы!

Поэтому > просто переключает, куда идут данные.

Инсайт #3: Терминал — это просто loop

Я думал, терминал — это что-то сложное. Оказалось:

while (true) {
    read_command();
    fork();
    exec();
    wait();
}

Всё. Остальное — это детали.


Что дальше?

Сейчас мой терминал умеет:

  • Запускать программы

  • Встроенные команды (cd, pwd, exit)

  • Перенаправление (>, <, 2>, 2>&1)

Что планирую добавить:

  • Pipes (ls | grep cpp) — соединять программы вместе

  • Background процессы (sleep 100 &) — запуск в фоне

  • Signals (Ctrl+C, Ctrl+Z) — обработка сигналов

  • История команд (стрелка вверх)

  • Автодополнение (Tab)

Каждая фича — это новая статья!

Ресурсы


Следите за продолжением!

Это только начало. Я буду продолжать развивать этот проект и писать о каждой новой фиче.

P.S. Если вы тоже учите операционные системы — попробуйте! Нет лучшего способа понять что-то, чем построить это самому. Мой код далёк от идеала, но каждая строка — это понимание, которое никуда не денется.

Вопросы? Пишите в комментариях! Это моя первая статья, и я только начинаю разбираться в теме, поэтому буду благодарен за конструктивную обратную связь и обсуждение.