Хабрахабр

[Из песочницы] Неканонический режим терминала и неблокирующий ввод на nasm

Идея написания игры на языке ассемблера, конечно, вряд ли придёт кому-то в голову сама собой, однако именно такая изощренная форма отчетности уже долгое время практикуется на первом курсе ВМК МГУ. Но так как прогресс не стоит на месте, то и DOS, и masm становятся историей, а nasm и Linux выходят на первый план подготовки бакалавров. Возможно, лет через десять руководство факультета откроет для себя python, но речь сейчас не об этом.

Вместо них приходится использовать системные вызовы и контактировать с api терминала. Программирование на ассемблере под Linux, при всех своих плюсах, делает невозможным использование прерываний BIOS'a и как следствие обделяет функциональностью. Дело в том, что система ввода-вывода контролируется терминалом, а системными функциями Си напрямую пользоваться нельзя. Поэтому написать симулятор блек-джека или морского боя не вызывает больших трудностей, а с самой обычной змейкой возникают проблемы. Об этом и пойдёт речь в статье.
Поэтому при написании даже довольно простых игр рождаются два камня преткновения: как переключить терминал в неканонический режим и как сделать ввод с клавиатуры неблокирующим.

1. Неканонический режим терминала

Как известно, чтобы понять, что делает функция на Си, нужно думать, как функция на Си. Благо, перевести терминал в неканонический режим не так сложно. Вот что дает нам пример из официальной документации по GNU, если убрать из него вспомогательный код:

struct termios saved_attributes; void reset_input_mode (void)
{ tcsetattr (STDIN_FILENO, TCSANOW, &saved_attributes);
} void set_input_mode (void)
{ struct termios tattr; /* Save the terminal attributes so we can restore them later. */ tcgetattr (STDIN_FILENO, &saved_attributes); /* Set the funny terminal modes. */ tcgetattr (STDIN_FILENO, &tattr); tattr.c_lflag &= ~(ICANON|ECHO); /* Clear ICANON and ECHO. */ tcsetattr (STDIN_FILENO, TCSAFLUSH, &tattr);
}

В данном коде STDIN_FILENO означает дескриптор потока ввода, с которым мы работаем (по умолчанию он равен 0), ICANON — флаг включения того самого канонического ввода, ECHO — флаг отображения вводимых символов на экране, а TCSANOW и TCSAFLUSH — определенные библиотекой макросы. Таким образом, «голый» алгоритм, лишенный проверок ради безопасности, выглядит так:

  1. сохранить исходную структуру termios;
  2. скопировать ее содержимое с изменением флагов ICANON и ECHO;
  3. измененную структуру отправить терминалу;
  4. по окончании работы вернуть терминалу сохраненную структуру.

Остается понять, что делают библиотечные функции tcsetattr и tcgetattr. На самом деле они делают много всего, но ключевым в их работе является системный вызов ioctl. Первым аргументом он принимает дискриптор потока (0 в нашем случае), вторым — набор флагов, которые как раз определяются макросами TCSANOW и TCSAFLUSH, а третьим — указатель на структуру (в нашем случае termios). На синтаксисе nasm и под конвенцией системных вызовов на linux он примет следующий вид:

mov rax, 16 ;номер системного вызова ioctl mov rdi, 0 ;номер стандартного дескриптора ввода mov rsi, TCGETS ;набор флагов mov rdx, tattr ;адресс области памяти с структурой syscall

В общем, это вся суть функций tcsetattr и tcgetattr. Для остального кода нам нужно знать размер и устройство структуры termios, которую также несложно найти в официальной документации. Ее рамер по умолчанию равен 60 байт, причем массив нужных нам флагов имеет размер 4 байта и располагается четвертым по счету. Остается написать две процедуры и объединить в один код.

Значения макросов были взяты из вышеупомянутых исходников стандартной библитеки Си. Под спойлером самая простая его реализация, далеко не самая защищенная, но вполне работающая на любой ОС с поддержкой стандартов POSIX.

Перевод в неканонический режим

%define ICANON 2 %define ECHO 8
%define TCGETS 21505 ;аттрибут для получения структуры
%define TCPUTS 21506 ;аттрибут для отправления структуры global setcan ;процедура переключения в канонический режим
global setnoncan ;процедура переключения в неканонический режим section .bss
stty resb 12 ;размер termios - 60 байт
slflag resb 4 ;slflag располагается четверым после 3*4 байт памяти
srest resb 44 tty resb 12
lflag resb 4
brest resb 44 section .text
setnoncan: push stty call tcgetattr push tty call tcgetattr and dword[lflag], (~ICANON) and dword[lflag], (~ECHO) call tcsetattr add rsp, 16 ret setcan: push stty call tcsetattr add rsp, 8 ret tcgetattr: mov rdx, qword[rsp+8] push rax push rbx push rcx push rdi push rsi mov rax, 16 ;ioctl system call mov rdi, 0 mov rsi, TCGETS syscall pop rsi pop rdi pop rcx pop rbx pop rax ret tcsetattr: mov rdx, qword[rsp+8] push rax push rbx push rcx push rdi push rsi mov rax, 16 ;ioctl system call mov rdi, 0 mov rsi, TCPUTS syscall pop rsi pop rdi pop rcx pop rbx pop rax ret

2. Неблокирующий ввод в терминале

Для неблокирующего ввода средств терминала нам не хватит. Мы напишем функцию, которая будет проверять буффер стандартного потока на готовность передать информацию: если в буффере есть символ, то она вернет его код; если буффер пустой, то она вернет 0. Для этой цели можно использовать два системных вызова — poll() или select(). Они оба способны просматривать различные потоки ввода-вывода на факт какого-либо события. Например, если в какой-то из потоков поступила информация, то оба этих системных вызова способны это зафискировать и отобразить в возвращаемых данных. Однако второй из них по сути является улучшенной версией первого и полезен при работе с несколькими потоками. У нас такой цели не стоит (мы работаем только со стандарным потоком), поэтому воспользуемся вызовом poll().

Он также принимает на вход три параметра:

  1. указатель на структуру данных, где содержится информация о дескрипторах отслеживаемых потоков (ее обсудим ниже);
  2. количество обрабатываемых потоков (у нас он один);
  3. время в милисекундах, в течение которого можно ожидать событие (нам нужно, чтобы оно наступило сразу, поэтому этот параметр равен 0).

Из документации можно узнать, что нужная структура данных имеет следующее устройство:

struct pollfd { int fd; /* описатель файла */ short events; /* запрошенные события */ short revents; /* возвращенные события */
};

В качестве описателя файла используется его дескриптор (мы работаем со стандартным потоком, поэтому он равен 0), а в качестве запрошенных событий — различные флаги, из которых нам нужен только флаг наличия данных в буфере. Он имеет имя POLLIN и равен 1. Поле возвращаемых событий игнорируем, ибо никакую информацию потоку ввода мы не отдаем. Тогда нужный системный вызов будет выглядеть так:

section .data
fd dd 0 ;дескриптор стандартного потока ввода
eve dw 1 ;только один аттрибут - POLLIN
rev dw 0 ;не используется section .text
poll: nop push rbx push rcx push rdx push rdi push rsi mov rax, 7 ;номер системного вызова poll mov rdi, fd ;указатель на структуру mov rsi, 1 ;отслеживаем один поток mov rdx, 0 ;не даем время на ожидание syscall

Системный вызов poll() возвращает количество потоков, в которых произошли «интересные» события. Так как у нас всего один поток, то возвращаемое значение равно либо 1 (есть введенные данные), либо 0 (таковых нет). Если все же буфер непустой, то сразу делаем еще один системный вызов — read — и считываем код введенного символа. В итоге, мы получим следующий код.

Неблокирующий ввод в терминале

section .data
fd dd 0 ;дескриптор стандартного потока ввода
eve dw 1 ;только один аттрибут - POLLIN
rev dw 0 ;не используется
sym db 1 section .text
poll: nop push rbx push rcx push rdx push rdi push rsi mov rax, 7 ;номер системного вызова poll mov rdi, fd ;указатель на структуру mov rsi, 1 ;отслеживаем один поток mov rdx, 0 ;не даем время на ожидание syscall test rax, rax ;проверка возвращенного значения на 0 jz .e mov rax, 0 mov rdi, 0 ;если данные есть mov rsi, sym ;то сделать вызов read mov rdx, 1 syscall xor rax, rax mov al, byte[sym] ;вернуть код символа, если он был считан
.e: pop rsi pop rdi pop rdx pop rcx pop rbx ret

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

Они запредельно просты как для понимания, так и для использования. Описанных выше трех функций (setcan, setnoncan и poll) вполне достаточно, чтобы подстроить терминальный ввод под себя и свои нужны. Однако в реальной игре было бы неплохо обезопасить их в соответствии с обычным подходом на Си, но это уже дело программиста.

Источники

1) Исходники функций tcgetattr и tcsetattr;
2) Документация по системному вызову ioctl;
3) Документация по системному вызову poll;
4) Документация по termios;
5) Таблица системных вызовов под Linux x64.

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

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

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

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

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