Почему я это делаю?
Недавно я начал читать книгу "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) = Заказ от клиента
- Программа = Повар, который готовит блюдо
Что происходит, когда вы пишете команду:
Клиент делает заказ (вы вводите "ls")
Менеджер клонирует себя (
fork). Теперь есть два менеджера: оригинал и копияКопия-менеджер превращается в повара (exec). Он забывает, что был менеджером, и становится поваром "ls"
Повар готовит блюдо (программа ls выполняется)
Оригинал-менеджер ждет готовности (wait)
Блюдо готово, повар уходит. Менеджер снова принимает заказы
Звучит странно? Сейчас покажу это в коде!
Три главных системных вызова
Весь терминал строится на трёх функциях:
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)
Каждая фича — это новая статья!
Ресурсы
Книга: Operating Systems: Three Easy Pieces (бесплатная!)
Мой код: GitHub репозиторий (добавлю позже)
Следите за продолжением!
Это только начало. Я буду продолжать развивать этот проект и писать о каждой новой фиче.
P.S. Если вы тоже учите операционные системы — попробуйте! Нет лучшего способа понять что-то, чем построить это самому. Мой код далёк от идеала, но каждая строка — это понимание, которое никуда не денется.
Вопросы? Пишите в комментариях! Это моя первая статья, и я только начинаю разбираться в теме, поэтому буду благодарен за конструктивную обратную связь и обсуждение.
