Хабрахабр

Hello, World! Глубокое погружение в Терминалы

Однако, там был пропущен момент о том, какой путь проходят данные после того, как они попадают в терминальное устройство. На написание данной статьи меня вдохновила статья об анализе Сишного printf. Также мы разберемся, чем отличается Terminal от Shell, что такое Pseudoterminal, как работают эмуляторы терминалов и многое другое. В данной статье я хочу исправить этот недочет и проанализировать путь данных в терминале.

Основы

Информации об этом написано уже довольно много, поэтому ничего нового вы здесь не услышите. Давайте для начала разберемся, что такое Terminal, Shell, Console, чем отличается Terminal Emulator от обычного Terminal и почему он так назван. Кто уже знает, что все эти вещи обозначают, может смело пропускать данный раздел. Почти вся информация здесь была взята из интернета, ссылки приведу в конце статьи.

Terminal

До того, как терминалы стали именно данной комбинацией, они являлись неким устройством под названием teleprinter (teletype, teletypewriter или TTY сокращенно), то есть комбинацией принтера и клавиатуры. Terminal (терминал) — это комбинация дисплея и клавиатуры, то есть физическое устройство. Таким образом возможно было работать нескольким пользователям за одним и тем же компьютером, причем каждому выделялась своя сессия, независимая от других. Обычно несколько терминалов подключались к одному и тому же компьютеру. Терминал был назван так потому, что он находился на конце терминального кабеля (terminal end).

Это Teletype:

Teletype

А это Terminal:

Terminal

Console

Дело в том, что большинство терминалов были соединены неявно, но хотя бы один был подключен напрямую к компьютеру. Console (консоль) — терминал, который подключен напрямую к компьютеру. Консоль было разрешено использовать строго определенному кругу лиц, так как она позволяла настраивать компьютер.

Shell

Если предыдущие два представляют собой физические устройства, то данное определение относится исключительно к программному обеспечению.

Главное предназначение — запускать другие программы. Shell — это command line interpreter. Самым распространенным является Bash (что как подсказывает Википедия, является каламбуром для «Born again» Shell, то есть «возрождённый» Shell). Существует большое количество различных Shell'ов. Другие примеры: Dash (легковесный Shell, доступен, если запустить бинарник по адресу /bin/sh), Zsh.

Поэтому далее мы рассмотрим такие вещи, как Terminal Emulator и Virtual Console. Конечно же, и терминалы, и консоли не могли не найти своего отражения в современности.

Terminal Emulator

Эмулятор терминала требуется для программ, которые не могут напрямую взаимодействовать с X Window System — Bash, Vim и прочие. Terminal Emulator — эмулятор старого доброго терминала.

Давайте для начала установим обязанности терминала:
1) Передача ввода пользователя в компьютер
2) Доставка вывода компьютера на дисплей

В любом случае, смысл сохраняется — между пользователем и запущенной программой, существует какой-то слой, отвечающий за ввод/вывод. Так и наш Terminal Emulator выполняет абсолютно то же самое: он доставляет ввод пользователя в запущенную программу, а также отображает вывод программы на дисплей. Примеры Terminal Emulator: gnome-terminal, xterm, konsole.

Shell — это command line interpreter, то есть просто исполнитель команд, он не имеет графической оболочки. Прошу не путать Shell и Terminal Emulator!
Terminal Emulator — GUI приложение, то есть окно в X Windows System. Terminal Emulator и Bash — абсолютно 2 различные программы. Если говорить совсем правильно, вы не запускаете Bash, вы запускаете Terminal Emulator, который запускает внутри себя Bash. Первая отвечает исключительно за ввод/вывод, вторая — за обработку команд.

Далее в статье все упоминания терминала будут относиться к эмулятору терминала.

Virtual Console (Virtual Terminal)

То, что вы сейчас видели — называется Virtual Console (виртуальная консоль) или Virtual Terminal (виртуальный терминал). Нажмите Ctrl+Alt+FN, где N, обычно, имеет значения от 1 до 6. Множество терминалов были подсоединены к одному компьютеру и каждый терминал был отдельной сессией, независимой от других. Помните, что я говорил ранее о терминалах? Virtual Console повторяет эту идею: внутри вашего компьютера может быть несколько независимых сессий (однако, ресурсы компьютера все же, очевидно, общие).

Вы можете именовать данную сущность как Virtual Console, так и Virtual Terminal, так как по определению, консоль — это терминал, подключенный напрямую к компьютеру, но ведь все виртуальные терминалы в каком-то смысле подключены напрямую к компьютеру.

TTY устройства

Хотя телетайпы вы вряд ли уже найдете, но сокращение TTY дошло и до наших дней. Каждому терминалу назначается свое TTY устройство (терминальное устройство), которое обеспечивает работу консоли.

TTY устройство состоит из двух фундаментальных компонентов:

  1. Драйвер устройства. Он отвечает за доставку ввода с клавиатуры в программу и за отображение вывода программы на экран.
  2. TTY Line Discipline (рус. — дисциплина линии). Дисциплина линии — это интерфейс доступа к драйверу, который, однако, привносит немало в логики в TTY устройство. Можно сказать, что дисциплина линии проксирует вызовы к драйверу. Какова зона ответственности данного компонента, мы будем узнавать по ходу статьи.

Строение TTY устройства:

Существует 3 типа TTY устройств:

  1. Console device — обеспечивает работу Virtual Console. Ввод и вывод данного устройства управляется полностью ядром.
  2. PTY device (псевдотерминал) — обеспечивают работу терминала в оконном интерфейсе. Ввод и вывод данного устройства управляется эмулятором терминала, который работает в пользовательском пространстве.
  3. Serial device — общается напрямую с железом. Обычно не используется напрямую, а существует как самый нижний уровень в организации архитектуры терминального устройства.

В данной статье мы будем говорить именно о втором типе TTY устройств — псевдотерминалах.

TTY Line Discipline

Начнем рассматривать дисциплину линии TTY устройств.

Это включает в себя, например, обработку управляющих символов (см Управляющие символы) и форматирование вывода. Первой важной особенностью дисциплиной линии является то, что она отвечает за процессинг ввода/вывода. Например, вы вводите любой текст, но вдруг понимаете, что ошиблись в написании чего-то и хотите это стереть — именно тут в дело вступает дисциплина линии.

По умолчанию TTY устройство работает в каноничном режиме с включенным эхо (echoing). Разберем подробно, что именно происходит, когда мы работаем в Bash, запущенном в терминале. Эхо — это отображение введенных вами символов на экране.

Она читает символ в свой внутренний буфер, видит, что включен режим echo и выводит символ на экран. Когда мы вводим, к примеру, символ a, данный символ посылается в TTY устройство, но перехватывается дисциплиной линии TTY устройства. Пусть мы нажимаем backspace на клавиатуре. В это время еще ничего не доступно для чтения в программе, к которой прикреплено терминальное устройство. Теперь, если мы нажмем Enter, TTY Line Discipline наконец пошлет в буфер чтения терминального устройства все, что было записано раннее в внутренний буфер дисциплины, включая LF. Символ ^? снова перехватывается дисциплиной линии, и последняя, понимая, что пользователь хочет стереть последний введенный символ, удаляет данный символ из своего внутреннего буфера и стирает этот символ также с экрана. При этом, на экран выводятся символы CR и LF для того, чтобы перевести курсор на новую строку — это форматирование вывода.

Так работает каноничный режим — передает все введенные символы в устройство только после нажатия Enter, производит процессинг управляющих символов и форматирует вывод.

TTY Line Editing

Следует сказать, что Line Editing — это общее понятие и относится оно к процессингу ввода. TTY Line Editing — это тот компонент, который отвечает за процессинг ввода в дисциплине линии. Наппример, Bash и Vim имеют свой Line Editing.

Давайте немного поэкспериментируем. Мы можем контролировать настройки дисциплины линии текущего TTY устройства с помощью программы stty.

Откройте Bash или любой другой Shell и введите:

stty icanon -echo

Вы только что отключили эхо — то есть отображение введенных символов на экране. Теперь попробуйте что-нибудь ввести — и вы не увидите вашего ввода (не беспокойтесь, вы все еще можете передавать ввод в программу). Теперь введите:

stty raw echo

Вы видите, как нарушается вывод. Попробуйте что-нибудь вводить. Теперь попробуйте вводить специальные символы (клавиша Ctrl + любой символ на клавиатуре) или же просто нажать Enter. Но для большего эффекта давайте зайдем в Dash — введите /bin/sh. Дело в том, что мы, зайдя в самый простой Shell, кроме Line Editing самой дисциплины отключили также Line Editing Bash, и теперь можем вовсю наблюдать эффект включения raw режима дисциплины линии. Вы недоумеваете — что это за странные символы на экране? Зачем raw режим нужен? Данный режим совершенно не производит процессинг ввода и не форматирует вывод. Например, для Vim: он открывается во всё окно терминала и производит сам процессинг ввода, хотя бы для того, чтобы специальные символы дисциплины линии не пересекались с специальными символами самого Vim.

В этом нам поможет команда stty <control-character> <string>.
Введите в Bash: Для еще большего понимания давайте рассмотрим кастомизацию управляющих символов.

stty erase 0

Кнопка backspace обычно имеет значение ^?, но теперь данный специальный символ будет передаваться в буфер чтения PTS устройства буквально — попробуйте сами. Теперь управляющий символ erase будет назначен на символ 0. Вернуть обратно настройку вы можете с помощью команды stty erase ^\? или просто закрытием терминала, ведь мы влияли только на текущее tty устройство. Стирать символы же теперь вы можете с помощью кнопки 0 на клавиатуре, ведь вы сами попросили tty line discipline распозновать введенный символ как управляющий символ erase.

Больше информации вы сможете найти в man stty.

Terminal Emulator и Pseudoterminal

Обычно, это какой-то Shell (например, Bash). Каждый раз, когда мы открываем новый терминал в X Window System, GNOME Terminal Server порождает новый процесс и запускает в нём выбранную по-умолчанию программу.

Сам псевдотерминал существует в ядре, однако ввод получает из пользовательского пространства — из эмулятора терминала. Общение с запущенной программой происходит через так называемый Pseudoterminal (псевдотерминал, PTY).

Используется GNOME Terminal Server для передачи ввода с клавиатуры в запущенную внутри терминала программу, а также для чтения вывода программы и отображения вывода на дисплей. Псевдотерминал состоит из следующих двух виртуальных TTY устройств:
1) PTY master (PTM) — ведущая часть псевдотерминала. Используется программой, запущенной внутри терминала, для чтения ввода с клавиатуры и отображения вывода на экран. GNOME Terminal Server в свою очередь общается с X Window System по X протоколу.
2) PTY slave (PTS) — ведомая часть псевдотерминала. По крайней мере, так думает сама программа (объясню, что это значит, чуть далее).

И наоборот: любые данные, записанные в PTM устройство, являются вводом PTS устройства. Любые данные, записанные в PTS устройство, являются вводом PTM устройства, то есть становятся доступны для чтения на PTM устройстве. Каждому PTM устройству сопоставляется свое PTS устройство. Именно таким образом и происходит общение GNOME Terminal Server и запущенной внутри терминала программы.

Вызов open() возвращает файловый дескриптор созданного PTM устройства — master_fd.
2) GNOME Terminal Server создает новый процесс с помощью вызова функции fork(). Процесс запуска нового терминала выглядит примерно следующим образом:
1) GNOME Terminal Server создает master и slave устройства с помощью вызова функции open() на специальном устройстве /dev/ptmx. Теперь стандартные потоки ввода/вывода терминала ведут на данное устройство.
4) В терминале запускается нужная программа с помощью вызова функции exec(). Данный процесс и будет являться новым терминалом.
3) В терминале PTS устройство открывается на файловых дескрипторах 0, 1, 2 (stdin, stdout и stderr соответственно). Любая программа, запущенная впоследствии из Bash, будет иметь те же файловые дескрипторы, что и сам Bash, то есть потоки программы будут направлены на PTS устройство. Обычно запускается какой-то Shell (например, Bash).

Вы можете сами посмотреть, куда направлены стандартные потоки вывода терминала, с помощью команды ls -la /proc/self/fd:

Дело в том, что GNOME Terminal Server уже имеет файловый дескриптор открытого PTM устройства и ему не требуется путь к нему, однако в дочернем процессе мы должны открыть PTS устройство на стандартных потоках вывода с помощью вызова функции open(), которая требует путь к файлу. PTS устройство находится по пути /dev/pts/N, а путь к PTM устройству нас абсолютно не интересует.

Дело в том, что PTS также является терминальным устройством (TTY устройством), но разница между PTS устройством и действительным TTY устройством в том, что PTS устройство ввод получает не с клавиатуры, а с master устройства, а вывод идет не на дисплей, а на master устройство. Помните, я сказал, что программа, использующая PTS устройство, только думает, что она общается напрямую с терминалом? Разница между эмулятором терминала и псевдотерминалом в том, что эмулятор терминала — это лишь графическая программа, позволяющая запускать терминал прямо внутри оконного интерфейса, но реализована эта возможность с помощью псевдотерминала. Именно поэтому псевдотерминал назван так — псевдотерминал лишь имитирует (опять??) терминал.

Вот почему: То, что PTS устройство является TTY устройством — это очень важно.

  1. Программе, к которой прикреплено терминальное устройство, доступны все возможности обычного терминала. Например: отключение эха, отключение/включение каноничного вида.
  2. Программа, зная, что к ней прикреплен терминальное устройство (говорится, что программа имеет управляющий терминал), может работать в интерактивном режиме и просить у пользователя ввода. Например, спрашивать логин и пароль.
  3. Здесь также существует TTY Line Discipline, поэтому мы имеем возможность обрабатывать управляющие символы до того, как они дойдут до программы, а также форматировать вывод программы.

Более того, дисциплина линии PTM устройства установлена в raw режим, поэтому процессинг при передаче данных от PTS к PTM устройству не производится. PTM устройство также является TTY устройством, но это не играет никакой роли, так как оно не используется в роли управляющего терминала. Данный момент сыграет еще большую роль, как мы увидим позднее. Однако, вызовы read и write из пользовательского пространства все равно сперва обслуживаются дисциплиной линии на обоих устройствах.

Процесс общения GNOME Terminal Server и запущенной внутри терминала программы выглядит следующим образом:

Данная дисциплина линии отвечает за процессинг данных, переходящих от PTM к PTS устройству, а также за общение между устройствами. Здесь следует поподробнее рассмотреть роль дисциплины линии, лежащей между двумя устройствами (самая нижняя на картинке). Когда мы находимся в драйвере PTS устройства, мы задействуем дисциплину линии PTM устройства, и наоборот.

Виртуальные устройства

Да, все устройства в Unix-подобных системах являются файлами благодаря фундаментальному принципу Unix, который гласит, что все является файлом. Вы, наверное, могли подумать, что можете открыть файл по пути /dev/pts/N и писать или читать данные из него, как из обычного текстового файла? — device file) не являются текстовыми файлами. Однако, никакие специальные файлы устройств (англ. Такие устройства называются виртуальными устройствами (virtual device) — то есть существуют исключительно в памяти, а не на диске.

Однако, вы можете использовать эти устройства через операции write() и read(), вызов которых обслужит драйвер устройства. Не стоит пытаться открывать данные файлы как обычные текстовые файлы. Давайте попробуем сделать это.

Данная команда покажет, какое TTY устройство обслуживает текущий активный терминал. Откройте два окна терминала и введите в каждом команду tty. Сейчас вы записали данные в PTS устройство второго окна так, как будто бы это сделала программа, работающая в том терминале. Теперь введите echo "Hello, World!" > /dev/pts/N в первом окне терминала, где N — это индекс PTS устройства второго окна, переключитесь на второе окно — и вы увидите ваш ввод с первого окна.

Устройство псевдотерминала

Будет много кода, но я постараюсь объяснять каждый приведенный блок кода максимально подробно, сокращать неважные детали и идти последовательно. Мы все ближе приближаемся к заключительной части статьи, но перед этим заглянем "под капот" Linux — рассмотрим устройство псевдотерминала на уровне ядра.

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

Такой драйвер имеется и у нашего псевдотерминала. Когда Linux запускается, он загружает необходимые драйверы устройств. Его регистрация начинается с вызова данной функции:

static int __init pty_init(void)
{ legacy_pty_init(); unix98_pty_init(); // <- то, что нас интересует return 0;
}
device_initcall(pty_init); // сообщает, какую функцию вызывать при регистрации драйвера

Для всех современных систем будет вызвана функция unix98_pty_init():

static void __init unix98_pty_init(void)
{ ptm_driver = tty_alloc_driver(NR_UNIX98_PTY_MAX, TTY_DRIVER_RESET_TERMIOS | TTY_DRIVER_REAL_RAW | TTY_DRIVER_DYNAMIC_DEV | TTY_DRIVER_DEVPTS_MEM | TTY_DRIVER_DYNAMIC_ALLOC); if (IS_ERR(ptm_driver)) panic("Couldn't allocate Unix98 ptm driver"); pts_driver = tty_alloc_driver(NR_UNIX98_PTY_MAX, TTY_DRIVER_RESET_TERMIOS | TTY_DRIVER_REAL_RAW | TTY_DRIVER_DYNAMIC_DEV | TTY_DRIVER_DEVPTS_MEM | TTY_DRIVER_DYNAMIC_ALLOC); if (IS_ERR(pts_driver)) panic("Couldn't allocate Unix98 pts driver"); ptm_driver->driver_name = "pty_master"; ptm_driver->name = "ptm"; ptm_driver->major = UNIX98_PTY_MASTER_MAJOR; ptm_driver->minor_start = 0; ptm_driver->type = TTY_DRIVER_TYPE_PTY; ptm_driver->subtype = PTY_TYPE_MASTER; ptm_driver->init_termios = tty_std_termios; ptm_driver->init_termios.c_iflag = 0; ptm_driver->init_termios.c_oflag = 0; ptm_driver->init_termios.c_cflag = B38400 | CS8 | CREAD; ptm_driver->init_termios.c_lflag = 0; ptm_driver->init_termios.c_ispeed = 38400; ptm_driver->init_termios.c_ospeed = 38400; ptm_driver->other = pts_driver; tty_set_operations(ptm_driver, &ptm_unix98_ops); pts_driver->driver_name = "pty_slave"; pts_driver->name = "pts"; pts_driver->major = UNIX98_PTY_SLAVE_MAJOR; pts_driver->minor_start = 0; pts_driver->type = TTY_DRIVER_TYPE_PTY; pts_driver->subtype = PTY_TYPE_SLAVE; pts_driver->init_termios = tty_std_termios; pts_driver->init_termios.c_cflag = B38400 | CS8 | CREAD; pts_driver->init_termios.c_ispeed = 38400; pts_driver->init_termios.c_ospeed = 38400; pts_driver->other = ptm_driver; tty_set_operations(pts_driver, &pty_unix98_ops); if (tty_register_driver(ptm_driver)) panic("Couldn't register Unix98 ptm driver"); if (tty_register_driver(pts_driver)) panic("Couldn't register Unix98 pts driver"); /* Now create the /dev/ptmx special device */ tty_default_fops(&ptmx_fops); ptmx_fops.open = ptmx_open; cdev_init(&ptmx_cdev, &ptmx_fops); if (cdev_add(&ptmx_cdev, MKDEV(TTYAUX_MAJOR, 2), 1) || register_chrdev_region(MKDEV(TTYAUX_MAJOR, 2), 1, "/dev/ptmx") < 0) panic("Couldn't register /dev/ptmx driver"); device_create(tty_class, NULL, MKDEV(TTYAUX_MAJOR, 2), NULL, "ptmx");

Здесь нас интересует 3 вещи:

  1. Вызовы tty_set_operatons для драйвера pty master и pty slave устройств.
  2. Функция ptmx_open, которая отвечает за создание обоих частей псевдотерминала при открытии специального устройства /dev/ptmx. Важно: /dev/ptmx — это не PTM устройство, а всего лишь интерфейс для создания нового пседотерминала.
  3. Регистрация драйвера PTM и PTS устройств.

Пойдем по порядку:

1. tty_set_operations

Функция tty_set_operations() всего лишь устанавливает таблицу функций для текущего драйвера:

void tty_set_operations(struct tty_driver *driver, const struct tty_operations *op)
{ driver->ops = op;
};

Структура tty_operations — это таблица функций, которая используется для доступа к функциям драйвера TTY устройства.

Выделю самое главное в структурах pty_unix98_ops и ptm_unix98_ops, которые являются таблицей функций для соответствующих частей псевдотерминала:

static const struct tty_operations ptm_unix98_ops = { .install = pty_unix98_install, .remove = pty_unix98_remove, .open = pty_open, .close = pty_close, .write = pty_write, // ...
}; static const struct tty_operations pty_unix98_ops = { .install = pty_unix98_install, .remove = pty_unix98_remove, .open = pty_open, .close = pty_close, .write = pty_write, // ...
};

Здесь можете наблюдать уже знакомую по статье о Сишном printf функцию pty_write — к ней мы вернемся чуть позднее.

Давайте добавим данную структуру в нашу корзину компонентов:

Кстати, заметьте, что отсутствует функция для read() операции — нет ничего похожего на pty_read(). Как видите, основные методы обоих драйверов совсем не отличаются. Таким образом, мы узнаём о второй важной особенности дисциплины линии — чтение данных с TTY утройства. Дело в том, что чтение будет обслуживаться исключительно дисциплиной линии.

2. ptmx_open

Теперь перейдем к ptmx_open():

static int ptmx_open(struct inode *inode, struct file *filp)
{ struct tty_struct *tty; // запомните эту структуру - она очень важна! fsi = devpts_acquire(filp); // получаем виртуальную файловую систему devpts index = devpts_new_index(fsi); // получаем индекс для нового устройства в /dev/pts // ... tty = tty_init_dev(ptm_driver, index); // ... devpts_pty_new(fsi, index, tty->link); // создаём новый файл в /dev/pts retval = ptm_driver->ops->open(tty, filp); // открываем PTM устройство, ничего особенного
}

Здесь мы покидаем зону ответственности PTY драйвера и переходим к файлу, который отвечает только за общие TTY устройства и ничего не знает о нашем псевдотерминале. Нас интересует функция tty_init_dev(), где первым аргументом является драйвер PTM устройства, а вторым — индекс устройства.

struct tty_struct *tty_init_dev(struct tty_driver *driver, int idx)
{ struct tty_struct *tty; tty = alloc_tty_struct(driver, idx); retval = tty_driver_install_tty(driver, tty); /* * Structures all installed ... call the ldisc open routines. */ retval = tty_ldisc_setup(tty, tty->link); // ничего необычного, просто открывает дисциплину линии обоих устройств return tty;
}

Сначала разберем функцию alloc_tty_struct():

struct tty_struct *alloc_tty_struct(struct tty_driver *driver, int idx)
{ struct tty_struct *tty; tty = kzalloc(sizeof(*tty), GFP_KERNEL); // создаем tty_struct tty_ldisc_init(tty) // создает дисциплину линии для текущей tty_struct tty->driver = driver; // устанавливает ссылку на драйвер в текущей tty_struct tty->ops = driver->ops; // устанавливает ссылку на операции драйвера в текущей tty_struct. Сделано просто для удобства tty->index = idx; // устанавливает индекс tty устройства return tty;
}

Единственное, что нас здесь интересует, это функция tty_ldisc_init():

int tty_ldisc_init(struct tty_struct *tty)
{ struct tty_ldisc *ld = tty_ldisc_get(tty, N_TTY); if (IS_ERR(ld)) return PTR_ERR(ld); tty->ldisc = ld; // устанавливает ссылку на дисциплину линии в текущей tty_struct return 0;
}

Которая вызывает tty_ldisc_get():

static struct tty_ldisc *tty_ldisc_get(struct tty_struct *tty, int disc)
{ struct tty_ldisc *ld; // структура дисциплины линии struct tty_ldisc_ops *ldops; // таблица функций дисциплины линии ldops = get_ldops(disc); // получает операции для выбранной дисциплины линии. Дело в том, что дисциплины линии могут быть разного типа. Наш тип - N_TTY ld = kmalloc(sizeof(struct tty_ldisc), GFP_KERNEL | __GFP_NOFAIL); ld->ops = ldops; // устанавливает таблицу функций для текущей дисциплины ld->tty = tty; // устанавливает ссылку на tty_struct в текущей дисциплине. Это требуется для доступа к драйверу устройства из дисциплины return ld;
}

Обе структуры имеют ссылки друг на друга. Итак, мы рассмотрели вызов функции alloc_tty_struct(), которая создает структуру tty_struct вместе с дисциплиной линии — структурой tty_ldisc. Давайте познакомимся с данными структурами поближе.

  • tty_struct — это структура для доступа к драйверу TTY устройства и некоторым другим полям. Выглядит она следующим образом:

struct tty_struct { struct tty_driver *driver; // драйвер TTY устройства const struct tty_operations *ops; // операции драйвера. Это то же самое, что и driver->ops, то есть сделано просто для удобства int index; // индекс устройства struct tty_ldisc *ldisc; // указатель на дисциплину линии struct tty_struct *link; // указатель на другую часть PTY // ...
}

  • tty_ldisc — это структура для дисциплины линии TTY устройства. Состоит она всего из двух полей и выглядит следующим образом:

struct tty_ldisc { struct tty_ldisc_ops *ops; // таблица функций дисциплины struct tty_struct *tty; // указатель на tty_struct текущей дисциплины. Используется для доступа к драйверу устройства
};

Давайте добавим все рассмотренные до этого момента структуры в нашу корзину и свяжем их таким же образом, как они связаны в коде:
Строение tty_struct Вроде бы ничего сложного?

А что же о PTS устройстве? Но мы создали tty_struct всего лишь для PTM устройства. Для этого вернемся к функции tty_init_dev() и вспомним о том, что дальше нас ожидает вызов функции tty_driver_install_tty():

/** * This method is responsible * for ensuring any need additional structures are allocated and configured. */
static int tty_driver_install_tty(struct tty_driver *driver, struct tty_struct *tty)
{ return driver->ops->install ? driver->ops->install(driver, tty) : tty_standard_install(driver, tty);
}

PTS устройство и будет являться нашей дополнительной структурой. Комментарий подсказывает нам, что данный метод ответственен за создание различных дополнительных структур. Но мы то с вами понимаем, что все устройства — это всего лишь какие-то структуры, так что идем дальше. Признаюсь, это было для меня крайне удивительно, ибо это, черт возьми, целое устройство, а не просто какая-то дополнительная структура! Для этого посмотрим на таблицу функций для PTM драйвера ещё раз: Хорошо, что такое здесь driver->ops->install?

static const struct tty_operations ptm_unix98_ops = { .install = pty_unix98_install, // ...

И поймем, что нас интересует функция pty_unix98_install():

static int pty_unix98_install(struct tty_driver *driver, struct tty_struct *tty)
{ return pty_common_install(driver, tty, false);
}

Которая вызывает функцию pty_common_install():

static int pty_common_install(struct tty_driver *driver, struct tty_struct *tty, bool legacy)
{ struct tty_struct *o_tty; // tty_struct для другой части PTY - то есть для PTS устройства // а здесь мы проверяем, для какого устройства была вызвана функция install. Дело в том, что для PTM устройства мы уже создали tty_struct, и нам не надо делать это снова if (driver->subtype != PTY_TYPE_MASTER) return -EIO; o_tty = alloc_tty_struct(driver->other, idx); tty->link = o_tty; o_tty->link = tty;
}

Обе структуры хранят указатели друг на друга для общения между собой. Мы видим, что для PTS устройства создается абсолютно такая же структура tty_struct за исключением того, что в ней будет находиться драйвер PTS устройства. Добавляем tty_struct для PTS устройства в нашу корзину.

Регистрация драйвера

Мы не будем рассматривать весь процесс регистрации драйвера, так как нас интересует только установка таблицы функций для файла TTY устройства (ведь мы должны как-то получить доступ к самому устройству при работе с файлом?).
Это — таблица функций, которая будет установлена для файла как PTM, так и PTS устройства:

static const struct file_operations tty_fops = { .llseek = no_llseek, .read = tty_read, .write = tty_write, .poll = tty_poll, .unlocked_ioctl = tty_ioctl, .compat_ioctl = tty_compat_ioctl, .open = tty_open, .release = tty_release, .fasync = tty_fasync, .show_fdinfo = tty_show_fdinfo,
};

Мы не будем добавлять данную структуру в нашу корзину, ибо она в принципе то и не относится к устройству псевдотерминалов и служит лишь для доступа к TTY устройству.

Мы рассмотрели процесс создания обоих устройств, который будет произведен при открытии мультиплексора /dev/ptmx. Готово. Таким образом, включая второе PTS устройство, которое имеет точно такое же строение, как и PTM устройство, общая картина строения псевдотерминала складывается следующая:

Hello, World!

В данной главе мы полностью разберем путь нашей строки "Hello, World!", отправленной из простой Си программы в терминальное устройство. Ну вот мы и подошли к самому главному.

#include <stdio.h> void main() { printf("Hello, World!\n");
}

На самом деле, программа не знает ничего, кроме того, что она пишет в стандартный поток вывода. Итак, наша строка "Hello, World!" отправляется в увлекательное путешествие. Направьте stdout в /dev/null — и вывод вообще не будет нигде отображаться. Ей больше нет разницы, куда этот вывод пойдет. Здесь я не буду рассказывать о вызовах библиотечных Си функций, а начнем сразу с файловой системы Linux.

Так как каждое устройство в Unix является файлом с определенными для него функциями write(), read(), close() и прочими, то при вызове write() на /dev/pts/0 мы попадаем в общую для всех файлов функцию __vfs_write():

ssize_t __vfs_write(struct file *file, const char __user *buf, size_t count, loff_t *pos)
{ ssize_t ret; //... ret = file->f_op->write(file, buf, count, pos); //... return ret;
}

Как вы помните, таблица функций была установлена при регистрации драйвера и выглядела она следующим образом: Здесь мы вызываем операцию write() из таблицы функций для текущего файла.

static const struct file_operations tty_fops = { // ... .write = tty_write, // ...

Функция tty_write():

static ssize_t tty_write(struct file *file, const char __user *buf, size_t count, loff_t *ppos)
{ struct tty_struct *tty = file_tty(file); struct tty_ldisc *ld; ssize_t ret; ld = tty_ldisc_ref_wait(tty); ret = do_tty_write(ld->ops->write, tty, file, buf, count); tty_ldisc_deref(ld); return ret;
}

Таблица функций дисциплины линии выглядела следующим образом: Данная функция получает структуру tty_struct для текущего файла TTY устройства, а потом достает из нее дисциплину линии и вызывает функцию write() для нее.

static struct tty_ldisc_ops n_tty_ops = { .write = n_tty_write, // ...
};

Переходим к функции n_tty_write():

/** * n_tty_write - write function for tty * @tty: tty device * @file: file object * @buf: userspace buffer pointer * @nr: size of I/O */
static ssize_t n_tty_write(struct tty_struct *tty, struct file *file, const unsigned char *buf, size_t nr)

}

Найдем эту функцию в таблице функций драйвера: Итак, строка "Hello, World!" наконец отправилась в write() функцию драйвера PTS устройства.

static const struct tty_operations pty_unix98_ops = { .write = pty_write, // ...
}

Функция pty_write():

static int pty_write(struct tty_struct *tty, const unsigned char *buf, int c)
{ struct tty_struct *to = tty->link; // получаем ссылку на другую сторону PTY. В нашем случае - это PTM устройство if (c > 0) { // передаем данные на PTM устройство c = tty_insert_flip_string(to->port, buf, c); // уведомить другую сторону о том, что готовы новые данные для чтения if (c) { tty_flip_buffer_push(to->port); tty_wakeup(tty); } } return c;
}

Давайте здесь остановимся и проследим наш путь до этого места:

__vfs_write() -> // 1-й уровень: файловая система tty_write() -> do_tty_write() -> n_tty_write() -> // 2-й уровень: дисциплина линии pty_write() // 3-й уровень: драйвер

Итак, буфер передается в очередь ввода на PTM устройство. Вроде бы ничего не упустили. Разберемся, как именно это происходит.

Flip buffer — это структура данных, состоящая из двух массивов. Для начала, следует познакомить вас с новой структурой данных под названием flip buffer. Когда массив заполняется, ожидающая данных сторона будет об этом уведомлена и сможет прочитать данные из этого массива. Когда tty driver получает новые данные, он сохраняет их в первом массиве. Когда и этот массив заполняется, ожидающая данных сторона снова будет уведомлена, а новые данные в следующий раз будут снова записываться в первый массив. Если в будущем появятся новые данные, они сохранятся уже во второй массив для того, чтобы не перезаписать читающиеся другой стороной данные. Именно из-за такой логики данная структура данных и названа flip buffer — потому что данные перемещаются между массивами (наверное, здесь лучше подойдет какое-то другое слово, но я не знаю хорошего перевода для слова flip).

Вызов tty_insert_flip_string() в итоге перетекает в вызов функции под названием tty_insert_flip_string_fixed_flag(), в которой и происходит основная работа по передаче данных в PTM устройство: Не будем долго тянуть и снова идти по цепочке вызовов, так что сразу перейдем к нужной функции.

int tty_insert_flip_string_fixed_flag(struct tty_port *port, const unsigned char *chars, char flag, size_t size)
{ int copied = 0; do { int goal = min_t(size_t, size - copied, TTY_BUFFER_PAGE); // сколько данных нам требуется записать int space = __tty_buffer_request_room(port, goal, flags); // сколько места в буфере struct tty_buffer *tb = port->buf.tail; // получаем указатель на текущий активный массив if (unlikely(space == 0)) break; memcpy(char_buf_ptr(tb, tb->used), chars, space); // копируем данные в данный массив tb->used += space; copied += space; chars += space; /* There is a small chance that we need to split the data over several buffers. If this is the case we must loop */ } while (unlikely(size > copied)); return copied;
}

Не стоит сильно вникать в устройство данного буфера, ясно одно — в конце концов данные будут перемещены в PTM устройство, а после окончания записи ожидающая сторона будет уведомлена о готовых для чтения данных. На самом деле, flip buffer был замёнен новой имплементацией в новых версиях ядра, однако поведение буфера осталось почти таким же, а все функции остались совместимы с раннее реализованными драйверами.

В это время GNOME Terminal Server заблокирован на вызове poll() (техника мультиплексирования I/O) и ожидает новых данных на любом из master устройств. Итак, наша строка "Hello, World!" оказалась в PTM устройстве. Как бы не так. Вы думаете, сейчас он проснется и прочитает новые данные на устройстве? Когда я говорил об ожидающей стороне, я говорил о дисциплине линии, ведь это именно её задача — принимать ввод и производить его процессинг при необходимости.

Дисциплина линии будет уведомлена о новых данных с помощью вызова функции tty_flip_buffer_push() (в том же pty_write):

/** * tty_flip_buffer_push - terminal * @port: tty port to push * * Queue a push of the terminal flip buffers to the line discipline. * Can be called from IRQ/atomic context. * * In the event of the queue being busy for flipping the work will be * held off and retried later. */ void tty_flip_buffer_push(struct tty_port *port)
{ tty_schedule_flip(port);
}

Функция tty_schedule_flip(), в свою очередь, планирует задачу по доставке данных в дисциплину линии:

/** * tty_schedule_flip - push characters to ldisc * @port: tty port to push from * * Takes any pending buffers and transfers their ownership to the * ldisc side of the queue. It then schedules those characters for * processing by the line discipline. */ void tty_schedule_flip(struct tty_port *port)
{ struct tty_bufhead *buf = &port->buf; /* paired w/ acquire in flush_to_ldisc(); ensures * flush_to_ldisc() sees buffer data. */ smp_store_release(&buf->tail->commit, buf->tail->used); queue_work(system_unbound_wq, &buf->work);
}

Я не знаю, что здесь подразумевается под work (предположу, что это какая-то внутренняя структура ядра для планировки различных задач) и какой компонент ядра отвечает за планировку, но из комментариев ясно следующее — когда задача начнет выполняться, доставку организует функция flush_to_ldisc():

static void flush_to_ldisc(struct work_struct *work)
{ struct tty_port *port = container_of(work, struct tty_port, buf.work); // получаем структуру tty_port PTM устройства. tty_port - это просто способ доступа к буферу TTY устройства struct tty_bufhead *buf = &port->buf; struct tty_buffer *head = buf->head; // ... receive_buf(port, head); // ...
}

Функция receive_buf() через цепочку вызовов в итоге перетекает в вызов функции __receive_buf(), которая диспатчит работу по процессингу ввода:

static void __receive_buf(struct tty_struct *tty, const unsigned char *cp, char *fp, int count)
{ struct n_tty_data *ldata = tty->disc_data; bool preops = I_ISTRIP(tty) || (I_IUCLC(tty) && L_IEXTEN(tty)); if (ldata->real_raw) n_tty_receive_buf_real_raw(tty, cp, fp, count); else if (ldata->raw || (L_EXTPROC(tty) && !preops)) n_tty_receive_buf_raw(tty, cp, fp, count); else if (tty->closing && !L_EXTPROC(tty)) n_tty_receive_buf_closing(tty, cp, fp, count); else { if (ldata->lnext) { char flag = TTY_NORMAL; if (fp) flag = *fp++; n_tty_receive_char_lnext(tty, *cp++, flag); count--; } if (!preops && !I_PARMRK(tty)) n_tty_receive_buf_fast(tty, cp, fp, count); else n_tty_receive_buf_standard(tty, cp, fp, count); } if (read_cnt(ldata)) { kill_fasync(&tty->fasync, SIGIO, POLL_IN); wake_up_interruptible_poll(&tty->read_wait, EPOLLIN); }
}

Так как дисциплина линии для PTM устройства установлена в raw режим, то процессинг не будет произведен и данные сразу запишутся в read_buf. Все функции, начинающиеся с n_tty_receive_buf (кроме тех, где есть суффикс _raw) производят процессинг данных и записывают данные в буфер под названием read_buf, который и является основным буфером для чтения с TTY устройства. Однако, если бы мы разбирали доставку данных от PTM к PTS устройству, то процессинг был бы произведен.

Опишу полную цепочку вызовов до конечного вызова, включая пропущенные вызовы:

... pty_write() -> // 3-й уровень: драйвер PTS устройства tty_insert_flip_string + tty_flip_buffer_push() -> tty_schedule_flip() -> --- // переходим в пространство PTM устройства flush_to_ldisc() -> // 2-й уровень: дисциплина линии PTM устройства receive_buf() -> n_tty_receive_buf -> n_tty_receive_buf_common -> __receive_buf()

Заметьте, никакая функция PTM драйвера при перемещении данных не была использована — вся работа произошла в дисциплине линии и драйвере PTS устройства.

Теперь GNOME Terminal Server просыпается и читает нашу строку "Hello, World!", вызывая read() на PTM устройстве. Можете выдохнуть: данные наконец записаны в конечный буфер PTM устройства. В этой функции нет ничего интересного, кроме того, что она просто переместит данные из буфера в ядре — read_buf — в пользовательский буфер. Вызов read() по аналогии с write() перехватывается дисциплиной линии — методом n_tty_read(). Далее GNOME Terminal Server передает строку в X Server, который отобразит её на дисплей.

Таким образом, наша строчка "Hello, World!" проходит следующий путь:

Программа -> PTY slave -> PTM master -> GNOME-TERMINAl-SERVER -> X Server -> -> Дисплей

Заключение

В данной статье мы узнали: Подведем итог.

  1. Как работают эмуляторы терминалов
  2. Что такое виртуальные устройства
  3. Что такое TTY устройства
  4. Как устроены псевдотерминалы
  5. Какой путь проходят данные, начиная с обычной Си программы и до дисплея

Если у вас возникли какие-нибудь вопросы — смело задавайте их в комментариях, буду рад ответить! На этом все, спасибо за внимание!

Источники

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть