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

habr.png

Идея написания игры на языке ассемблера, конечно, вряд ли придёт кому-то в голову сама собой, однако именно такая изощренная форма отчетности уже долгое время практикуется на первом курсе ВМК МГУ. Но так как прогресс не стоит на месте, то и 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 — определенные библиотекой макросы. Таким образом, «голый» алгоритм, лишенный проверок ради безопасности, выглядит так:

  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.

© Habrahabr.ru