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

Комментарии (12)


  1. Jef239
    17.06.2018 00:50

    Там много ещё нужно. Например 8битный ввод включить, эхо отключить… Насколько я помню, макросы не на всех позикс-совместимых системах делают все нужное.


    1. Lirol Автор
      17.06.2018 01:00

      Флаг эхо я в примере обнулил, просто не акцентировал внимание
      И я не особо понимаю о каких Вы макросах говорите


      1. Jef239
        17.06.2018 01:52

        если не путаю, то ICANON — это макрос. Если нужно, то завтра глянул.


        1. netch80
          17.06.2018 11:41

          ICANON как раз просто однобитовый флаг.

          Макросы (или функции) это cfsetspeed, cfmakeraw, а вот аналогичного макроса для канонического режима я не видел.

          В ncurses есть noraw() и nocbreak(), возвращающие терминал в cooked mode.


          1. Jef239
            17.06.2018 18:14

            Про ICANON — возможно, вы правы. Мне помнится, что на одном из *nix это был макрос — несколько битов по «или». Но на каком (FreeBSD, QNX, Unix) не помню. На linux — точно бит. Впрочем, с Unix я работал 30 лет назад, может и перепутал.

            В *nix (линуксе, FreeBSD, QNX как минимум) есть cfmakeraw.

            описание изменяемых битов
            cfmakeraw меняет атрибуты терминала таки образом:

                        termios_p->c_iflag &= ~(IGNBRK|BRKINT|PARMRK|ISTRIP
                                        |INLCR|IGNCR|ICRNL|IXON);
                        termios_p->c_oflag &= ~OPOST;
                        termios_p->c_lflag &= ~(ECHO|ECHONL|ICANON|ISIG|IEXTEN);
                        termios_p->c_cflag &= ~(CSIZE|PARENB);
                        termios_p->c_cflag |= CS8;
            


  1. checkpoint
    17.06.2018 04:29

    Даешь консольный Xonix в массы!!!


    1. Barafu_Albino_Cheetah
      17.06.2018 05:05

      На Ассемблере?


      1. aamonster
        17.06.2018 10:46

        Ну, во времена CP/M-80 я его именно на ассемблере писал, и горько плакал, когда после добавления заставки и фоновой музыки он стал больше килобайта (емнип).


      1. checkpoint
        17.06.2018 12:55

        Язык ассемблер не так страшен как его малюют. По моему, не плохая тема курсовика для студента МВК МГУ.


  1. netch80
    17.06.2018 11:37
    +2

    Мелкие замечания по отдельным моментам:

    > Однако второй из них по сути является улучшенной версией первого и полезен при работе с несколькими потоками.

    Вообще, как раз poll — это улучшенная версия select. Select негарантированно масштабируется и поддерживает только три типа наблюдения. Poll был введён для устранения явных недостатков select.

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

    BSD системы тоже поддерживают тут POSIX полностью, но детали реализации у них совсем другие (номера сисколлов / параметров ioctl, смещения в структуре termios и т.п.) Ваш код будет работать только для Linux/x86_64.

    > Так как у нас всего один поток, то возвращаемое значение равно либо 1 (есть введенные данные), либо 0 (таковых нет).

    Select и poll ещё могут быть прерваны сигналом, надо проверять rax на -EINTR. Ну и другие ошибки могут быть, но совсем уж в необычных случаях.


    1. eirnym
      17.06.2018 22:41

      Я бы добавил еще поддержку termcap/terminfo, чтобы знать наверняка, что поддерживает терминальная линия


  1. Sander80
    17.06.2018 21:50

    В качестве отчетности по практикуму по программированию на первом курсе ВМК, конечно, используется написание некоторой программы с применением ассемблера, но об играх там речь не идёт. Это было бы перебор.