The idea of writing a game in assembly language, of course, is unlikely to come to anyone's head by itself, but it is precisely this sophisticated form of reporting that has been practiced in the first year at VMK MGU for a long time. But since progress does not stand still, then DOS and masm become history, and nasm and Linux come to the forefront of bachelor education. It is possible that in ten years the leadership of the faculty will discover python, but this is not the case now.
Programming in assembler under Linux, with all its advantages, makes it impossible to use BIOS interrupts and, as a result, deprives of functionality. Instead, you have to use system calls and contact the terminal api. Therefore, to write a simulator of blackjack or sea battle does not cause great difficulties, and problems arise with the most ordinary snake. The fact is that the I / O system is controlled by the terminal, and the C system functions cannot be directly used. Therefore, when writing even fairly simple games, two stumbling blocks are born: how to switch the terminal to non-canonical mode and how to make keyboard input non-blocking. This will be discussed in the article.
1. Non-canon terminal mode
As you know, to understand what a function does in C, you need to think like a function in C. Fortunately, transferring the terminal to non-canonical mode is not so difficult. This is what gives us
an example from the official GNU documentation , if we remove the auxiliary code from it:
struct termios saved_attributes; void reset_input_mode (void) { tcsetattr (STDIN_FILENO, TCSANOW, &saved_attributes); } void set_input_mode (void) { struct termios tattr; tcgetattr (STDIN_FILENO, &saved_attributes); tcgetattr (STDIN_FILENO, &tattr); tattr.c_lflag &= ~(ICANON|ECHO); tcsetattr (STDIN_FILENO, TCSAFLUSH, &tattr); }
In this code, STDIN_FILENO means a handle to the input stream we are working with (it is 0 by default), ICANON is the enable flag for that canonical input, ECHO is the flag for displaying input characters on the screen, and TCSANOW and TCSAFLUSH are macros defined by the library. Thus, the “bare” algorithm, devoid of checks for the sake of security, looks like this:
- keep the original termios structure;
- copy its contents with changing flags ICANON and ECHO;
- send the changed structure to the terminal;
- at the end of the work, return the saved structure to the terminal.
It remains to understand what the library functions tcsetattr and tcgetattr do.
In fact, they do a lot of things, but the key to their work is the
ioctl system call. The first argument takes the stream descriptor (0 in our case), the second the set of flags that are defined by the TCSANOW and TCSAFLUSH macros, and the third the pointer to the structure (in our case termios). On the nasm syntax and under the convention of system calls to linux, it will look like this:
mov rax, 16 ; ioctl mov rdi, 0 ; mov rsi, TCGETS ; mov rdx, tattr ; syscall
In general, this is the whole point of the tcsetattr and tcgetattr functions. For the rest of the code, we need to know the size and structure of the termios structure, which is also easy to find in the
official documentation . Its default size is 60 bytes, and the array of flags we need is 4 bytes in size and is the fourth in a row. It remains to write two procedures and combine them into one code.
Under the spoiler, its simplest implementation, far from being the most secure, but working on any OS with POSIX standards support. The values of the macros were taken from the above sources of the standard library C.
Switch to non-canonical mode %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. Non-blocking terminal input
For non-blocking input of funds, the terminal is not enough for us. We will write a function that will check the standard stream buffer for readiness to transfer information: if there is a character in the buffer, then it will return its code; if the buffer is empty, it returns 0. For this purpose, you can use two system calls - poll () or select (). Both of them are capable of viewing various I / O streams for the fact of an event. For example, if some of the streams received information, then both of these system calls can capture and display this in the returned data. However, the second of these is essentially an improved version of the first and is useful when working with multiple threads. We do not have such a goal (we work only with the standard stream), so we will use the poll () call.
It also takes three parameters as input:
- a pointer to the data structure, which contains information about the descriptors of the monitored streams (discussed below);
- the number of threads processed (we have one);
- time in milliseconds, during which we can expect an event (we need it to come immediately, so this parameter is 0).
From the
documentation you can find out that the desired data structure has the following device:
struct pollfd { int fd; short events; short revents; };
The file descriptor is used as the file descriptor (we work with the standard stream, so it is 0), and the requested events are various flags, of which we only need the buffer for data availability in the buffer. It has the name POLLIN and is 1. We ignore the field of returned events, because we do not give any information to the input stream. Then the desired system call will look like this:
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
The poll () system call returns the number of threads in which "interesting" events occurred. Since we have only one stream, the return value is either 1 (there is entered data) or 0 (there is no such data). If the buffer is non-empty, then we immediately make another system call — read — and read the code of the character entered. As a result, we get the following code.
Non-blocking terminal input 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
Thus, you can now use the poll function to read information. If there is no data entered, that is, no button was pressed, it will return 0 and thus will not block our process. Of course, if this implementation has flaws, in particular, it can only work with ascii characters, but it easily changes depending on the task.
The three functions described above (setcan, setnoncan and poll) are enough to fine-tune the terminal input for themselves and theirs. They are prohibitively simple both for understanding and for use. However, in a real game, it would be nice to protect them in accordance with the usual approach in C, but this is already a programmer’s case.
Sources
1)
Sources of functions tcgetattr and tcsetattr ;
2)
ioctl system call documentation ;
3)
Documentation on the poll system call ;
4)
termios documentation ;
5)
Table of system calls under Linux x64 .