Терминалы

Предпосылки: сигналы (SIGINT, SIGTSTP, доставка сигнала группе процессов), процессы (fork, exec, PID, группы процессов).

ELF и линковка | Трассировка

Процесс может читать файлы, слушать сокеты, выделять память — но для интерактивной работы с человеком ему нужен канал ввода-вывода, который передаёт нажатия клавиш и отображает текст. В современных системах этот канал почти никогда не связан с физическим устройством — его эмулирует ядро. Чтобы понять, как устроен этот механизм, стоит начать с того, что он заменил.

Физические терминалы и наследие TTY

В 1970-х терминал — это физическое устройство: клавиатура и экран (или печатающая головка), подключённые к серверу через последовательный кабель RS-232. Каждый такой терминал представлен в системе файлом устройства /dev/ttyS0, /dev/ttyS1 и так далее. Когда пользователь нажимает клавишу, UART-контроллер (Universal Asynchronous Receiver-Transmitter) генерирует прерывание, ядро читает байт из порта и передаёт его процессу, привязанному к этому устройству. К одному серверу PDP-11 подключались десятки терминалов — каждый через отдельный последовательный порт, каждый с отдельным файлом /dev/ttyS*.

Название TTY (teletype) — сокращение от Teletype Model 33, одного из первых терминалов, подключавшихся к Unix-машинам. Термин пережил устройство: в Linux любой интерфейс ввода-вывода, через который пользователь взаимодействует с shell, называется TTY. Команда tty в терминале показывает путь к устройству текущей сессии — но возвращает она не /dev/ttyS0, а что-то вроде /dev/pts/3. Физических терминалов давно нет — их место заняли псевдотерминалы.

PTY: пара master/slave

Когда пользователь открывает gnome-terminal, xterm или подключается по SSH, физического устройства нет. Но shell (bash, zsh) ожидает, что за fd 0, 1, 2 стоит TTY-устройство — оно нужно для обработки Ctrl+C, управления заданиями, эхо-режима. Ядро решает эту задачу через псевдотерминал (pseudo-terminal, PTY) — пару связанных устройств.

PTY master (/dev/ptmx) — файловый дескриптор, который держит эмулятор терминала (xterm, sshd). Программа-эмулятор открывает /dev/ptmx вызовом posix_openpt(), ядро создаёт пару и возвращает fd мастера. Всё, что эмулятор пишет в master, появляется как ввод на стороне slave. Всё, что процесс пишет в slave, эмулятор читает из master и отображает на экране.

PTY slave (/dev/pts/N) — устройство, которое shell видит как свой терминал. Для bash разницы между /dev/ttyS0 и /dev/pts/3 нет: оба — TTY-устройства с одинаковым набором ioctl(). Номер N выделяется ядром при создании пары. На рабочей станции с пятью вкладками терминала — пять пар, /dev/pts/0 до /dev/pts/4.

Создание пары в коде эмулятора выглядит так: posix_openpt(O_RDWR) возвращает fd мастера, grantpt() устанавливает права на slave-устройство, unlockpt() разблокирует slave для открытия, ptsname() возвращает путь вида /dev/pts/3. После этого эмулятор делает fork(), в потомке открывает slave через open(ptsname(...)), дублирует его на fd 0, 1, 2 и вызывает exec() для запуска shell. Родительский процесс (эмулятор) работает с master, дочерний (shell) — с slave.

Поток данных при нажатии клавиши в локальном терминале:

 клавиатура
     |
     v
 xterm (user space) --write()--> PTY master (/dev/ptmx)
                                     |
                                     v
                              line discipline
                                     |
                                     v
                                PTY slave (/dev/pts/3)
                                     |
                                     v
                              bash (read() на fd 0)

Обратный путь — вывод bash — идёт в противоположном направлении: bash пишет в fd 1 (PTY slave), данные проходят через line discipline, попадают в PTY master, xterm читает их и рисует символы на экране.

Между master и slave стоит компонент, без которого PTY был бы просто pipe: line discipline.

Line discipline: обработка ввода в ядре

Line discipline (дисциплина линии, n_tty в коде ядра) — модуль, через который проходят все байты между master и slave. Он выполняет четыре функции, каждая из которых связана с конкретным пользовательским ожиданием.

Эхо (echo). Когда пользователь нажимает a, он ожидает увидеть a на экране. Но shell ещё не получил этот байт — он ждёт Enter. Эхо обеспечивает line discipline: получив байт от master, он отправляет копию обратно в master (для отображения) и одновременно буферизует байт для slave. Без эхо пользователь печатал бы вслепую. Отключить эхо можно флагом ECHO в настройках терминала — именно это делает sudo при запросе пароля: символы не отображаются, хотя ввод работает.

Редактирование строки. Backspace не отправляет байт \x7f в shell — line discipline перехватывает его и удаляет последний символ из внутреннего буфера. Аналогично Ctrl+W удаляет последнее слово, Ctrl+U — всю строку. Shell не знает об этих нажатиях. Он получает готовую строку после Enter.

Канонический режим (canonical mode). В этом режиме line discipline буферизует ввод и отдаёт slave целую строку только после Enter (\n). Размер буфера — 4096 байт (константа N_TTY_BUF_SIZE в ядре; POSIX-константа MAX_CANON задаёт минимум в 255 байт, но Linux использует собственный, значительно больший буфер). Пока пользователь набирает команду, read() на стороне bash блокирован. Нажатие Enter разблокирует его, и bash получает строку целиком. Этот режим стоит по умолчанию на каждом свежем PTY.

Обработка управляющих символов. Нажатие Ctrl+C не передаёт байт 0x03 в shell. Line discipline перехватывает его и генерирует сигнал SIGINT, который доставляется всей foreground-группе (группе переднего плана) процессов терминала. Аналогично Ctrl+Z генерирует SIGTSTP (приостановка), а Ctrl+\ — SIGQUIT (завершение с core dump). Привязка символов к сигналам настраивается: stty intr ^C назначает Ctrl+C на SIGINT, stty susp ^Z — Ctrl+Z на SIGTSTP. Можно даже переназначить: stty intr ^X заставит Ctrl+X генерировать SIGINT вместо Ctrl+C.

Все четыре функции работают одновременно. Когда пользователь набирает ls -la, нажимает Backspace (line discipline удаляет a), дописывает al и нажимает Enter, shell получает строку ls -lal. Если вместо Enter нажимается Ctrl+C — строка никогда не дойдёт до shell, вся foreground-группа процессов получит SIGINT. Важная деталь: line discipline — это модуль внутри ядра, а не user-space программа. Обработка Ctrl+C происходит без участия shell и без переключения контекста в пользовательское пространство — ядро генерирует сигнал напрямую.

Raw mode: отключение line discipline

Канонический режим удобен для shell, но не годится для программ, которым нужен контроль над каждым нажатием. vim не может ждать Enter для каждой команды — нажатие j должно мгновенно переместить курсор вниз. htop перерисовывает экран по таймеру и реагирует на стрелки без Enter. ssh-клиент должен передавать каждый байт удалённой стороне, не интерпретируя его локально.

Такие программы переключают терминал в raw mode (сырой режим) — режим, в котором line discipline выключает всю обработку. Нет эхо, нет буферизации до Enter, нет интерпретации Ctrl+C как сигнала. Каждый байт передаётся от master к slave как есть, а read() возвращает данные сразу, не дожидаясь перевода строки.

Существует также промежуточный вариант — cbreak mode (полусырой режим, он же non-canonical mode с сохранением ISIG). В нём ICANON выключен (каждый байт доставляется сразу), но ISIG оставлен (Ctrl+C по-прежнему генерирует SIGINT). Этот режим полезен программам, которым нужна побайтная реакция, но стандартная обработка сигналов устраивает — например, less или top.

Переключение выполняется через структуру termios и два системных вызова:

struct termios raw;
tcgetattr(STDIN_FILENO, &raw);       // прочитать текущие настройки
 
raw.c_lflag &= ~(ECHO | ICANON);    // выключить эхо и канонический режим
raw.c_lflag &= ~(ISIG);             // выключить Ctrl+C -> SIGINT
raw.c_cc[VMIN] = 1;                  // read() возвращает при >=1 байте
raw.c_cc[VTIME] = 0;                 // без таймаута
 
tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw); // применить настройки

tcgetattr() считывает текущие параметры терминала в структуру termios. tcsetattr() применяет изменённые параметры. TCSAFLUSH означает: применить после того, как все буферизованные данные отправлены, и сбросить непрочитанный ввод. Без TCSAFLUSH переключение может произойти в середине строки, и часть символов будет обработана в старом режиме, часть — в новом.

Флаги в c_lflag (local flags) управляют поведением line discipline: ICANON — канонический режим, ECHO — эхо, ISIG — интерпретация управляющих символов как сигналов. Их комбинация определяет, сколько обработки происходит в ядре. Программа вроде vim при запуске выключает всё (ECHO | ICANON | ISIG), а при выходе восстанавливает сохранённые настройки. Если vim аварийно завершится без восстановления, терминал остаётся в raw mode: буквы не отображаются, Ctrl+C не работает. Команда reset (или stty sane) возвращает настройки к стандартным.

Посмотреть текущие параметры можно командой stty -a:

speed 38400 baud; rows 50; cols 190;
...
-icanon -echo -isig ...

Минусы перед именами флагов означают, что они выключены. В raw mode -icanon, -echo, -isig будут стоять вместе. Помимо c_lflag (local flags), структура termios содержит ещё три набора флагов: c_iflag (input processing — преобразование CR в NL, обработка чётности), c_oflag (output processing — автоматическая вставка CR перед NL) и c_cflag (control flags — скорость, количество бит). Полный raw mode обычно отключает обработку и в c_iflag, и в c_oflag, чтобы ни один байт не трансформировался по дороге.

Job control: группы процессов и управление заданиями

Line discipline генерирует SIGINT при Ctrl+C, но кому именно его доставить? В терминале могут одновременно работать несколько процессов: bash, его дочерний cat, запущенный в фоне sleep 300. SIGINT не должен убивать всё подряд.

Ответ — в группах процессов. Каждый процесс принадлежит группе (process group), идентифицируемой по PGID (Process Group ID). При запуске cat | grep error bash создаёт новую группу и помещает в неё оба процесса — cat и grep получают одинаковый PGID. Терминал хранит PGID текущей foreground-группы; системный вызов tcsetpgrp() переключает foreground-группу. SIGINT доставляется именно foreground-группе — фоновые процессы не затрагиваются.

Когда пользователь нажимает Ctrl+Z, line discipline отправляет SIGTSTP foreground-группе. Процессы приостанавливаются, bash выводит [1]+ Stopped и становится foreground-группой терминала. Дальше — три действия:

fg %1 — bash делает остановленную группу снова foreground, отправляет ей SIGCONT. Процессы продолжают работу и могут читать с терминала.

bg %1 — bash отправляет SIGCONT, но группа остаётся background (фоновой). Процессы продолжают работу, но не могут читать с терминала. Если фоновый процесс попытается вызвать read() на fd, связанном с PTY slave, ядро отправит его группе сигнал SIGTTIN, который по умолчанию приостанавливает процесс. Это защита: два процесса не должны одновременно читать ввод пользователя. Аналогично попытка записи из фона генерирует SIGTTOU (если включён флаг TOSTOP в termios).

kill %1 — bash отправляет SIGTERM группе и удаляет задание из списка. Синтаксис %N — это job ID, внутренний номер задания bash. Его не стоит путать с PID: kill %1 адресует задание bash, а kill 1450 адресует конкретный процесс.

Проверить текущее состояние групп и заданий:

$ ps -o pid,pgid,sid,tty,stat,comm
  PID  PGID   SID TT       STAT COMMAND
 1200  1200  1200 pts/3    Ss   bash
 1450  1450  1200 pts/3    T    cat
 1451  1450  1200 pts/3    T    grep
 1500  1500  1200 pts/3    S    sleep

PGID 1450 объединяет cat и grep — это одна pipeline-группа. Статус T означает stopped (приостановлен по SIGTSTP). SID (Session ID — идентификатор сессии, контейнера процессов, привязанного к одному терминалу) 1200 у всех — все процессы принадлежат одной сессии. bash (PID 1200) — лидер сессии: его PID совпадает с SID. sleep (PGID 1500) — отдельная фоновая группа: запуск через sleep 300 & создал для него собственную группу.

Сессии и контролирующий терминал

Группы процессов объединяются в сессию (session). Сессия — это контейнер, привязанный к одному терминалу. У каждой сессии есть лидер (session leader) — процесс, который вызвал setsid(). В типичном сценарии лидер — это bash, запущенный при входе в терминал. Его PID совпадает с SID (Session ID).

Терминал, привязанный к сессии, называется контролирующим терминалом (controlling terminal — терминал, через который сессия получает сигналы от клавиатуры и уведомления о разрыве связи). Связь между ними двусторонняя: терминал знает SID сессии, а каждый процесс сессии знает свой контролирующий терминал. Когда терминал закрывается (пользователь закрыл вкладку, разорвалось SSH-соединение), ядро отправляет сигнал SIGHUP лидеру сессии. bash по умолчанию транслирует SIGHUP всем своим заданиям — и они завершаются. Именно поэтому программы, запущенные без nohup или tmux, умирают при закрытии SSH.

Команда setsid() создаёт новую сессию: вызывающий процесс становится лидером новой сессии и лидером новой группы процессов, а контролирующий терминал отсоединяется. Процесс оказывается без терминала — сигналы от TTY (SIGINT, SIGTSTP, SIGHUP) больше не доставляются.

Цепочка SIGHUP показывает, почему сессии важны на практике. Когда пользователь закрывает вкладку gnome-terminal, ядро обнаруживает, что PTY master закрыт. Оно отправляет SIGHUP + SIGCONT лидеру сессии (bash). bash, получив SIGHUP, рассылает SIGHUP каждому заданию в своей сессии и завершается. Фоновый sleep 3600 &, не защищённый nohup, получает SIGHUP и умирает. Если бы sleep вызвал setsid() и оказался в собственной сессии — он бы не получил этот сигнал, потому что его контролирующий терминал отсоединён. Утилита nohup работает проще: она не создаёт новую сессию, а устанавливает обработчик SIGHUP в SIG_IGN (игнорирование) перед exec() целевой программы.

Паттерн демонизации

Серверный процесс (nginx, PostgreSQL) не должен зависеть от терминала пользователя. Классический паттерн:

pid_t pid = fork();          // 1. fork: родитель завершается,
if (pid > 0) exit(0);       //    потомок продолжает
 
setsid();                    // 2. потомок создаёт новую сессию
                             //    (без контролирующего терминала)
 
pid = fork();                // 3. второй fork: лидер сессии завершается
if (pid > 0) exit(0);       //    внук не является лидером сессии
                             //    и не может случайно получить
                             //    контролирующий терминал при open()

Первый fork() + exit() родителя нужен, чтобы вызов setsid() сработал: лидер группы не может создать сессию (он уже лидер), а потомок — может. Второй fork() — защита от повторного получения контролирующего терминала. По стандарту POSIX, если лидер сессии откроет TTY-устройство без флага O_NOCTTY, терминал может стать контролирующим. После второго fork() процесс-внук не является лидером сессии и эта ситуация исключена.

В современных системах systemd берёт эту работу на себя: daemon описывается как unit-файл, и systemd запускает его сразу в отдельной сессии без контролирующего терминала. Помимо отсоединения от терминала, полноценный daemon также перенаправляет stdin, stdout и stderr в /dev/null (или в лог-файл), меняет рабочую директорию на / (чтобы не держать монтированную файловую систему) и сбрасывает umask. Все эти шаги защищают daemon от зависимости от окружения запустившего его пользователя.

SSH: полный путь нажатия клавиши

Все предыдущие механизмы собираются вместе в сценарии SSH-подключения, где пользователь работает в vim на удалённом сервере.

 локальная клавиатура
        |
        v
 терминал (gnome-terminal)
        |
   write() в PTY master (локальный)
        |
        v
 line discipline (локальный, raw mode - ssh-клиент перевёл)
        |
        v
 PTY slave (локальный) --> ssh-клиент
        |                       |
        |              шифрование, TCP
        |                       |
        v                       v
                          [   сеть   ]
                                |
                                v
                            sshd (remote)
                                |
                           write() в PTY master (удалённый)
                                |
                                v
                         line discipline (удалённый)
                                |
                                v
                         PTY slave (удалённый) --> vim (read на fd 0)

На локальной машине SSH-клиент при запуске переводит свой PTY slave в raw mode: он отключает локальную обработку Ctrl+C, эхо и буферизацию, потому что всё это должно происходить на удалённой стороне. Каждый байт от клавиатуры проходит через локальный line discipline (который в raw mode просто пропускает его), попадает в SSH-клиент, шифруется и уходит по TCP на сервер.

На удалённой стороне sshd при аутентификации создаёт PTY-пару вызовом posix_openpt(). Мастер остаётся у sshd, slave передаётся дочернему процессу — remote bash (или напрямую vim, если SSH-команда указана явно). sshd записывает расшифрованный байт в PTY master. Дальше байт проходит через удалённый line discipline и попадает в PTY slave, откуда его читает vim.

Когда пользователь нажимает Ctrl+C, байт 0x03 проходит по всей цепочке без обработки до удалённого line discipline. Если vim работает в raw mode (а он работает), line discipline пропускает 0x03 как обычный байт — vim интерпретирует его по своим правилам. Если бы вместо vim работал обычный cat, удалённый line discipline в каноническом режиме перехватил бы 0x03 и отправил SIGINT foreground-группе удалённого терминала.

Вывод идёт в обратном направлении: vim пишет escape-последовательности в PTY slave, sshd читает их из master, шифрует и отправляет по TCP, локальный SSH-клиент получает данные и пишет в свой PTY slave, gnome-terminal читает из master и рисует символы на экране.

Отдельный механизм — изменение размера окна. Когда пользователь растягивает окно gnome-terminal, эмулятор обновляет размер локального PTY через ioctl(TIOCSWINSZ). SSH-клиент получает сигнал SIGWINCH, считывает новые размеры (struct winsize: rows и columns) и передаёт их через SSH-канал. На удалённой стороне sshd устанавливает новый размер для удалённого PTY master тем же ioctl(TIOCSWINSZ). Ядро генерирует SIGWINCH для foreground-группы удалённого терминала — vim перехватывает его и перерисовывает интерфейс под новые размеры. Без этой цепочки vim после ресайза окна продолжал бы рисовать в старых границах, оставляя артефакты по краям.

Ещё одна деталь: sshd на удалённой стороне создаёт для каждого подключения новую сессию. Удалённый bash становится лидером сессии с контролирующим терминалом /dev/pts/N. Если SSH-соединение разрывается (потеря сети, закрытие ноутбука), sshd закрывает PTY master. Ядро обнаруживает, что у PTY slave нет master, и посылает SIGHUP лидеру удалённой сессии — всё дерево процессов на удалённой стороне получает сигнал и завершается. Именно поэтому tmux и screen запускают shell в собственной сессии с отдельной PTY-парой: разрыв SSH-соединения убивает только sshd-форк и его PTY, а tmux-сессия с собственным PTY продолжает жить.

Наблюдаемость: диагностика терминалов

Четыре инструмента покрывают основные вопросы диагностики.

tty — показывает путь к устройству текущего терминала. Полезен для понимания, какой PTY slave использует текущая сессия:

$ tty
/dev/pts/3

Если процесс запущен без терминала (в cron, через pipe), tty выведет not a tty.

stty -a — полный дамп настроек line discipline для текущего терминала. Показывает размер окна (rows, columns), скорость (baud), все управляющие символы и состояние флагов:

$ stty -a
speed 38400 baud; rows 50; cols 190;
intr = ^C; quit = ^\; erase = ^?; kill = ^U; eof = ^D; susp = ^Z;
...
icanon echo isig ...

Если после аварийного выхода из vim терминал перестал показывать ввод — stty -a покажет -echo. Лечение: stty sane или reset.

ps -o pid,pgid,sid,tty,comm — покажет принадлежность процессов к группам, сессиям и терминалам. Позволяет ответить на вопрос: какие процессы привязаны к данному PTY, кто лидер сессии, кто foreground.

/proc/PID/sessionid — audit session ID процесса, назначенный при логине и неизменяемый даже через setsid(). Полезен для трассировки: все процессы, порождённые в рамках одного SSH-входа, будут иметь одинаковый sessionid, даже если они создали собственные сессии:

$ cat /proc/$$/sessionid
1185

Для глубокой отладки PTY-проблем (зависший терминал, потерянное соединение) помогает связка: tty определяет устройство, stty -a показывает состояние line discipline, ps отображает дерево процессов на этом PTY, а lsof /dev/pts/N выявляет все файловые дескрипторы, открывающие конкретный slave.

Типичный сценарий: после аварийного завершения vim терминал не показывает вводимые символы и не реагирует на Ctrl+C. Последовательность диагностики: stty -a (через слепой ввод) покажет -echo -icanon -isig — терминал остался в raw mode. stty sane сбросит все параметры к стандартным, или reset полностью переинициализирует терминал, включая отправку escape-последовательности для очистки экрана.

Sources


ELF и линковка | Трассировка