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

Недавно я начал читать книгу "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. Если вы тоже учите операционные системы — попробуйте! Нет лучшего способа понять что-то, чем построить это самому. Мой код далёк от идеала, но каждая строка — это понимание, которое никуда не денется.

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

Комментарии (9)


  1. garm
    18.02.2026 06:12

    Автор путает терминал, эмулятор терминала и командную оболочку.

    Программа, которую написал автор — это командная оболочка.


    1. n99
      18.02.2026 06:12

      Хорошо что руками писал, а не с чатгопотой (надеюсь), уже плюс автору.


      1. valijonsharifjonov Автор
        18.02.2026 06:12

        Спасибо! Да, старался передать свой опыт изучения. Рад, что чувствуется :)


    1. valijonsharifjonov Автор
      18.02.2026 06:12

      Точно подмечено, спасибо! Это shell, а не терминал.
      Буду точнее с терминологией в следующих статьях.


  1. n99
    18.02.2026 06:12

    Грустно признавать, но это статья уровня:

    10 let a=1

    20 print a

    30 a=a+1

    40 goto 20

    И возгласы "ого!! оно печатает числа!1" со ссылкой на обязательный гитхаб.

    Я не хочу обидеть автора. Но в доинтернетное время такое делалось не только не в гитхабе, а часто в блокноте ручкой и даже в уме.

    Чтобы познать уровень своего невежества по Даннингу-Крюгеру, зайдите на wasm.in или на Киберфорум, почитайте, постарайтесь понять, что приведенное в статье это дичайший хелловорд.

    Да, Вы поймете, что не понимаете вообще ничего. Это минус. Но это и плюс - если интерес к предмету настоящий, а не попонтоваться "я написал статью на Хабр, время чтения 5 минут", то понимание того, что Вы не понимаете ничего, сподвигнет Вас изучать предмет дальше.

    С Уважением.


    1. valijonsharifjonov Автор
      18.02.2026 06:12

      Спасибо за честность и за ссылки! Да, это базовый уровень —
      дневник обучения, а не экспертная статья. Интерес настоящий,
      поэтому буду копать глубже. Ценю направление!


  1. SIISII
    18.02.2026 06:12

    На самом деле, настоящий терминал -- это отдельная железка, которую использует человек для работы с машиной:

    Мужик сидит и что-то набирает на клавиатуре терминала; то, что он набирает, передаётся байт за байтом в машину (слева три шкафа -- это и есть машина); машина же, в свою очередь, байт за байтом передаёт на терминал символы и управляющие коды, образующие в итоге то, что человек видит на экране. У машины может быть несколько терминалов, и все они могут использоваться одновременно (собственно, в 60-80-е годы так обычно и бывало); терминалами не обязательно должны быть дисплеи с клавиатурами -- очень часто использовались телетайпы и пишущие машинки, хотя по мере распространения дисплеев они, конечно, выходили из употребления.

    Что же касается т.н. терминалов в, скажем, современном Линухе, -- это программы, которые программно симулируют настоящие железные терминалы. Как правильно заметили в комментариях, то, что автор написал, -- это не терминал, это командная оболочка, т.е. программа, выполняющаяся под управлением операционной системы и использующая сервисы операционной системы для считывания символов, поступающих от терминала, и вывода на терминал каких-либо сообщений. Что из себя терминал представляет, данная программа понятия не имеет -- это скрывает от неё операционная система. Технически, скажем, возможно подключить к современному ПК настоящий древний терминал, при необходимости написать для операционной системы драйвер под конкретную модель терминала, после чего можно будет работать за этим терминалом точно так же, как можно работать в окне программного симулятора терминала на самом ПК.


    1. lolikandr
      18.02.2026 06:12

      Я как-то пытался отследить по исходникам линукс ядра, как данные перемещаются из драйвера uart в line discipline, tty, а затем в приложение. В итоге не вышло, весьма запутанно оказалось. Вот и подумал, неужели эта статья реально описывает терминал с нуля? Ан нет (


      1. valijonsharifjonov Автор
        18.02.2026 06:12

        Вы правы — название вводит в заблуждение. Это userspace shell,
        а не TTY subsystem. Тема с line discipline интригует, но пока
        не на том уровне. Спасибо за уточнение!