Идея написания игры на языке ассемблера, конечно, вряд ли придёт кому-то в голову сама собой, однако именно такая изощренная форма отчетности уже долгое время практикуется на первом курсе ВМК МГУ. Но так как прогресс не стоит на месте, то и DOS, и masm становятся историей, а nasm и Linux выходят на первый план подготовки бакалавров. Возможно, лет через десять руководство факультета откроет для себя python, но речь сейчас не об этом.
Программирование на ассемблере под Linux, при всех своих плюсах, делает невозможным использование прерываний BIOS'a и как следствие обделяет функциональностью. Вместо них приходится использовать системные вызовы и контактировать с api терминала. Поэтому написать симулятор блек-джека или морского боя не вызывает больших трудностей, а с самой обычной змейкой возникают проблемы. Дело в том, что система ввода-вывода контролируется терминалом, а системными функциями Си напрямую пользоваться нельзя. Поэтому при написании даже довольно простых игр рождаются два камня преткновения: как переключить терминал в неканонический режим и как сделать ввод с клавиатуры неблокирующим. Об этом и пойдёт речь в статье.
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 — определенные библиотекой макросы. Таким образом, «голый» алгоритм, лишенный проверок ради безопасности, выглядит так:
- сохранить исходную структуру termios;
- скопировать ее содержимое с изменением флагов ICANON и ECHO;
- измененную структуру отправить терминалу;
- по окончании работы вернуть терминалу сохраненную структуру.
Остается понять, что делают библиотечные функции 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().
Он также принимает на вход три параметра:
- указатель на структуру данных, где содержится информация о дескрипторах отслеживаемых потоков (ее обсудим ниже);
- количество обрабатываемых потоков (у нас он один);
- время в милисекундах, в течение которого можно ожидать событие (нам нужно, чтобы оно наступило сразу, поэтому этот параметр равен 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.
Автор: Lirol