Simple x86 assembler program: Sieve of Eratosthenes
- Tutorial
introduction
In my profession, I do not encounter low-level programming: I am engaged in programming in scripting languages. But since the soul requires diversity, broadening the horizons of knowledge or just understanding how the machine works at a low level, I do programming in languages other than those with which I make money - this is my hobby.
And now, I would like to share the experience of creating a simple assembly language program for x86 family processors, with the analysis of which you can begin your journey into conquering the lowlands of abstraction levels.
Before writing it, I formulated such requirements for a future program:
- My program should not be a DOS program. Too many examples are oriented to it in connection with a simple API. My program must have run on modern OSs.
- The program should use a bunch - get at its disposal dynamically allocated memory.
- In order not to be too complicated, the program should work with unsigned integers without the use of hyphens.
For my program, I chose to search for primes using the Sieve of Eratosthenes . I chose nasm as the assembler .
I wrote the code with more emphasis on style and comprehensibility than on the speed of its execution. For example, I didn’t reset the register with help
xor eax, eax, but with help mov eax, 0in connection with a more suitable instruction semantics. I decided that since the program pursues exclusively educational goals, you can unbelieve and engage in the pursuit of code style in assembler. So, let's see what happened.
Where to begin?
Perhaps the most difficult thing that you encounter in the transition from high-level languages to assembler is the organization of memory. Fortunately, there was already a good article on this topic on Habré .
The question also arises of how the data exchange between the internal world of the program and the external environment is realized at such a low level. Here the API of the operating system enters the scene. In DOS, as already mentioned, the interface was quite simple. For example, the program “Hello, world” looked like this:
SECTION .text
org 0x100
mov ah, 0x9
mov dx, hello
int 0x21
mov ax, 0x4c00
int 0x21
SECTION .data
hello: db "Hello, world!", 0xD, 0xA, '$'
In Windows, the Win32 API is used for these purposes, respectively, the program should use the methods of the corresponding libraries:
%include "win32n.inc"
extern MessageBoxA
import MessageBoxA user32.dll
extern ExitProcess
import ExitProcess kernel32.dll
SECTION code use32 class=code
..start:
push UINT MB_OK
push LPCTSTR window_title
push LPCTSTR banner
push HWND NULL
call [MessageBoxA]
push UINT NULL
call [ExitProcess]
SECTION data use32 class=data
banner: db "Hello, world!", 0xD, 0xA, 0
window_title: db "Hello", 0
Here we use the win32n.inc file , where macros are defined that reduce the code for working with the Win32 API.
I decided not to use the OS API directly and chose the way to use functions from the C library. It also opened up the possibility of compiling the program in Linux (and, most likely, in other OSs) - an achievement that is not too big and necessary for this program, but a pleasant achievement.
Subroutine call
The need to call subroutines entails several topics for study: organizing subroutines, passing arguments, creating a stack frame, working with local variables.
Subprograms are the label by which the code is located. The subroutine ends with an instruction
ret. For example, here is a subroutine in DOS that displays the string “Hello, world” in the console:print_hello:
mov ah, 0x9
mov dx, hello
int 0x21
ret
To call it, you would need to use the instruction
call:call print_hello
For myself, I decided to pass arguments to subprograms through registers and indicate in the comments in which registers which arguments should be, but in high-level languages, arguments are passed through the stack. For example, this is how the function
printf from the C library is called :push hello
call _printf
add esp, 4
Arguments are passed from right to left, the responsibility to clear the stack lies with the caller.
When entering the subroutine, you need to create a new stack frame. This is done as follows:
print_hello:
push ebp ;сохраняем указатель начала стекового кадра на стеке
mov ebp, esp ;теперь началом кадра является вершина предыдущего
Accordingly, before exiting, you need to restore the previous state of the stack:
mov esp, ebp
pop ebp
ret
For local variables , a stack is also used on which, after creating a new frame, the required number of bytes is allocated:
print_hello:
push ebp
mov ebp, esp
sub esp, 8 ;опускаем указатель вершины стека на 8 байт, чтобы выделить память
The x86 architecture also provides special instructions with the help of which these actions can be more succinctly implemented:
print_hello:
enter 8, 0 ;создать новый кадр, выделить 8 байт для локальных переменных
leave ;восстановить стек
ret
The second parameter of the instruction
enteris the nesting level of the subroutine. It is needed for linking with high-level languages that support such a technique for organizing routines. In our case, this value can be left zero.The program itself
The project contains the following files:
main.asm- main file,functions.asm- routinesstring_constants.asm- definitions of string constants,Makefile- build script
Consider the code for the main file:
%define SUCCESS 0
%define MIN_MAX_NUMBER 3
%define MAX_MAX_NUMBER 4294967294
global _main
extern _printf
extern _scanf
extern _malloc
extern _free
SECTION .text
_main:
enter 0, 0
;ввод максимального числа
call input_max_number
cmp edx, SUCCESS
jne .custom_exit
mov [max_number], eax
;выделяем память для массива флагов
mov eax, [max_number]
call allocate_flags_memory
cmp edx, SUCCESS
jne .custom_exit
mov [primes_pointer], eax
;отсеять составные числа
mov eax, [primes_pointer]
mov ebx, [max_number]
call find_primes_with_eratosthenes_sieve
;вывести числа
mov eax, [primes_pointer]
mov ebx, [max_number]
call print_primes
;освободить память от массива флагов
mov eax, [primes_pointer]
call free_flags_memory
;выход
.success:
push str_exit_success
call _printf
jmp .return
.custom_exit:
push edx
call _printf
.return:
mov eax, SUCCESS
leave
ret
%include "functions.asm"
SECTION .data
max_number: dd 0
primes_pointer: dd 0
%include "string_constants.asm"
It can be seen that the program is divided in meaning into 5 blocks, designed as subprograms:
input_max_number- using the console it asks the user for the maximum number to which simple ones are searched; to avoid errors, the value is limited by constantsMIN_MAX_NUMBERandMAX_MAX_NUMBERallocate_flags_memory- request from the OS the allocation of memory for the array of tagging numbers (simple / compound) on the heap; if successful, returns a pointer to the allocated memory through the registereaxfind_primes_with_eratosthenes_sieve- weed out composite numbers using the classic sieve of Eratosthenes;print_primes- display in the console a list of primes;free_flags_memory- free up memory allocated for flags
For functions, this rule was agreed: the value is returned through the register
eax, the register edxcontains the status. In case of success, it contains a value SUCCESS, that is, 0in case of failure, the address of the line with the error message that will be displayed to the user. The file
string_constants.asmcontains a definition of string variables whose values, as the name of the file hints, are not intended to be changed. It was only for the sake of these variables that an exception was made to the “do not use global variables” rule. I never found a more convenient way to deliver string constants to I / O functions - I even thought about writing to the stack immediately before function calls, but decided that this idea is much worse than the idea with global variables.;подписи ввода-вывода, форматы
str_max_number_label: db "Max number (>=3): ", 0
str_max_number_input_format: db "%u", 0
str_max_number_output_format: db "Using max number %u", 0xD, 0xA, 0
str_print_primes_label: db "Primes:", 0xD, 0xA, 0
str_prime: db "%u", 0x9, 0
str_cr_lf: db 0xD, 0xA, 0
;сообщения выхода
str_exit_success: db "Success!", 0xD, 0xA, 0
str_error_max_num_too_little: db "Max number is too little!", 0xD, 0xA, 0
str_error_max_num_too_big: db "Max number is too big!", 0xD, 0xA, 0
str_error_malloc_failed: db "Can't allocate memory!", 0xD, 0xA, 0
The following scenario is used for assembly:
ifdef SystemRoot
format = win32
rm = del
ext = .exe
else
format = elf
rm = rm -f
ext =
endif
all: primes.o
gcc primes.o -o primes$(ext)
$(rm) primes.o
primes.o:
nasm -f $(format) main.asm -o primes.o
Subprograms (functions)
input_max_number
; Ввести максимальное число
; Результат: EAX - максимальное число
input_max_number:
;создать стек-фрейм,
;4 байта для локальных переменных
enter 4, 1
;показываем подпись
push str_max_number_label ;см. string_constants.asm
call _printf
add esp, 4
;вызываем scanf
mov eax, ebp
sub eax, 4
push eax
push str_max_number_input_format ;см. string_constants.asm
call _scanf
add esp, 8
mov eax, [ebp-4]
;проверка
cmp eax, MIN_MAX_NUMBER
jb .number_too_little
cmp eax, MAX_MAX_NUMBER
ja .number_too_big
jmp .success
;выход
.number_too_little:
mov edx, str_error_max_num_too_little ;см. string_constants.asm
jmp .return
.number_too_big:
mov edx, str_error_max_num_too_big ;см. string_constants.asm
jmp .return
.success:
push eax
push str_max_number_output_format ;см. string_constants.asm
call _printf
add esp, 4
pop eax
mov edx, SUCCESS
.return:
leave
ret
The subprogram is intended to introduce into the program the maximum number up to which simple ones will be searched. The key point here is the function call
scanffrom the C library: mov eax, ebp
sub eax, 4
push eax
push str_max_number_input_format ;см. string_constants.asm
call _scanf
add esp, 8
mov eax, [ebp-4]
Thus,
eaxthe memory address is written 4 bytes below the stack base pointer first. This is the memory allocated for the local needs of the subprogram. A pointer to this memory is passed to the function scanfas the target for writing data entered from the keyboard. After calling the function, the
eaxentered value is moved to the memory.allocate_flags_memory and free_flags_memory
; Выделить память для массива флагов
; Аргумент: EAX - максимальное число
; Результат: EAX - указатель на память
allocate_flags_memory:
enter 8, 1
;выделить EAX+1 байт
inc eax
mov [ebp-4], eax
push eax
call _malloc
add esp, 4
;проверка
cmp eax, 0
je .fail
mov [ebp-8], eax
;инициализация
mov byte [eax], 0
cld
mov edi, eax
inc edi
mov edx, [ebp-4]
add edx, eax
mov al, 1
.write_true:
stosb
cmp edi, edx
jb .write_true
;выход
mov eax, [ebp-8]
jmp .success
.fail:
mov edx, str_error_malloc_failed ;см. string_constants.asm
jmp .return
.success:
mov edx, SUCCESS
.return:
leave
ret
; Освободить память от массива флагов
; Аргумент: EAX - указатель на память
free_flags_memory:
enter 0, 1
push eax
call _free
add esp, 4
leave
ret
Key locations of these routines are the functions of the challenges
mallocand freeof the C library. mallocin case of success, returns the eaxaddress of the allocated memory through the register , in case of failure, this register contains 0. This is the bottleneck of the program regarding the maximum number. 32 bits is enough to search for primes up to 4,294,967,295, but you won’t be able to allocate as much memory at once.find_primes_with_eratosthenes_sieve
;Найти простые числа с помощью решета Эратосфена
;Аргументы: EAX - указатель на массив флагов, EBX - максимальное число
find_primes_with_eratosthenes_sieve:
enter 8, 1
mov [ebp-4], eax
add eax, ebx
inc eax
mov [ebp-8], eax
;вычеркиваем составные числа
cld
mov edx, 2 ;p = 2
mov ecx, 2 ;множитель с = 2
.strike_out_cycle:
;x = c*p
mov eax, edx
push edx
mul ecx
pop edx
cmp eax, ebx
jbe .strike_out_number
jmp .increase_p
.strike_out_number:
mov edi, [ebp-4]
add edi, eax
mov byte [edi], 0
inc ecx ;c = c + 1
jmp .strike_out_cycle
.increase_p:
mov esi, [ebp-4]
add esi, edx
inc esi
mov ecx, edx
inc ecx
.check_current_number:
mov eax, ecx
mul eax
cmp eax, ebx
ja .return
lodsb
inc ecx
cmp al, 0
jne .new_p_found
jmp .check_current_number
.new_p_found:
mov edx, ecx
dec edx
mov ecx, 2
jmp .strike_out_cycle
.return:
leave
ret
The subroutine implements the classical algorithm for striking out composite numbers, the sieve of Eratosthenes, in x86 assembly language. It is pleasant because it does not use external function calls and does not require error handling :)
print_primes
; Вывести простые числа
; Параметры: EAX - указатель на массив флагов, EBX - максимальное число
print_primes:
enter 12, 1
mov [ebp-4], eax
mov [ebp-8], ebx
push str_print_primes_label
call _printf
add esp, 4
cld
mov esi, [ebp-4]
mov edx, esi
add edx, [ebp-8]
inc edx
mov [ebp-12], edx
mov ecx, 0
.print_cycle:
lodsb
cmp al, 0
jne .print
jmp .check_finish
.print:
push esi
push ecx
push str_prime ;см. string_constants.asm
call _printf
add esp, 4
pop ecx
pop esi
mov edx, [ebp-12]
.check_finish:
inc ecx
cmp esi, edx
jb .print_cycle
push str_cr_lf
call _printf
add esp, 4
leave
ret
The routine prints prime numbers to the console. The key point here is the function call
printffrom the C library.Conclusion
Well, the program meets all the stated requirements and seems easy to understand. I would like to hope that someone’s analysis of it will help to understand programming at a low level and he will enjoy it as much as I did.
I also bring the complete source code of the program .
I can also give an interesting fact. Since we were taught from childhood that assembler programs run faster, I decided to compare the speed of this program with the speed of a C ++ program that I wrote once and was looking for primes using the Atkin Reshet . A C ++ program compiled in Visual Studio c
/O2searched up to 2 30in about 25 seconds on my car. The program in assembler showed 15 seconds with the Sieve of Eratosthenes. This, of course, is more a trick than a scientific fact, since there was no serious testing, there was no clarification of the reasons, but as an interesting fact, it seems to me to complete the article.