Каждое нажатие клавиши в Linux проходит длинный, но строго упорядоченный маршрут: от контроллера клавиатуры через ядро, строчную дисциплину и терминальный буфер до процесса, который стоит в ожидании read(). Подсистема TTY, отвечающая за этот маршрут, старше Linux почти на два десятилетия и до сих пор сохранила архитектуру времён перфолент и последовательных линий. Ниже показано, откуда в системе взялись виртуальные консоли, чем эмулятор терминала в графическом окне отличается от консоли ядра, что происходит между клавишей и оболочкой и почему пара Ctrl+C прерывает даже полностью зависшую программу.

Телетайп ASR-33 и последовательные линии, из которых вырос TTY

Сокращение TTY расшифровывается как teletypewriter, то есть телетайп. У первых компьютеров не было мониторов: ввод и вывод велись на печатные машинки с электроприводом, и самой массовой из них стала модель ASR-33 производства Teletype Corporation. Скорость у неё 110 бод, примерно 10 символов в секунду, код 7-битный ASCII, а результаты работы программы нужно было разыскивать на рулоне бумаги. Именно с таких устройств Кен Томпсон и Деннис Ритчи запускали ранние версии Unix на PDP-7 и PDP-11.

Телетайпы подключались последовательной линией RS-232, и Unix обошёлся с ними просто и элегантно: каждое устройство получило файл вида /dev/tty0, /dev/tty1 и дальше по списку. К машинам через мультипортовые платы вешали по 8, 16 и больше терминалов, и за каждым ждала своя пара login. Тогда же родился термин строчная дисциплина: между аппаратурой и процессом сидел программный слой, который собирал байты в строки, позволял править опечатки и отдавал процессу готовый результат.

Экранные терминалы сменили бумагу, и эталоном стал DEC VT100 1978 года выпуска, закрепивший набор управляющих последовательностей из стандарта ANSI X3.64: переместить курсор, стереть до конца строки, сменить цвет. Любой современный эмулятор терминала понимает эти последовательности до сих пор. Linux унаследовал у Unix и название, и логику: слово tty в системе означает терминальное устройство, а всей подсистеме достались повадки, которым без малого полвека.

Чем виртуальная консоль ядра отличается от эмулятора терминала в окне

Виртуальная консоль, она же виртуальный терминал, это часть ядра. Ядро умеет рисовать текст прямо на экране машины через фреймбуфер и держит для этого до 63 консолей, устройства /dev/tty1 по /dev/tty63, ограничение зашито константой MAX_NR_CONSOLES. Переключение Ctrl+Alt+F1 по F7 знакомо всякому, кто чинил упавшую графику, а из скрипта то же самое делает команда chvt N. Устройство /dev/tty0 служит алиасом активной консоли, а /dev/console отведено системным сообщениям ядра.

На части консолей при загрузке стартует программа getty: она печатает приглашение, принимает имя пользователя и запускает login, а тот в свою очередь оболочку. Дистрибутивы с systemd держат по умолчанию шесть консолей с приглашениями, параметр NAutoVTs=6 в /etc/systemd/logind.conf, и порождают очередные по мере переключения. В старых системах с sysvinit то же самое описывали строками файла /etc/inittab вида 1:2345:respawn:/sbin/agetty 38400 tty1.

Эмулятор терминала в графической среде устроен иначе. Это обычный процесс пользовательского пространства, xterm, gnome-terminal, konsole, alacritty, который рисует символы в окне средствами графической библиотеки и связывается с ядром через псевдотерминал pty. Он ничего не знает про фреймбуфер, не принадлежит машине и закрывается вместе с графическим сеансом, если его не защитить менеджером сессий вроде tmux. Разница на первый взгляд формальна, а по сути фундаментальна: виртуальную консоль обслуживает само ядро, эмулятор это программа, которой ядро лишь выдаёт терминальный интерфейс.

Обе ветки при всём различии сходятся в одной точке, внутри строчной дисциплины ядра, поэтому Ctrl+C, стирание строки и отображение ввода работают одинаково в окне и на tty3.

Путь нажатия клавиши от контроллера до оболочки

Нажатие клавиши на виртуальной консоли проходит несколько ядерных этажей. Сначала контроллер клавиатуры генерирует прерывание, драйвер превращает его в события подсистемы ввода, видимой в /dev/input/event0 и соседях. Затем слой виртуальных терминалов переводит сканкоды в символы по раскладке, которую задаёт файл /etc/vconsole.conf и команда loadkeys, и кладёт байты в буфер терминала активной консоли. Дальше слово за строчной дисциплиной, а завершает маршрут вызов read(), в котором стоит оболочка bash.

Оболочка всё это время не опрашивает клавиатуру в цикле, а спит в блокирующем чтении. Пришла полная строка с байтом перевода строки 0x0A, ядро разбудило процесс, bash разобрал команду и запустил программу. Путь занимает доли миллисекунды и полностью детерминирован.

Графическая среда добавляет ещё один этаж. События клавиатуры забирает X-сервер или композитор Wayland, эмулятор терминала превращает их в байты и записывает в мастер-конец псевдотерминала. Ядро прогоняет эти байты через ту же строчную дисциплину и отдаёт оболочке, которая сидит на ведомом устройстве /dev/pts/N. Вывод идёт в обратную сторону: программа пишет в /dev/pts/N, дисциплина наводит лоск выходными флагами, а эмулятор читает мастер-конец и рисует глифы в окне. Терминальный тип на этих маршрутах разный: на виртуальных консолях TERM=linux, в эмуляторах обычно TERM=xterm-256color, и программы смотрят по нему таблицу возможностей в базе terminfo.

Строчная дисциплина ядра и режимы работы termios

Реализация строчной дисциплины по умолчанию в Linux называется n_tty и живёт в файле drivers/tty/n_tty.c. Все её настройки собраны в структуре termios с пятью полями: c_iflag для входных флагов, c_oflag для выходных, c_cflag для параметров линии, c_lflag для локального поведения и c_cc для кодов управляющих символов.

Главный переключатель это флаг ICANON, канонический режим. В нём дисциплина копит ввод в буфере на 4096 байт и даёт редактировать текст до отправки: клавиша erase по умолчанию ^? убирает последний символ, werase ^W срезает слово, kill ^U чистит всю строку целиком. Процесс получает строку только после Enter. Флаг ECHO включает отображение набранного, поэтому sudo, спрашивая пароль, выключает эхо и ввод остаётся невидимым. Флаг ISIG разрешает превращение управляющих символов в сигналы, к нему вернёмся в следующем разделе.

Выключенные ICANON и ECHO дают сырой режим: байты уходят процессу сразу, без строк и эха. Так работают vim и другие полноэкранные программы, которым каждая клавиша нужна отдельно и немедленно.

Текущее состояние терминала показывает stty. Выполняется в любом терминале или на виртуальной консоли:

stty -a

Вывод получится примерно таким:

speed 38400 baud; rows 45; columns 160; line = 0;
intr = ^C; quit = ^\; erase = ^?; kill = ^U; eof = ^D; eol = <undef>;
start = ^Q; stop = ^S; susp = ^Z; rprnt = ^R; werase = ^W; lnext = ^V;

Переназначить символ можно тут же: stty intr ^X, после чего прерывание сработает по Ctrl+X. Такие правки чаще нужны серийным консолям, где stty -F /dev/ttyS0 115200 задаёт скорость линии.

Механика Ctrl+C и остальные управляющие клавиши терминала

Ctrl+C это не особая кнопка, о которой ядро знает отдельно, а обычный байт 0x03, третий символ таблицы ASCII, носивший в старину имя ETX. Когда дисциплина видит во входном потоке 0x03 и флаг ISIG включён, она не записывает байт в буфер ввода, а сверяется с массивом c_cc, где под индексом VINTR хранится код прерывания, по умолчанию тот самый 0x03. Дальше дисциплина просит ядро разослать сигнал SIGINT группе процессов переднего плана, связанной с этим терминалом.

Группа переднего плана появляется не случайно. Оболочка при запуске задачи создаёт для неё отдельную группу и делает её активной вызовом tcsetpgrp. Сигнал уходит именно активной группе, поэтому фоновая задача, запущенная с амперсандом, продолжает работать, сколько раз ни нажимай Ctrl+C. Проверить просто, всё выполняется в терминале:

sleep 100

и после нажатия Ctrl+C процесс завершается мгновенно. А теперь фоновый вариант:

sleep 100 &

и те же нажатия его не трогают, в чём легко убедиться командой jobs. Целиком группы процессов показывает строка ps -o pid,ppid,pgid,sid,tpgid,cmd, где колонка PGID это группа, а TPGID группа переднего плана.

Самое ценное в этой схеме то, что прерывание выполняет ядро, а не программа. Процесс может крутиться в бесконечном цикле, забыть про клавиатуру или залипнуть в сетевом ожидании, сигнал всё равно дойдёт, потому что байт распознаётся в дисциплине до приложения. Программист лишь при желании ставит обработчик: интерпретатор python ловит SIGINT и поднимает исключение KeyboardInterrupt, редактор vim ставит свой обработчик и решает сам, что делать с прерыванием.

По умолчанию ядро назначает управляющим комбинациям такие значения:

  1. intr = ^C отправляет сигнал SIGINT активной группе процессов на переднем плане;
  2. quit = ^\ отправляет сигнал SIGQUIT и просит ядро снять дамп памяти процесса;
  3. susp = ^Z отправляет сигнал SIGTSTP, ставит задачу на паузу, дальше с ней управляются команды fg и bg;
  4. eof = ^D сообщает о конце входа и закрывает сессию оболочки, если нажат на пустой строке;
  5. stop = ^S и start = ^Q приостанавливают и возобновляют вывод, за них отвечает входной флаг IXON;
  6. lnext = ^V вводит следующий символ буквально, минуя все проверки, удобно, когда нужно передать программе настоящий байт 0x03.

Классическая ловушка новичка связана с пунктом 5: человек задевает Ctrl+S, терминал будто замер, команды не отвечают. На деле вывод просто приостановлен флагом IXON, и Ctrl+Q возвращает всё на место за долю секунды, без перезапуска окна и без претензий к системе.

Псевдотерминалы в /dev/pts для sshd, tmux и графических окон

Псевдотерминал, pty, это пара устройств, мастер и слейв, соединённых каналом в ядре. Слейв для программы выглядит как самый обычный терминал со всеми дисциплинами, а мастер отдаёт наружу всё, что пришло со слейва, и наоборот. Эмулятор терминала держит мастер-конец, служба sshd на удалённой машине создаёт pty для интерактивного сеанса и подсоединяет слейв к оболочке, а мастер связан сетью с клиентом.

Отсюда занятный эффект: команда ssh host uname -a выполняется без терминала, байты идут по каналу напрямую, и байт 0x03 по пути ни во что не превращается, ведь строчной дисциплины на этом маршруте просто нет. Интерактивный ssh выделяет pty, и Ctrl+C снова работает. tmux устроен так же: каждая панель это отдельный псевдотерминал, поэтому сессия спокойно переживает разрыв соединения.

Размер окна тоже согласуется через pty: эмулятор вызывает ioctl TIOCSWINSZ, сообщая новое число строк и колонок, и ядро рассылает сигнал SIGWINCH группе переднего плана. Редактор vim перерисовывается при растягивании окна именно поэтому, а не по своей инициативе.

Количество pty в системе ограничено параметром ядра, посмотреть его можно в любом терминале:

cat /proc/sys/kernel/pty/max
cat /proc/sys/kernel/pty/nr

По умолчанию max равен 4096, а nr показывает текущее число занятых устройств. Сами устройства лежат в /dev/pts, по файлу на каждое открытое pty, а узел /dev/ptmx служит входом для новых. Каждая новая вкладка эмулятора удлиняет список в /dev/pts на один номер, и система молча делает всю работу, которая в 1970-х требовала протянуть кабель и занять порт на мультиплате.

Осмотр терминалов командами tty, w, who и chvt в живой системе

Команда tty печатает путь управляющего терминала текущего процесса. Выполняется в любом терминале:

tty

Типичный ответ в графической сессии /dev/pts/0, на второй виртуальной консоли /dev/tty2. Если терминала у процесса нет, например команда запущена из планировщика, ответ будет not a tty.

Кто и где работает, показывают who и w: у обеих есть колонка TTY, а w дополнительно выводит запущенную команду и время простоя. Переключиться на соседнюю консоль можно командой chvt 3, она выручает, когда графика встала и нужно добраться до текстовой консоли. Обратно вернёт chvt 1 или Ctrl+Alt+F1.

Мини-сеанс для закрепления, всё выполняется в обычном терминале:

tty
/dev/pts/0
sleep 100
^C
sleep 100 &
[1] 4213
ps -o pid,pgid,tpgid,cmd
    PID    PGID  TPGID CMD
   4213    4213   4217 sleep 100
   4217    4217   4217 ps -o pid,pgid,tpgid,cmd

Колонка TPGID показывает, что активна сейчас группа 4217, в которой крутится сама команда ps, а спящий sleep живёт в своей группе 4213 и потому не боится Ctrl+C.

Есть особое удовольствие в том, чтобы разобрать механизм, который каждый день работает под руками и молчит о своей работе. TTY остался живым памятником инженерной экономии Unix: телетайпы давно ушли в музеи, а их повадки, байты-команды, строки, сигналы и группы процессов, ядро сохранило почти без изменений. После этого разбора Ctrl+C перестаёт быть случайной комбинацией и становится понятной цепочкой: байт 0x03, флаг ISIG, сигнал SIGINT, группа переднего плана. И когда в очередной раз спасённый из зацикливания процесс закроется по этой цепочке, в голове будет звучать короткое объяснение: внутри ядра проснулась дисциплина возрастом в полвека и сделала ровно то, чему её учили ещё на PDP-11.