Простая программа на ассемблере x86: Решето Эратосфена
Вступительное слово
По своей профессии я не сталкиваюсь с низкоуровневым программированием: занимаюсь программированием на скриптовых языках. Но поскольку душа требует разнообразия, расширения горизонтов знаний или просто понимания, как работает машина на низком уровне, я занимаюсь программированием на языках, отличающихся от тех, с помощью которых зарабатываю деньги – такое у меня хобби.
И вот, я хотел бы поделиться опытом создания простой программы на языке ассемблера для процессоров семейства x86, с разбора которой можно начать свой путь в покорение низин уровней абстракции.
До ее написания я сформулировал такие требования к будущей программе:
- Моя программа не должна быть программой под DOS. Слишком много примеров ориентировано на нее в связи с простым API. Моя программа обязательно должна была запускаться на современных ОС.
- Программа должна использовать кучу – получать в свое распоряжение динамически распределяемую память.
- Чтобы не быть слишком сложной, программа должна работать с целыми беззнаковыми числами без использования переносов.
Задачей для своей программы я выбрал поиск простых чисел с помощью Решета Эратосфена. В качестве ассемблера я выбрал nasm.
Код я писал с упором больше на стиль и понятность, чем на скорость его выполнения. К примеру, обнуление регистра я проводил не с помощью xor eax, eax , а с помощью mov eax, 0 в связи с более подходящей семантикой инструкции. Я решил, что поскольку программа преследует исключительно учебные цели, можно распоясаться и заниматься погоней за стилем кода в ассемблере.
Итак, посмотрим, что получилось.
С чего начать?
Пожалуй, самая сложная вещь, с которой сталкиваешься при переходе от высокоуровневых языков к ассемблеру, это организация памяти. К счастью, на эту тему на Хабре уже была хорошая статья.
Так же встает вопрос, каким образом на таком низком уровне реализуется обмен данными между внутренним миром программы и внешней средой. Тут на сцену выходит API операционной системы. В DOS, как уже было упомянуто, интерфейс был достаточно простой. К примеру, программа «Hello, world» выглядела так:
В Windows же для этих целей используется Win32 API, соответственно, программа должна использовать методы соответствующих библиотек:
Здесь используется файл win32n.inc, где определены макросы, сокращающие код для работы с Win32 API.
Я решил не использовать напрямую API ОС и выбрал путь использования функций из библиотеки Си. Так же это открыло возможность компиляции программы в Linux (и, скорее всего, в других ОС) – не слишком большое и нужное этой программе достижение, но приятное достижение.
Вызов подпрограмм
Потребность вызывать подпрограммы влечет за собой несколько тем для изучения: организация подпрограмм, передача аргументов, создание стекового кадра, работа с локальными переменными.
Подпрограммы представляют собой метку, по которой располагается код. Заканчивается подпрограмма инструкцией ret . К примеру, вот такая подпрограмма в DOS выводит в консоль строку «Hello, world»:
Для ее вызова нужно было бы использовать инструкцию call :
Для себя я решил передавать аргументы подпрограммам через регистры и указывать в комментариях, в каких регистрах какие аргументы должны быть, но в языках высокого уровня аргументы передаются через стек. К примеру, вот так вызывается функция printf из библиотеки Си:
Аргументы передаются справа налево, обязанность по очистке стека лежит на вызывающей стороне.
При входе в подпрограмму необходимо создать новый стековый кадр. Делается это следующим образом:
Соответственно, перед выходом нужно восстановить прежнее состояние стека:
Для локальных переменных так же используется стек, на котором после создания нового кадра выделяется нужное количество байт:
Так же архитектура x86 предоставляет специальные инструкции, с помощью которых можно более лаконично реализовать эти действия:
Второй параметр инструкции enter – уровень вложенности подпрограммы. Он нужен для линковки с языками высокого уровня, поддерживающими такую методику организации подпрограмм. В нашем случае это значение можно оставить нулевым.
Непосредственно программа
Проект содержит такие файлы:
- main.asm – главный файл,
- functions.asm – подпрограммы,
- string_constants.asm – определения строковых констант,
- Makefile – сценарий сборки
Рассмотрим код основного файла:
Видно, что программа поделена по смыслу на 5 блоков, оформленных в виде подпрограмм:
- input_max_number — с помощью консоли запрашивает у пользователя максимальное число, до которого производится поиск простых; во избежание ошибок значение ограничено константами MIN_MAX_NUMBER и MAX_MAX_NUMBER
- allocate_flags_memory — запросить у ОС выделение памяти для массива пометок чисел (простое/составное) в куче; в случае успеха возвращает указатель на выделенную память через регистр eax
- find_primes_with_eratosthenes_sieve — отсеять составные числа с помощью классического решета Эратосфена;
- print_primes — вывести в консоль список простых чисел;
- free_flags_memory — освободить память, выделенную для флагов
Для функций было условлено такое правило: значение возвращается через регистр eax , регистр edx содержит статус. В случае успеха он содержит значение SUCCESS , то есть, 0 , в случае неудачи — адрес строки с сообщением об ошибке, которое будет выведено пользователю.
Решить уравнение на ассемблере i
7.1. Сложение и вычитание.
7.1.1. ADD – команда для сложения двух чисел. Она работает как с числами со знаком, так и без знака.
Логика работы команды:
Возможные сочетания операндов для этой команды аналогичны команде MOV .
По сути дела, это – команда сложения с присвоением, аналогичная принятой в языке C / C ++:
Операнды должны иметь одинаковый размер. Результат помещается на место первого операнда.
После выполнения команды изменяются флаги, по которым можно определить характеристики результата:
- Флаг CF устанавливается, если при сложении произошёл перенос из старшего разряда. Для беззнаковых чисел это будет означать, что произошло переполнение и результат получился некорректным.
- Флаг OF обозначает переполнение для чисел со знаком.
- Флаг SF равен знаковому биту результата (естественно, для чисел со знаком, а для беззнаковых он равен старшему биту и особо смысла не имеет).
- Флаг ZF устанавливается, если результат равен 0.
- Флаг PF — признак чётности, равен 1, если результат содержит нечётное число единиц.
add ax ,5 ; AX = AX + 5
add dx,cx ;DX = DX + CX
add dx,cl ;Ошибка: разный размер операндов.
7.1.2. SUB — команда для вычитания одного числа из другого. Она работает как с числами со знаком, так и без знака.
Логика работы команды:
Возможные сочетания операндов для этой команды аналогичны команде MOV .
По сути дела, это – команда вычитания с присвоением, аналогичная принятой в языке C / C ++:
Операнды должны иметь одинаковый размер. Результат помещается на место первого операнда.
На самом деле вычитание в процессоре реализовано с помощью сложения. Процессор меняет знак второго операнда на противоположный, а затем складывает два числа.
sub ax ,13 ; AX = AX — 13
sub ax , bx ; AX = AX + BX
sub b x,cl ;Ошибка: разный размер операндов.
7.1.3. Инкремент и декремент. Очень часто в программах используется операция прибавления или вычитания единицы. Прибавление единицы называется инкрементом, а вычитание — декрементом. Для этих операций существуют специальные команды процессора: INC и DEC. Эти команды не изменяют значение флага CF.
Эти команды содержит один операнд и имеет следующий синтаксис:
Логика работы команд:
В качестве инкремента допустимы регистры и память: reg , mem .
inc ax ; AX = AX + 1
dec ax ; AX = AX — 1
7.1.4. NEG – команда для изменения знака операнда.
Логика работы команды:
В качестве декремента допустимы регистры и память: reg , mem .
7.2. Сложение и вычитание с переносом.
В системе команд процессоров x86 имеются специальные команды сложения и вычитания с учётом флага переноса (CF). Для сложения с учётом переноса предназначена команда ADC, а для вычитания — SBB. В общем, эти команды работают почти так же, как ADD и SUB, единственное отличие в том, что к младшему разряду первого операнда прибавляется или вычитается дополнительно значение флага CF.
Они позволяют выполнять сложение и вычитание многобайтных целых чисел, длина которых больше, чем разрядность регистров процессора (в нашем случае 16 бит). Принцип программирования таких операций очень прост — длинные числа складываются (вычитаются) по частям. Младшие разряды складываются(вычитаются) с помощью обычных команд ADD и SUB, а затем последовательно складываются(вычитаются) более старшие части с помощью команд ADC и SBB. Так как эти команды учитывают перенос из старшего разряда, то мы можем быть уверены, что ни один бит не потеряется. Этот способ похож на сложение(вычитание) десятичных чисел в столбик.
На следующем рисунке показано сложение двух двоичных чисел командой ADD:
При сложении происходит перенос из 7-го разряда в 8-й, как раз на границе между байтами. Если мы будем складывать эти числа по частям командой ADD, то перенесённый бит потеряется и в результате мы получим ошибку. К счастью, перенос из старшего разряда всегда сохраняется в флаге CF. Чтобы прибавить этот перенесённый бит, достаточно применить команду ADC:
//Сложение двух чисел с учетом переноса: FFFFFFAA + FFFF
http://www.sites.google.com/site/sistprogr/lekcii1/lek7