Как найти остаток от деления ассемблер

Здесь все ссылки на материалы Old Programmer. А вот это ссылки на все мои статьи, где есть язык ассемблер. Рекомендую также познакомиться со структурой моего канала.

А здесь начала языка ассемблер на моем канале Old Programmer

Арифметические операции x86-64

В основном наборе инструкций входят разные вариации четырех арифметических действий: сложение, вычитание, умножение, деление. Важно помнить, что в результате арифметических действий меняются некоторые биты регистра флагов, что позволяет выполнять команду условного перехода, т.е. разветвлять программу на основе результат операции. Замечу, что для команд сложения и вычитания справедливыми являются отмеченное выше для операндов команды mov . К командам сложения можно отнести: add – обычное сложение, adc – сложение с добавлением результату флага переноса в качестве единицы (если флаг равен нулю, то команда эквивалентна команде add ), xadd – сложение, с предварительным обменом данных между операндами, inc – прибавление единицы к содержимому операнда. Несколько примеров: add %rbx , dt (или addq, dt, где четко указано, что складываются 64-битовые величины) – к содержимому области памяти dt добавляется содержимое регистра rbx и результат помещается в dt ; adc %rdx , %rdx – удвоение содержимого регистра rdx плюс добавление значения флага переноса; incl ll – увеличение на единицу содержимого памяти по адресу ll. При этом явно указывается, что операнд имеет размер 32 бита (d – dword).

К командам вычитания можно отнести следующие инструкции процессора x86-64: sub – обычное вычитание, sbb – вычитание из результата флага переноса в качестве единицы (если флаг равен нулю, то команда эквивалентна sub ), dec – вычитание единицы из результата, neg – вычитание значения операнда из 0 . Несколько примеров: sub %rax , ll – из содержимого ll вычитается содержимое регистра rax (или явно subq %rax , ll, где указывается, что операнды имеют 64-размер), и результат помещается в ll; subw go, %ax – вычитание из содержимого ax числа по адресу go, результат помещается в ax ; sbb %rdx , %rax – вычитание с дополнительным вычитанием флага переноса (из числа в rax вычитается число в rdx и результат в rax); decb l – вычитание единицы из байта, расположенного по адресу l . Следует отметить еще специальную команду cmp , которая во всем похожа на команду sub, кроме одного – результат вычитания никуда не помещается. Инструкция используется специально, для сравнения операндов.

Две основные команды умножения: mul – умножение беззнаковых чисел, imul – умножение знаковых чисел. Команда содержит один операнд – регистр или адрес памяти. В зависимости от размера операнда данные помещаются: в ax , dx : ax , edx : eax , rdx : rax . Например: mull ll – содержимое памяти с адресом ll будет умножено на содержимое eax (не забываем о суффиксе l), а результат отправлен в пару регистров edx : eax; mul %dl – умножить содержимое регистра dl на содержимое регистра al , а результат положить в ax ; mul %r8 – умножить содержимое регистра r8 на содержимое регистра rax , а результат положить в пару регистров rdx : rax.

Для деления (целого) также предусмотрены две команды: div – беззнаковое деление, idiv – знаковое деление. Инструкция также имеет один операнд – делитель. В зависимости от его размера результат помещается: al – результат деления, ah – остаток от деления; ax – результат деления, dx – остаток от деления; eax – результат деления, edx – остаток от деления; rax – результат деления, rdx – остаток от деления. Приведем примеры: divl dv – содержимое edx : eax делится на делитель, находящийся в памяти по адресу dv и результат деления помещается в eax , остаток в edx ; div %rsi – содержимое rdx : rax делится на содержимое rsi , результат помещается в rax , остаток в rdx .

Веб разработка на ассемблере. 
Можешь застрелиться прямо сейчас.
Веб разработка на ассемблере.
Можешь застрелиться прямо сейчас.

Говоря об арифметических операциях, укажем также на инструкции процессора, позволяющие работать с BCD числами. BCD – binary code decimal , т.е. двоично-десятичный код. Есть упакованные и не упакованные BCD -числа. В упакованных BCD -числах каждый полубайт представляет один разряд десятичного числа, в неупакованных BCD – числах на разряд десятичного числа отводится целый байт. Рассматриваемые здесь инструкции позволяют работать с такими числами, точнее они корректируют обычные арифметические операции, которые мы только что разбирали, так, что результат для BCD -числа оказывается верным. Надо сказать, что наличие с современном процессоре x86-64 инструкций группы FPU (FPU – floating point unit , т.е. устройство чисел с плавающей точкой или математический сопроцессор) делает не нужным использование выше означенных инструкций. Очевидно, по этой причине они были удалены из 64 -битового режима (о котором идет речь в данной книге). Мы только перечислим для справки эти инструкции: aaa , aas , aam , aad , daa , das .

Сегодня все, но ассемблер на нашем канале Old Programmer продолжается. Подписывайтесь, ставьте лайки и комментируйте.

Ассемблер x86-64. Базовые арифметические операции

Результат деления от -55/4=-13.75
Вывожу -13.
А где храниться остаток 75 и как его вывести. Ума не приложу. Ох если бы эксперт взглянул и ругнулся.

;z=(4*c-d/2+23)/(a*a-1)
.MODEL small
.Stack 100h
Datas segment
a dw 2
c dw 2
d dw 2
z dw ?
h dw ?
znam dw ?
mess db ‘Error! Division by zero.$’
Datas ends

Codes segment
Assume cs:Codes, ds:Datas
First:mov ax, Datas
mov ds, ax

;a*a
mov ax, a
cwd
imul a

;if a == 1 => divide by zero => error
cmp ax, 1
je @err

;(a*a)-1
mov h, 1
sbb ax, h
mov znam, ax

;4*c
mov h, 4
mov ax, c
imul h
mov z, ax

;d/2
mov h, 2
mov ax, d
cwd
idiv h

;(4*c)-(d/2)
sbb z, ax

;(4*c-d/2)+23
mov ax, z
mov h, 23
adc ax, h

;(4*c-d/2+23) / (znam)
cwd
idiv znam
mov z, ax

xor ax, ax
mov ax, z
push ax
cmp ax, 0
jns @plus ;åñëè çíàê ïëþñ (çíàêîâûé (ñòàðøèé) áèò ðåçóëüòàòà ðàâåí 0)

mov dl, ‘-‘
mov ah, 02h ;âèâîä
int 21h
pop ax
neg ax

@plus:xor cx, cx
mov bx, 10

@dvsn:xor dx, dx
div bx
push dx
inc cx
test ax, ax
jnz short @dvsn ;åñëè íåò íóëÿ
mov ah, 02h

@vivod: pop dx
add dl, 30h ; +30
int 21h
loop @vivod
jmp @end

@err:mov dx, offset mess
mov ah, 09h
int 21h

@end:mov ax, 4c00h
int 21h
Codes ends
end First

Деление

OF

DF

IF

TF

SF

ZF

AF

PF

CF

?

     

?

?

?

?

?

Код

Команда

Описание

Проц.

Пример

F6 /6

DIV AL,r/m8

Беззнаковое деление AX на r/m8, частное помещается в AL, остаток от деления – в AH

8086

div cl

F7 /6

DIV AX,r/m16

Беззнаковое деление DX:AX на r/m16, частное помещается в AX, остаток от деления – в DX

8086

div bx

F7 /6

DIV EAX,r/m32

Беззнаковое деление EDX:EAX на r/m32, частное помещается в EAX, остаток от деления – в EDX

Intel386

div edi

Команда DIV (Unsigned Divide) относится к группе команд целочисленной (или двоичной) арифметики (Binary Arithmetic Instructions) и производит целочисленное деление с остатком беззнаковых целочисленных операндов. Делимое, частное и остаток задаются неявно. Делимое является переменной в регистре (или регистровой паре) AX, DX:AX или EDX:EAX в зависимости от кода команды и атрибута размера операнда (что также определяет и разрядность делителя). Единственный явный операнд команды — операнд-источник (SRC), задающий делитель — может быть переменной в регистре или в памяти (r/m8, r/m16, r/m32). Целая часть частного помещается в регистр AL, AX или EAX в зависимости от заданного размера делителя (8, 16 или 32 бита). При этом остаток от целочисленного деления помещается в регистр AH, DX или EDX соответственно.

Действие команды DIV зависит от размера операнда-источника (SRC) следующим образом:

Размер

Делитель

Частное

Остаток

Делимое

byte

r/m8

AL

AH

AX

word

r/m16

AX

DX

DX:AX

dword

r/m32

EAX

EDX

EDX:EAX

Если частное, получаемое в результате деления, оказывается слишком велико, чтобы поместиться в целевом регистре-назначении (то есть имеет место переполнение), или если делитель равен нулю, то генерируется особая ситуация #DE.

Целочисленное частное округляется в сторону нуля. Абсолютная величина остатка всегда меньше, чем абсолютная величина делителя.

Команду DIV можно применять в вычислениях с числами в неупакованном двоично-десятичном формате (неупакованный BCD-формат). В этом случае необходимо использовать команду AAD непосредственно перед командой DIV, чтобы произвести так называемую ASCII-коррекцию перед делением и затем получить частное в таком же неупакованном BCD-формате, как и исходные операнды.

Дополнительной особенностью команды DIV является неопределенное значение большинства флагов в регистре EFLAGS в результате выполнения команды. То есть флаги не устанавливаются в соответствии с полученным частным или остатком.

Для деления целочисленных значений со знаком предназначены команды IDIV, FIDIV и FIDIVR.

IF (SRC = 0)

THEN #DE; (* Деление на нуль *)

FI;

IF (OperandSize = 8) (* Слово делим на байт *)

   THEN

      temp = AX / SRC;

      IF temp > 0xFFh

         THEN #DE; (* Ошибка деления *)

         ELSE

            AL = temp;

            AH = AX MOD SRC;

      FI;

   ELSE

      IF (OperandSize = 16) (* Двойное слово делим на слово *)

         THEN

            temp = DX:AX / SRC;

            IF ( temp > 0xFFFFh )

               THEN #DE; (* Ошибка деления *)

               ELSE

                  AX = temp;

                  DX = DX:AX MOD SRC;

            FI;

         ELSE (* Учетверенное слово делим на двойное слово *)

            temp = EDX:EAX / SRC;

            IF ( temp > 0xFFFFFFFFh )

               THEN #DE; (* Ошибка деления *)

               ELSE

                  EAX = temp;

                  EDX = EDX:EAX MOD SRC;

            FI;

      FI;

FI;

#DE, если делимое равно нулю или частное слишком велико для размещения в регистре-назначении.
#GP(0), если при обращении к операнду в памяти в сегменте DS, ES, FS или GS используется нулевой селектор.
#GP(0), если любая часть операнда в памяти находится вне допустимого пространства эффективных адресов в сегменте CS, DS, ES, FS или GS.
#SS(0), если любая часть операнда в памяти находится вне допустимого пространства эффективных адресов в стековом сегменте SS.

Intel386 … :
#PF(Код ошибки) при страничной ошибке.
#UD при использовании префикса LOCK.

Intel486 … :
#AC(0) при невыровненной ссылке в память, если активирован контроль выравнивания (CR0.AM = 1, EFLAGS.AC = 1, CPL = 3).

#DE, если делимое равно нулю или частное превышает разрядность регистра-назначения.
#GP, если любая часть операнда в памяти находится вне допустимого для реального режима пространства эффективных адресов в сегменте CS, DS, ES, FS или GS.
#SS, если любая часть операнда в памяти выходит за допустимую для реального режима верхнюю границу стекового сегмента SS.

Intel386 … :
#UD при использовании префикса LOCK.

#DE, если делимое равно нулю или частное превышает разрядность регистра-назначения.
#GP(0), если любая часть операнда в памяти находится вне допустимого пространства эффективных адресов в сегменте CS, DS, ES, FS или GS.
#SS(0), если любая часть операнда в памяти находится вне допустимого пространства эффективных адресов в стековом сегменте SS.

Intel386 … :
#PF(Код ошибки) при страничной ошибке.
#UD при использовании префикса LOCK.

Intel486 … :
#AC(0) при невыровненной ссылке в память, если активирован контроль выравнивания (CR0.AM = 1, EFLAGS.AC = 1, CPL = 3).

Отдельные особенности существовали у процессоров 8086/8088 для генерации особой ситуации деления на нуль (#DE). В этих процессорах, в отличие от последующих моделей, точка возврата CS:IP, сохраняемая в стеке, не указывает на команду DIV, вызвавшую прерывание, — она указывает на следующую за ней команду.

К командам целочисленной арифметики относятся команды ADD, ADC, SUB, SBB, IMUL, MUL, IDIV, DIV, INC, DEC, NEG, CMP.

В свою очередь, сами названные команды целочисленной арифметики делятся на следующие подгруппы:

  • команды сложения (ADD, ADC, INC);
  • команды вычитания (SUB, SBB, DEC);
  • команды умножения (IMUL, MUL);
  • команды деления (IDIV, DIV).
  • команды сравнения и изменения знака (CMP, NEG).

Время на прочтение
16 мин

Количество просмотров 123K

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

В этой статье мы напишем с нуля калькулятор обратной польской записи (RPN) на чистом ассемблере x86. Когда закончим, то сможем использовать его так:

$ ./calc "32+6*" # "(3+2)*6" в инфиксной нотации
30

Весь код для статьи здесь. Он обильно закомментирован и может служить учебным материалом для тех, кто уже знает ассемблер.

Начнём с написания базовой программы Hello world! для проверки настроек среды. Затем перейдём к системным вызовам, стеку вызовов, стековым кадрам и соглашению о вызовах x86. Потом для практики напишем некоторые базовые функции на ассемблере x86 — и начнём писать калькулятор RPN.

Предполагается, что у читателя есть некоторый опыт программирования на C и базовые знания компьютерной архитектуры (например, что такое регистр процессора). Поскольку мы будем использовать Linux, вы также должны уметь использовать командную строку Linux.

Настройка среды

Как уже сказано, мы используем Linux (64- или 32-битный). Приведённый код не работает в Windows или Mac OS X.

Для установки нужен только компоновщик GNU ld из binutils, который предварительно установлен в большинстве дистрибутивов, и ассемблер NASM. На Ubuntu и Debian можете установить и то, и другое одной командой:

$ sudo apt-get install binutils nasm

Я бы также рекомендовал держать под рукой таблицу ASCII.

Hello, world!

Для проверки среды сохраните следующий код в файле calc.asm:

; Компоновщик находит символ _start и начинает выполнение программы
; отсюда.
global _start

; В разделе .rodata хранятся константы (только для чтения)
; Порядок секций не имеет значения, но я люблю ставить её вперёд
section .rodata
    ; Объявляем пару байтов как hello_world. Псевдоинструкция базы NASM 
    ; допускает однобайтовое значение, строковую константу или их сочетание,
    ; как здесь. 0xA = новая строка, 0x0 = нуль окончания строки
    hello_world: db "Hello world!", 0xA, 0x0

; Начало секции .text, где находится код программы
section .text
_start:
    mov eax, 0x04           ; записать число 4 в регистр eax (0x04 = write())
    mov ebx, 0x1            ; дескриптор файла (1 = стандартный вывод, 2 = стандартная ошибка)
    mov ecx, hello_world    ; указатель на выводимую строку
    mov edx, 14             ; длина строки
    int 0x80                ; отправляем сигнал прерывания 0x80, который ОС
                            ;   интерпретирует как системный вызов

    mov eax, 0x01           ; 0x01 = exit()
    mov ebx, 0              ; 0 = нет ошибок
    int 0x80

Комментарии объясняют общую структуру. Список регистров и общих инструкций можете изучить в «Руководстве по ассемблеру x86 университета Вирджинии». При дальнейшем обсуждении системных вызовов это тем более понадобится.

Следующие команды собирают файл ассемблера в объектный файл, а затем компонует исполняемый файл:

$ nasm -f elf_i386 calc.asm -o calc
$ ld -m elf_i386 calc.o -o calc

После запуска вы должны увидеть:

$ ./calc
Hello world!

Makefile

Это необязательная часть, но для упрощения сборки и компоновки в будущем можно сделать Makefile. Сохраните его в том же каталоге, что и calc.asm:

CFLAGS= -f elf32
LFLAGS= -m elf_i386

all: calc

calc: calc.o
	ld $(LFLAGS) calc.o -o calc

calc.o: calc.asm
	nasm $(CFLAGS) calc.asm -o calc.o

clean:
	rm -f calc.o calc
        
.INTERMEDIATE: calc.o

Затем вместо вышеприведённых инструкций просто запускаем make.

Системные вызовы

Системные вызовы Linux указывают ОС выполнить для нас какие-то действия. В этой статье мы используем только два системных вызова: write() для записи строки в файл или поток (в нашем случае это стандартное устройство вывода и стандартная ошибка) и exit() для выхода из программы:

syscall 0x01: exit(int error_code)
  error_code - используем 0 для выхода без ошибок и любые другие значения (такие как 1) для ошибок
syscall 0x04: write(int fd, char *string, int length)
  fd — используем 1 для стандартного вывода, 2 для стандартного потока вывода ошибок
  string — указатель на первый символ строки
  length — длина строки в байтах

Системные вызовы настраиваются путём сохранения номера системного вызова в регистре eax, а затем его аргументов в ebx, ecx, edx в таком порядке. Можете заметить, что у exit() только один аргумент — в этом случае ecx и edx не имеют значения.

eax ebx ecx edx
Номер системного вызова arg1 arg2 arg3

Стек вызовов

Стек вызовов — структура данных, в которой хранится информация о каждом обращении к функции. У каждого вызова собственный раздел в стеке — «фрейм». Он хранит некоторую информацию о текущем вызове: локальные переменные этой функции и адрес возврата (куда программа должна перейти после выполнения функции).

Сразу отмечу одну неочевидную вещь: стек увеличивается вниз по памяти. Когда вы добавляете что-то на верх стека, оно вставляется по адресу памяти ниже, чем предыдущий элемент. Другими словами, по мере роста стека адрес памяти в верхней части стека уменьшается. Чтобы избежать путаницы, я буду всё время напоминать об этом факте.

Инструкция push заносит что-нибудь на верх стека, а pop уносит данные оттуда. Например, push еах выделяет место наверху стека и помещает туда значение из регистра eax, а pop еах переносит любые данные из верхней части стека в eax и освобождает эту область памяти.

Цель регистра esp — указать на вершину стека. Любые данные выше esp считаются не попавшими в стек, это мусорные данные. Выполнение инструкции push (или pop) перемещает esp. Вы можете манипулировать esp и напрямую, если отдаёте отчёт своим действиям.

Регистр ebp похож на esp, только он всегда указывает примерно на середину текущего кадра стека, непосредственно перед локальными переменными текущей функции (поговорим об этом позже). Однако вызов другой функции не перемещает ebp автоматически, это нужно каждый раз делать вручную.

Соглашение о вызовах для архитектуры x86

В х86 нет встроенного понятия функции как в высокоуровневых языках. Инструкция call — это по сути просто jmp (goto) в другой адрес памяти. Чтобы использовать подпрограммы как функции в других языках (которые могут принимать аргументы и возвращать данные обратно), нужно следовать соглашению о вызовах (существует много конвенций, но мы используем CDECL, самое популярное соглашение для x86 среди компиляторов С и программистов на ассемблере). Это также гарантирует, что регистры подпрограммы не перепутаются при вызове другой функции.

Правила вызывающей стороны

Перед вызовом функции вызывающая сторона должна:

  1. Сохранить в стек регистры, которые обязан сохранять вызывающий. Вызываемая функция может изменить некоторые регистры: чтобы не потерять данные, вызывающая сторона должна сохранить их в памяти до помещения в стек. Речь идёт о регистрах eax, ecx и edx. Если вы не используете какие-то из них, то их можно не сохранять.
  2. Записать аргументы функции на стек в обратном порядке (сначала последний аргумент, в конце первый аргумент). Такой порядок гарантирует, что вызываемая функция получит из стека свои аргументы в правильном порядке.
  3. Вызвать подпрограмму.

По возможности функция сохранит результат в eax. Сразу после call вызывающая сторона должна:

  1. Удалить из стека аргументы функции. Обычно это делается путём простого добавления числа байтов в esp. Не забывайте, что стек растёт вниз, поэтому для удаления из стека необходимо добавить байты.
  2. Восстановить сохранённые регистры, забрав их из стека в обратном порядке инструкцией pop. Вызываемая функция не изменит никакие другие регистры.

Следующий пример демонстрирует, как применяются эти правила. Предположим, что функция _subtract принимает два целочисленных (4-байтовых) аргумента и возвращает первый аргумент за вычетом второго. В подпрограмме _mysubroutine вызываем _subtract с аргументами 10 и 2:

_mysubroutine:
    ; ...
    ; здесь какой-то код
    ; ...
    push ecx       ; сохраняем регистры (я решил не сохранять eax)
    push edx
    push 2         ; второе правило, пушим аргументы в обратном порядке
    push 10
    call _subtract ; eax теперь равен 10-2=8
    add esp, 8     ; удаляем 8 байт со стека (два аргумента по 4 байта)
    pop edx        ; восстанавливаем сохранённые регистры
    pop ecx
    ; ...
    ; ещё какой-то код, где я использую удивительно полезное значение из eax
    ; ...

Правила вызываемой подпрограммы

Перед вызовом подпрограмма должна:

  1. Сохранить указатель базового регистра ebp предыдущего фрейма, записав его на стек.
  2. Отрегулировать ebp с предыдущего фрейма на текущий (текущее значение esp).
  3. Выделить больше места в стеке для локальных переменных, при необходимости переместить указатель esp. Поскольку стек растёт вниз, нужно вычесть недостающую память из esp.
  4. Сохранить в стек регистры вызываемой подпрограммы. Это ebx, edi и esi. Необязательно сохранять регистры, которые не планируется изменять.

Стек вызовов после шага 1:

Стек вызовов после шага 2:

Стек вызовов после шага 4:

На этих диаграммах в каждом стековом фрейме указан адрес возврата. Его автоматически вставляет в стек инструкция call. Инструкция ret извлекает адрес с верхней части стека и переходит на него. Эта инструкция нам не нужна, я просто показал, почему локальные переменные функции находятся на 4 байта выше ebp, но аргументы функции — на 8 байт ниже ebp.

На последней диаграмме также можно заметить, что локальные переменные функции всегда начинается на 4 байта выше ebp с адреса ebp-4 (здесь вычитание, потому что мы двигаемся вверх по стеку), а аргументы функции всегда начинается на 8 байт ниже ebp с адреса ebp+8 (сложение, потому что мы двигаемся вниз по стеку). Если следовать правилам из этой конвенции, так будет c переменными и аргументами любой функции.

Когда функция выполнена и вы хотите вернуться, нужно сначала установить eax на возвращаемое значение функции, если это необходимо. Кроме того, нужно:

  1. Восстановить сохранённые регистры, вынеся их из стека в обратном порядке.
  2. Освободить место в стеке, выделенное локальным переменным на шаге 3, если необходимо: делается простой установкой esp в ebp
  3. Восстановить указатель базы ebp предыдущего фрейма, вынеся его из стека.
  4. Вернуться с помощью ret

Теперь реализуем функцию _subtract из нашего примера:

_subtract:
    push ebp           ; сохранение указателя базы предыдущего фрейма
    mov ebp, esp        ; настройка ebp
    ; Здесь я бы выделил место на стеке для локальных переменных, но они мне не нужны
    ; Здесь я бы сохранил регистры вызываемой подпрограммы, но я ничего не
    ; собираюсь изменять
    ; Тут начинается функция
    mov eax, [ebp+8]    ; копирование первого аргумента функции в eax. Скобки
                        ; означают доступ к памяти по адресу ebp+8
    sub eax, [ebp+12]   ; вычитание второго аргумента по адресу ebp+12 из первого 
                        ; аргумента
    ; Тут функция заканчивается, eax равен её возвращаемому значению
    ; Здесь я бы восстановил регистры, но они не сохранялись
    ; Здесь я бы освободил стек от переменных, но память для них не выделялась
    pop ebp             ; восстановление указателя базы предыдущего фрейма
    ret

Вход и выход

В приведённом примере вы можете заметить, что функция всегда запускается одинаково: push ebp, mov ebp, esp и выделение памяти для локальных переменных. В наборе x86 есть удобная инструкция, которая всё это выполняет: enter a b, где a — количество байт, которые вы хотите выделить для локальных переменных, b — «уровень вложенности», который мы всегда будем выставлять на 0. Кроме того, функция всегда заканчивается инструкциями pop ebp и mov esp, ebp (хотя они необходимы только при выделении памяти для локальных переменных, но в любом случае не причиняют вреда). Это тоже можно заменить одной инструкцией: leave. Вносим изменения:

_subtract:
    enter 0, 0            ; сохранение указателя базы предыдущего фрейма и настройка ebp
    ; Здесь я бы сохранил регистры вызываемой подпрограммы, но я ничего не 
    ; собираюсь изменять
    ; Тут начинается функция
    mov eax, [ebp+8]    ; копирование первого аргумента функции в eax. Скобки
                        ; означают доступ к памяти по адресу ebp+8
    sub eax, [ebp+12]   ; вычитание второго аргумента по адресу ebp+12 из 
                        ; первого аргумента
    ; Тут функция заканчивается, eax равен её возвращаемому значению
    ; Здесь я бы восстановил регистры, но они не сохранялись
    leave              ; восстановление указателя базы предыдущего фрейма
    ret

Написание некоторых основных функций

Усвоив соглашение о вызовах, можно приступить к написанию некоторых подпрограмм. Почему бы не обобщить код, который выводит “Hello world!”, для вывода любых строк: функция _print_msg.

Здесь понадобится ещё одна функция _strlen для подсчёта длины строки. На C она может выглядеть так:

size_t strlen(char *s) {
    size_t length = 0;
    while (*s != 0)
    {           // начало цикла
        length++;
        s++;
    }           // конец цикла
    return length;
}

Другими словами, с самого начала строки мы добавляем 1 к возвращаемым значением для каждого символа, кроме нуля. Как только замечен нулевой символ, возвращаем накопленное в цикле значение. В ассемблере это тоже довольно просто: можно использовать как базу ранее написанную функцию _subtract:

_strlen:
    enter 0, 0          ; сохраняем указатель базы предыдущего фрейма и настраиваем ebp
    ; Здесь я бы сохранил регистры вызываемой подпрограммы, но я ничего не 
    ; собираюсь изменять
    ; Здесь начинается функция
    mov eax, 0          ; length = 0
    mov ecx, [ebp+8]    ; первый аргумент функции (указатель на первый
                        ; символ строки) копируется в ecx (его сохраняет вызывающая 
                        ; сторона, так что нам нет нужды сохранять)
_strlen_loop_start:     ; это метка, куда можно перейти
    cmp byte [ecx], 0   ; разыменование указателя и сравнение его с нулём. По
                        ; умолчанию память считывается по 32 бита (4 байта).
                        ; Иное нужно указать явно. Здесь мы указываем
                        ; чтение только одного байта (один символ)
    je _strlen_loop_end ; выход из цикла при появлении нуля
    inc eax             ; теперь мы внутри цикла, добавляем 1 к возвращаемому значению
    add ecx, 1          ; переход к следующему символу в строке
    jmp _strlen_loop_start  ; переход обратно к началу цикла
_strlen_loop_end:
    ; Здесь функция заканчивается, eax равно возвращаемому значению
    ; Здесь я бы восстановил регистры, но они не сохранялись
    leave               ; восстановление указателя базы предыдущего фрейма
    ret

Уже неплохо, верно? Сначала написать код на C может помочь, потому что большая его часть непосредственно преобразуется в ассемблер. Теперь можно использовать эту функцию в _print_msg, где мы применим все полученные знания:

_print_msg:
    enter 0, 0
    ; Здесь начинается функция
    mov eax, 0x04       ; 0x04 = системный вызов write()
    mov ebx, 0x1        ; 0x1 = стандартный вывод
    mov ecx, [ebp+8]    ; мы хотим вывести первый аргумент этой функции,
    ; сначала установим edx на длину строки. Пришло время вызвать _strlen
    push eax            ; сохраняем регистры вызываемой функции (я решил не сохранять edx)
    push ecx       
    push dword [ebp+8]  ; пушим аргумент _strlen в _print_msg. Здесь NASM
                        ; ругается, если не указать размер, не знаю, почему.
                        ; В любом случае указателем будет dword (4 байта, 32 бита)
    call _strlen        ; eax теперь равен длине строки
    mov edx, eax        ; перемещаем размер строки в edx, где он нам нужен
    add esp, 4          ; удаляем 4 байта со стека (один 4-байтовый аргумент char*)
    pop ecx             ; восстанавливаем регистры вызывающей стороны
    pop eax
    ; мы закончили работу с функцией _strlen, можно инициировать системный вызов
    int 0x80
    leave
    ret

И посмотрим плоды нашей тяжёлой работы, используя эту функцию в полной программе “Hello, world!”.

_start:
    enter 0, 0
    ; сохраняем регистры вызывающей стороны (я решил никакие не сохранять)
    push hello_world    ; добавляем аргумент для _print_msg
    call _print_msg
    mov eax, 0x01           ; 0x01 = exit()
    mov ebx, 0              ; 0 = без ошибок
    int 0x80

Хотите верьте, хотите нет, но мы рассмотрели все основные темы, которые нужны для написания базовых программ на ассемблере x86! Теперь у нас есть весь вводный материал и теория, так что полностью сосредоточимся на коде и применим полученные знания для написания нашего калькулятора RPN. Функции будут намного длиннее и даже станут использовать некоторые локальные переменные. Если хотите сразу увидеть готовую программу, вот она.

Для тех из вас, кто не знаком с обратной польской записью (иногда называемой обратной польской нотацией или постфиксной нотацией), то здесь выражения вычисляются с помощью стека. Поэтому нужно создать стек, а также функции _pop и _push для манипуляций с этим стеком. Понадобится ещё функция _print_answer, которая выведет в конце вычислений строковое представление числового результата.

Создание стека

Сначала определим для нашего стека пространство в памяти, а также глобальную переменную stack_size. Желательно изменить эти переменные так, чтобы они попали не в раздел .rodata, а в .data.

section .data
    stack_size: dd 0        ; создаём переменную dword (4 байта) со значением 0
    stack: times 256 dd 0   ; заполняем стек нулями

Теперь можно реализовать функции _push и _pop:

_push:
    enter 0, 0
    ; Сохраняем регистры вызываемой функции, которые будем использовать
    push eax
    push edx
    mov eax, [stack_size]
    mov edx, [ebp+8]
    mov [stack + 4*eax], edx    ; Заносим аргумент на стек. Масштабируем по
                                ; четыре байта в соответствии с размером dword
    inc dword [stack_size]      ; Добавляем 1 к stack_size
    ; Восстанавливаем регистры вызываемой функции
    pop edx
    pop eax
    leave
    ret

_pop:
    enter 0, 0
    ;  Сохраняем регистры вызываемой функции
    dec dword [stack_size]      ; Сначала вычитаем 1 из stack_size
    mov eax, [stack_size]
    mov eax, [stack + 4*eax]    ; Заносим число на верх стека в eax
    ; Здесь я бы восстановил регистры, но они не сохранялись
    leave
    ret

Вывод чисел

_print_answer намного сложнее: придётся конвертировать числа в строки и использовать несколько других функций. Понадобится функция _putc, которая выводит один символ, функция mod для вычисления остатка от деления (модуля) двух аргументов и _pow_10 для возведения в степень 10. Позже вы поймёте, зачем они нужны. Это довольно просто, вот код:

_pow_10:
    enter 0, 0
    mov ecx, [ebp+8]    ; задаёт ecx (сохранённый вызывающей стороной) аргументом 
                        ; функции
    mov eax, 1          ; первая степень 10 (10**0 = 1)
_pow_10_loop_start:     ; умножает eax на 10, если ecx не равно 0
    cmp ecx, 0
    je _pow_10_loop_end
    imul eax, 10
    sub ecx, 1
    jmp _pow_10_loop_start
_pow_10_loop_end:
    leave
    ret

_mod:
    enter 0, 0
    push ebx
    mov edx, 0          ; объясняется ниже
    mov eax, [ebp+8]
    mov ebx, [ebp+12]
    idiv ebx            ; делит 64-битное целое [edx:eax] на ebx. Мы хотим поделить
                        ; только 32-битное целое eax, так что устанавливаем edx равным 
                        ; нулю.
                        ; частное сохраняем в eax, остаток в edx. Как обычно, получить 
                        ; информацию по конкретной инструкции можно из справочников, 
                        ; перечисленных в конце статьи.
    mov eax, edx        ; возвращает остаток от деления (модуль)
    pop ebx
    leave
    ret

_putc:
    enter 0, 0
    mov eax, 0x04       ; write()
    mov ebx, 1          ; стандартный вывод
    lea ecx, [ebp+8]    ; входной символ
    mov edx, 1          ; вывести только 1 символ
    int 0x80
    leave
    ret

Итак, как мы выводим отдельные цифры в числе? Во-первых, обратите внимание, что последняя цифра числа равна остатку от деления на 10 (например, 123 % 10 = 3), а следующая цифра — это остаток от деления на 100, поделенный на 10 (например, (123 % 100)/10 = 2). В общем, можно найти конкретную цифру числа (справа налево), найдя (число % 10**n) / 10**(n-1), где число единиц будет равно n = 1, число десятков n = 2 и так далее.

Используя это знание, можно найти все цифры числа с n = 1 до n = 10 (это максимальное количество разрядов в знаковом 4-байтовом целом). Но намного проще идти слева направо — так мы сможем печатать каждый символ, как только находим его, и избавиться от нулей в левой части. Поэтому перебираем числа от n = 10 до n = 1.

На C программа будет выглядеть примерно так:

#define MAX_DIGITS 10
void print_answer(int a) {
    if (a < 0) { // если число отрицательное
        putc('-'); // вывести знак «минус»
        a = -a; // преобразовать в положительное число
    }
    int started = 0;
    for (int i = MAX_DIGITS; i > 0; i--) {
        int digit = (a % pow_10(i)) / pow_10(i-1);
        if (digit == 0 && started == 0) continue; // не выводить лишние нули
        started = 1;
        putc(digit + '0');
    }
}

Теперь вы понимаете, зачем нам эти три функции. Давайте реализуем это на ассемблере:

%define MAX_DIGITS 10

_print_answer:
    enter 1, 0              ; используем 1 байт для переменной "started" в коде C
    push ebx
    push edi
    push esi
    mov eax, [ebp+8]        ; наш аргумент "a"
    cmp eax, 0              ; если число не отрицательное, пропускаем этот условный 
                            ; оператор
    jge _print_answer_negate_end
    ; call putc for '-'
    push eax
    push 0x2d               ; символ '-'
    call _putc
    add esp, 4
    pop eax
    neg eax                 ; преобразуем в положительное число
_print_answer_negate_end:
    mov byte [ebp-4], 0     ; started = 0
    mov ecx, MAX_DIGITS     ; переменная i
_print_answer_loop_start:
    cmp ecx, 0
    je _print_answer_loop_end
    ; вызов pow_10 для ecx. Попытаемся сделать ebx как переменную "digit" в коде C.
    ; Пока что назначим edx = pow_10(i-1), а ebx = pow_10(i)
    push eax
    push ecx
    dec ecx             ; i-1
    push ecx            ; первый аргумент для _pow_10
    call _pow_10
    mov edx, eax        ; edx = pow_10(i-1)
    add esp, 4
    pop ecx             ; восстанавливаем значение i для ecx
    pop eax
    ; end pow_10 call
    mov ebx, edx        ; digit = ebx = pow_10(i-1)
    imul ebx, 10        ; digit = ebx = pow_10(i)
    ; вызываем _mod для (a % pow_10(i)), то есть (eax mod ebx)
    push eax
    push ecx
    push edx
    push ebx            ; arg2, ebx = digit = pow_10(i)
    push eax            ; arg1, eax = a
    call _mod
    mov ebx, eax        ; digit = ebx = a % pow_10(i+1), almost there
    add esp, 8
    pop edx
    pop ecx
    pop eax
    ; завершение вызова mod
    ; делим ebx (переменная "digit" ) на pow_10(i) (edx). Придётся сохранить пару 
    ; регистров, потому что idiv использует для деления и edx, eax. Поскольку 
    ; edx является нашим делителем, переместим его в какой-нибудь 
    ; другой регистр
    push esi
    mov esi, edx
    push eax
    mov eax, ebx
    mov edx, 0
    idiv esi            ; eax хранит результат (цифру)
    mov ebx, eax        ; ebx = (a % pow_10(i)) / pow_10(i-1), переменная "digit" в коде C
    pop eax
    pop esi
    ; end division
    cmp ebx, 0                        ; если digit == 0
    jne _print_answer_trailing_zeroes_check_end
    cmp byte [ebp-4], 0               ; если started == 0
    jne _print_answer_trailing_zeroes_check_end
    jmp _print_answer_loop_continue   ; continue
_print_answer_trailing_zeroes_check_end:
    mov byte [ebp-4], 1     ; started = 1
    add ebx, 0x30           ; digit + '0'
    ; вызов putc
    push eax
    push ecx
    push edx
    push ebx
    call _putc
    add esp, 4
    pop edx
    pop ecx
    pop eax
    ; окончание вызова putc
_print_answer_loop_continue:
    sub ecx, 1
    jmp _print_answer_loop_start
_print_answer_loop_end:
    pop esi
    pop edi
    pop ebx
    leave
    ret

Это было тяжкое испытание! Надеюсь, комментарии помогают разобраться. Если вы сейчас думаете: «Почему нельзя просто написать printf("%d")?», то вам понравится окончание статьи, где мы заменим функцию именно этим!

Теперь у нас есть все необходимые функции, осталось реализовать основную логику в _start — и на этом всё!

Вычисление обратной польской записи

Как мы уже говорили, обратная польская запись вычисляется с помощью стека. При чтении число заносится на стек, а при чтении оператор применяется к двум объектам наверху стека.

Например, если мы хотим вычислить 84/3+6* (это выражение также можно записать в виде 6384/+*), процесс выглядит следующим образом:

Шаг Символ Стек перед Стек после
1 8 [] [8]
2 4 [8] [8, 4]
3 / [8, 4] [2]
4 3 [2] [2, 3]
5 + [2, 3] [5]
6 6 [5] [5, 6]
7 * [5, 6] [30]

Если на входе допустимое постфиксное выражение, то в конце вычислений на стеке остаётся лишь один элемент — это и есть ответ, результат вычислений. В нашем случае число равно 30.

В ассемблере нужно реализовать нечто вроде такого кода на C:

int stack[256];         // наверное, 256 слишком много для нашего стека
int stack_size = 0;

int main(int argc, char *argv[]) {
    char *input = argv[0];
    size_t input_length = strlen(input);
    
    for (int i = 0; i < input_length; i++) {
        char c = input[i];
        if (c >= '0' && c <= '9') { // если символ — это цифра
            push(c - '0'); // преобразовать символ в целое число и поместить в стек
        } else {
            int b = pop();
            int a = pop();
            if (c == '+') {
                push(a+b);
            } else if (c == '-') {
                push(a-b);
            } else if (c == '*') {
                push(a*b);
            } else if (c == '/') {
                push(a/b);
            } else {
                error("Invalid inputn");
                exit(1);
            }
        }
    }
    
    if (stack_size != 1) {
        error("Invalid inputn");
        exit(1);
    }
    
    print_answer(stack[0]);
    exit(0);
}

Теперь у нас имеются все функции, необходимые для реализации этого, давайте начнём.

_start:
    ; аргументы _start получаются не так, как в других функциях.
    ; вместо этого esp указывает непосредственно на argc (число аргументов), а 
    ; esp+4 указывает на argv. Следовательно, esp+4 указывает на название
    ; программы, esp+8 - на первый аргумент и так далее
    mov esi, [esp+8]         ; esi = "input" = argv[0]
    ; вызываем _strlen для определения размера входных данных
    push esi
    call _strlen
    mov ebx, eax             ; ebx = input_length
    add esp, 4
    ; end _strlen call
    mov ecx, 0               ; ecx = "i"
_main_loop_start:
    cmp ecx, ebx             ; если (i >= input_length)
    jge _main_loop_end
    mov edx, 0
    mov dl, [esi + ecx]      ; то загрузить один байт из памяти в нижний байт
                             ; edx. Остальную часть edx обнуляем.
                             ; edx = переменная c = input[i]
    cmp edx, '0'
    jl _check_operator
    cmp edx, '9'
    jg _print_error
    sub edx, '0'
    mov eax, edx             ; eax = переменная c - '0' (цифра, не символ)
    jmp _push_eax_and_continue
_check_operator:
    ; дважды вызываем _pop для выноса переменной b в edi, a переменной b - в eax
    push ecx
    push ebx
    call _pop
    mov edi, eax             ; edi = b
    call _pop                ; eax = a
    pop ebx
    pop ecx
    ; end call _pop
    cmp edx, '+'
    jne _subtract
    add eax, edi                 ; eax = a+b
    jmp _push_eax_and_continue
_subtract:
    cmp edx, '-'
    jne _multiply
    sub eax, edi                 ; eax = a-b
    jmp _push_eax_and_continue
_multiply:
    cmp edx, '*'
    jne _divide
    imul eax, edi                ; eax = a*b
    jmp _push_eax_and_continue
_divide:
    cmp edx, '/'
    jne _print_error
    push edx                     ; сохраняем edx, потому что регистр обнулится для idiv
    mov edx, 0
    idiv edi                     ; eax = a/b
    pop edx
    ; теперь заносим eax на стек и продолжаем
_push_eax_and_continue:
    ; вызываем _push
    push eax
    push ecx
    push edx
    push eax          ; первый аргумент
    call _push
    add esp, 4
    pop edx
    pop ecx
    pop eax
    ; завершение call _push
    inc ecx
    jmp _main_loop_start
_main_loop_end:
    cmp byte [stack_size], 1      ; если (stack_size != 1), печать ошибки
    jne _print_error
    mov eax, [stack]
    push eax
    call _print_answer
    ; print a final newline
    push 0xA
    call _putc
    ; exit successfully
    mov eax, 0x01           ; 0x01 = exit()
    mov ebx, 0              ; 0 = без ошибок
    int 0x80                ; здесь выполнение завершается
_print_error:
    push error_msg
    call _print_msg
    mov eax, 0x01
    mov ebx, 1
    int 0x80

Понадобится ещё добавить строку error_msg в раздел .rodata:

section .rodata
    ; Назначаем на некоторые байты error_msg. Псевдоинструкция db в NASM
    ; позволяет использовать однобайтовое значение, строковую константу или их 
    ; сочетание. 0xA = новая строка, 0x0 = нуль окончания строки
    error_msg: db "Invalid input", 0xA, 0x0

И мы закончили! Удивите всех своих друзей, если они у вас есть. Надеюсь, теперь вы с большей теплотой отнесётесь к языкам высокого уровня, особенно если вспомнить, что многие старые программы писали полностью или почти полностью на ассемблере, например, оригинальный RollerCoaster Tycoon!

Весь код здесь. Спасибо за чтение! Могу продолжить, если вам интересно.

Дальнейшие действия

Можете попрактиковаться, реализовав несколько дополнительных функций:

  1. Выдать вместо segfault сообщение об ошибке, если программа не получает аргумент.
  2. Добавить поддержку дополнительных пробелов между операндами и операторами во входных данных.
  3. Добавить поддержку многоразрядных операндов.
  4. Разрешить ввод отрицательных чисел.
  5. Заменить _strlen на функцию из стандартной библиотеки C, а _print_answer заменить вызовом printf.

Дополнительные материалы

  • «Руководство по ассемблеру x86 университета Вирджинии» — более подробное изложение многих тем, рассмотренных нами, в том числе дополнительная информация по всем популярным инструкциям x86.
  • «Искусство выбора регистров Intel». Хотя большинство регистров x86 — регистры общего назначения, но у многих есть историческое значение. Следование этим соглашениям может улучшить читаемость кода и, как интересный побочный эффект, даже немного оптимизировать размер двоичных файлов.
  • NASM: Intel x86 Instruction Reference — полное руководство по всем малоизвестным инструкциям x86.

Расширенные арифметические операции с целыми числами

Несмотря на то, что мы привыкли к десятичной арифметике
(база 10), компьютер работает только с двоичной арифметикой
(база 2). Кроме того, ввиду ограничения, накладываемого
16-битовыми регистрами, большие величины требуют специальной
обработки.
Данная глава дает сведения об операциях сложения,
вычитания, умножения и деления для беззнаковых и знаковых
данных. В главе приводятся много примеров и предупреждений о
различных ловушках для опрометчивых исследователей мира
микропроцессора. В следующей главе будут раскрыты операции
преобразования между двоичными данными и ASCII кодами.

Сложение и вычитание

Команды ADD и SUB выполняют сложение и вычитание байтов
или слов, содержащих двоичные данные. Вычитание выполняется
в компьютере по методу сложения с двоичным дополнением: для
второго операнда устанавливаются обратные значения бит и
прибавляется 1, а затем проиCXодит сложение с первым
операндом. Во всем, кроме первого шага, операции сложения и
вычитания идентичны.
Возможны следующие пять ситуаций:

        сложение/вычитание регистр-регистр;
        сложение/вычитание память-регистр;
        сложение/вычитание регистр-память;
        сложение/вычитание регистр-непоср.значение;
        сложение/вычитание память-непоср.значение.

Поскольку прямой операции память-память не существует,
данная oперация выполняется через регистр. В следующем
примере к содержимому слова WORDB прибавляется содержимое
слова WORDA, описанных как DW:

                  MOV  AX,WORDA
                  ADD  AX,WORDB
                  MOV  WORDB,AX

Переполнения

Опасайтесь переполнений в арифметических операциях. Один
байт содержит знаковый бит и семь бит данных, т.е. значения
от -128 до +127. Результат арифметической операции может
легко превзойти емкость однобайтового регистра. Например,
результат сложения в регистре AL, превышающий его емкость,
автоматически не переходит в регистр AH. Предположим, что
регистр AL содержит шест.60, тогда результат команды

                  ADD  AL,20H

генерирует в AL суумму – шест.80. Но операция также
устанавливает флаг переполнения и знаковый флаг в состояние
«отрицательно». Причина заключается в том, что шест.80 или
двоичное 1000 0000 является отрицательным числом. Т.е. в
результате, вместо +128, мы получим -128. Так как регистр
AL слишком мал для такой операции и следует воспользоваться
регистром AX. В следующем примере команда CBW (Convert Byte
to Word – преобразовать байт в слово) преобразует шест.60 в
регистре AL в шест.0060 в регистре AX, передавая при этом
знаковый бит (0) через регистр AH. Команда ADD генерирует
теперь в регистре AX правильный результат: шест.0080, или
+128:

             CBW            ;Расширение AL до AX
             ADD  AX,20H    ;Прибавить к AX

Но полное слово имеет также ограничение: один знаковый
бит и 15 бит данных, что соответствует значениям от -32768
до +32767. Рассмотрим далее как можно обрабатывать числа,
превышающие эти пределы.

Многословное сложение

Максимальное возможное значение в регистре +32767 ограни
чивает возможность компьютера для выполнения арифметических
операций. Рассмотрим два способа выполнения арифметических
операций. Первый способ – более прост, но специфичен, второй
– сложнее, но имеет общий характер.
Сумма – шест.1119C превышает емкость регистра AX.
Переполнение вызывает установку флага переноса в 1. Затем
выполняется сложение левых слов, но в данном случае, вместо
команды ADD используется команда сложения с переносом ADC
(ADd with Carry). Эта команда складывает два значения, и
если флаг CF уже установлен, то к сумме прибавляется 1:

                  WORD1A         0123
                  WORD2A         0012
                  Плюс перенос      1
                  Сумма:         0136

При использовании отладчика DEBUG для трассировки
арифметических команд можно увидеть эту сумму 0136 в
регистре AX, и обpатные значения 3601 в поле WORD3A и 9C11 в
поле WORD3B.
На рис.12.2 процедура E10DWD демонстрирует подход к
сложению значений любой длины. Действие начинается со
сложения самых правых слов складываемых полей. В первом
цикле складываются правые cлова, во втором – слова,
расположенные левее. При этом адреса в регистрах SI, DI и BX
уменьшаются на 2. По две команда DEC выполняют эту операцию
для каждого регистра. Применять команду

                  SUB  reg,02

в данном случае нельзя, т.к. при этом будет очищен флаг
переноса, что приведет к искажению результата сложения.
Ввиду наличия цикла, используется только одна команда
сложения ADC. Перед циклом команда CLC (CLear Carry –
очистить флаг переноса) устанавливает нулевое значение флага
переноса. Для работы данного метода необходимо: 1) обеспе
чить смежность слов, 2) выполнять обработку справа налево и
3) загрузить в регистр CX число складываемых слов.
Для многословного вычитания используется команда SBB
(SuBtract with Borrow – вычитание с заемом) эквивалентная
команде ADC. Заменив в процедуре E10DWD (рис.12.2) команду
ADC на SBB, получим процедуру для вычитания.

Беззнаковые данные

Многие числовые поля не имеют знака, например, номер
абонента, aдрес памяти. Некоторые числовые поля предлагаются
всегда положительные, например, норма выплаты, день недели,
значение числа ПИ. Другие числовые поля являются знаковые,
так как их содержимое может быть положительным или
отрицательным. Например, долговой баланс покупателя, который
может быть отрицательным при переплатах, или алгебраическое
число.
Для беззнаковых величин все биты являются битами данных и
вместо ограничения +32767 регистр может содержать числа до
+65535. Для знаковых величин левый байт является знаковым
битом. Команды ADD и SUB не делают разницы между знаковыми
и беззнаковыми величинами, они просто складывают и вычитают
биты. В следующем примере сложения двух двоичных чисел,
первое число содержит единичный левый бит. Для беззнакового
числа биты представляют положительное число 249, для
знакового – отрицательное число -7:

                       Беззнаковое    Знаковое
        11111001            249            -7
        00000010              2            +2
        11111011            251            -5

Двоичное представление результата сложения одинаково для
беззнакового и знакового числа. Однако, биты представляют
+251 для беззнакового числа и -5 для знакового. Таким
одразом, числовое содержимое поля может интерпретироваться
по разному.
Состояние «перенос» возникает в том случае, когда имеется
пеpенос в знаковый разряд. Состояние «переполнение» возника
ет в том случае, когда перенос в знаковый разряд не создает
переноса из разрядной сетки или перенос из разрядной сетки
проиCXодит без переноса в знаковый разряд. При возникновении
переноса при сложении беззнаковых чисел, результат получает
ся неправильный:

                       Беззнаковое    Знаковое  CF   OF
        11111100            252            -4
        00000101              5            +5
        00000001              1             1    1    0
                       (неправильно)

При возникновении переполнения при сложении знаковых
чисел, результат получается неправильный:

                       Беззнаковое    Знаковое  CF   OF
        01111001            121          +121
        00001011             11           +11
        10000100            132          -124    0    1
                                   (неправильно)

При операциях сложения и вычитания может одновременно
возникнуть и переполнение, и перенос:

                       Беззнаковое    Знаковое  CF   OF
        11110110            246           -10
        10001001            137          -119
        01111111            127          +127    1    1
                       (неправильно)  (неправильно)

Умножение

Операция умножения для беззнаковых данных выполняется
командой MUL, а для знаковых – IMUL (Integer MULtiplication
– умножение целых чисел). Ответственность за контроль над
форматом обрабатываемых чисел и за выбор подходящей команды
умножения лежит на самом программисте. Существуют две
основные операции умножения:

«Байт на байт». Множимое находится в регистре AL, а множи
тель в байте памяти или в однобайтовом регистре. После
умножения произведение находится в регистре AX. Операция
игнорирует и стиpает любые данные, которые находились в
регистре AH.

              |   AH   |   AL   |           |   AX       |
 До умножения:|        |Множимое|     После:|Произведение|

«Слово на слово». Множимое находится в регистре AX, а мно
житель – в слове памяти или в регистре. После умножения
произведение находится в двойном слове, для которого
требуется два регистра: старшая (левая) часть произведения
находится в регистре DX, а младшая (правая) часть в регистре
AX. Операция игнорирует и стирает любые данные, которые
находились в регистре DX.

              |   AX   |          |   DX   ||   AX   |
 До умножения:|Множимое| После:   |Ст.часть||Мл.часть|
                                  |   Произведение   |

В единственном операнде команд MUL и IMUL указывается
множитель. Рассмотрим следующую команду:

                       MUL  MULTR

Если поле MULTR определено как байт (DB), то операция
предполагает умножение содержимого AL на значение байта из
поля MULTR. Если поле MULTR определено как слово (DW), то
опереция предполагает умножение содержимого AX на значение
слова из поля MULTR. Если множитель находится в регистре, то
длина регистра определяет тип операции, как это показанно
ниже:

   MUL  CL  ;Байт-множитель: множимое в AL, произвед. в AX
   MUL  BX  ;Слово-множитель:множимое в AX, произв.в DX:AX

Беззнаковое умножение: Команда MUL

Команда MUL (MULtiplication – умножение) умножает беззна
ковые числа. На рис. 12.3 в процедуре C10MUL дано три
примера умножения: байт на байт, слово на слово и слово на
байт. Первый пример команды MUL умножает шест.80 (128) на
шест.47 (64). Произведение -ш ест.2000 (8192) получается в
регистре AX.
Второй пример команды MUL генерирует шест. 10000000 в
регистpах DX:AX.
Третий пример команды MUL выполняет умножение слова на
байт и требует расширение байта BYTE1 до размеров слова. Так
как предполагаются беззнаковые величины, то в примере левый
бит регистра AH равен нулю. (При использовании команды CBW
значение левого бита регистpа AL может быть 0 или 1).
Произведение – шест. 00400000 получается в регистрах DX:AX.

Знаковое умножение: Команда IMUL

Команда IMUL (Integer MULtiplication – умножение целых
чисел) умножает знаковые числа. На рис. 12.3 в процедуре
D10IMUL используются те же три примера умножения, что и в
процедуре C10MUL, но вместо команд MUL записаны команды
IMUL.
Первый пример команды IMUL умножает шест.80 (отрицатель
ное число) на шест.40 (положительное число). Произведение
– шест.E000 получается в регистре AX. Используя те же
данные, команда MUL дает в результате шест.2000, так что
можно видеть разницу в использовании команд MUL и IMUL.
Команда MUL рассматривает шест.80 как +128, а команда IMUL
– как -128. В результате умножения -128 на +64 получается
-8192 или шест.E000. (Попробуйте преобразовать шест.Е000 в
десятичный формат).
Второй пример команды IMUL умножает шест.8000 (отрицатель
ное значение) на шест.2000 (положительное значение).
Произведение – шест.F0000000 получается в регистрах DX:AX и
представляет собой oтрицательное значение.
Третий пример команды IMUL перед умножением выполняет
расширение байта BYTE1 до размеров слова в регистре AX. Так
как значения предполагаются знаковые, то в примере
используется команда CBW для перевода левого знакового бита
в регистр AH: шест.80 в pегистре AL превращается в шест.FF80
в регистре AX. Поскольку множитель в слове WORD1 имеет
также отрицательное значение, то произведение должно
получится положительное. В самом деле: шест.00400000 в
регистрах DX:AX – такой же результат, как и в случае
умножения командой MUL, которая предполагала положительные
сомножители.
Таким образом, если множимое и множитель имеет одинаковый
знаковый бит, то команды MUL и IMUL генерируют одинаковый
результат. Но, если сомножители имеют разные знаковые биты,
то команда MUL вырабатывает положительный результат
умножения, а команда IMUL – отрицательный.
Можно обнаружить это, используя отладчик DEBUG для
трассировки примеров.
Повышение эффективности умножения: При умножении на
степень числа 2 (2,4,8 и т.д.) более эффективным является
сдвиг влево на требуемое число битов. Сдвиг более чем на 1
требует загрузки величины сдвига в регистр CL. В следующих
примерах предположим, что множимое находится в регистре AL
или AX:

             Умножение на 2:     SHL  AL,1
             Умножение на 8:     MOV  CL,3
                                 SHL  AX,CL

Многословное умножение

Обычно умножение имеет два типа: «байт на байт» и «слово
на слово». Как уже было показано, максимальное знаковое
значение в слове ограничено величиной +32767. Умножение
больших чисел требует выполнения некоторых дополнительных
действий. Рассматриваемый подход предполагает умножение
каждого слова отдельно и сложение полученных результатов.
Рассмотрим следующее умножение в десятичном формате:

                       1365
                        х12
                       2730
                       1365
                      16380

Представим, что десятичная арифметика может умножать только
двухзначные числа. Тогда можно умножить 13 и 65 на 12
раздельно, cледующим образом:

              13             65
             х12            х12
              26            130
              13             65
             156            780

Следующим шагом сложим полученные произведения, но поскольку
число 13 представляло сотни, то первое произведение в
действительности будет 15600:

                15600
                   +780
                  16380

Ассемблерная программа использует аналогичную технику за
исключением того, что данные имеют размерность слов (четыре
цифры) в шестнадцатеричном формате.

Умножение двойного слова на слово. Процедура E10XMUL на
рис.12.4 умножает двойное слово на слово. Множимое, MULTCND,
состоит из двух слов, содержащих соответственно шест. 3206 и
шест. 2521. Определение данных в виде двух слов (DW) вместо
двойного слова (DD) обусловлено необходимостью правильной
адресации для команд MOV, пересылающих слова в регистр AX.
Множитель MULTPLR содержит шест. 6400. Область для записи
произведения, PRODUCT, состоит из трех слов. Первая команда
MUL перемножает MULTPLR и правое cлово поля MULTCND;
произведение – шест. 0E80 E400 записывается в PRODUCT+2 и
PRODUCT+4. Вторая команда MUL перемножает MULTPLR и левое
слово поля MULTCND, получая в результате шест. 138A 5800.
Далее выполняется сложение двух произведений следующим
образом:

        Произведение1: 0000 0E80 E400
        Произведение 2:     138A 5800
        Результат:          138A 6680 E400

Так как первая команда ADD может выработать перенос, то
второе cложение выполняется командой сложения с переносом
ADC (ADd with Carry). В силу обратного представления байтов
в словах в процессоpах 8086/8088, область PRODUCT в действи
тельности будет содержать значение 8A13 8066 00E4.
Программа предполагает, что первое слово в области
PRODUCT имеет начальное значение 0000.
Умножение «двойного слова на двойное слово». Умножение
двух двойных слов включает следующие четыре операции
умножения:

             Множимое       Множитель
             слово 2   х    слово 2
             слово 2   х    слово 1
             слово 1   х    слово 2
             слово 1   х    слово 1

Каждое произведение в регистрах DX и AX складывается с
соответствующим словом в окончательном результате. Пример
такого умножения приведен в процедуре F10XMUL на рис. 12.4.
Множимое MULTCND содержит шест. 3206 2521, множитель MULTPLR
– шест. 6400 0A26. Результат заносится в область PRODUCT,
состоящую из четырех слов.
Хотя логика умножения двойных слов аналогична умножению
двойного слова на слово, имеется одна особенность, после
пары команд сложения ADD/ADC используется еще одна команда
ADC, которая прибавляет 0 к значению в поле PRODUCT. Это
необходимо потому, что первая команда ADC сама может вызвыть
перенос, который последующие команды могут стереть. Поэтому
вторая команда ADC прибавит 0, если переноса нет, и
прибавит 1, если перенос есть. Финальная пара команд ADD/ADC
не тредует дополнительной команды ADC, так как область
PRODUCT достаточно велика для генерации окончательного
результата и переноса на последнем этапе не будет.
Окончательный результат 138A 687C 8E5C CCE6 получится в
поле PRODUCT в обратной записи байт в словах. Выполните
трассировку этого примера с помощью отладчика DEBUG.

=== Сдвиг регистровой пары DX:AX
Следующая подпрограмма может быть полезна для сдвига
содержимого pегистровой пары DX:AX вправо или влево. Можно
придумать более эффективный метод, но данный пример
представляет общий подход для любого числа циклов (и,
соответственно, сдвигов) в регистре CX. Заметьте, что сдвиг
единичного бита за разрядную сетку устанавливает флаг
переноса.

                   Сдвиг влево на 4 бита
             MOV  CX,04     ;Инициализация на 4 цикла
        C20: SHL  DX,1      ;Сдвинуть DX на 1 бит влево
             SHL  AX,1      ;Сдвинуть AX на 1 бит влево
             ADC  DX,00     ;Прибавить значение переноса
             LOOP C20       ;Повторить
                   Сдвиг вправо на 4 бита
             MOV  CX,04     ;Инициализация на 4 цикла
        D20: SHR  AX,1      ;Сдвинуть AX на 1 бит вправо
             SHR  DX,1      ;Сдвинуть DX на 1 бит вправо
             JNC  D30       ;Если есть перенос,
             OR   AH,10000000B   ;  то вставить 1 в AH
        D30: LOOP D20       ;Повторить

Ниже приведен более эффективный способ для сдвига влево,
не требующий организации цикла. В этом примере фактор сдвига
записывается в регистр CL. Пример написан для сдвига на 4
бита, но может быть адаптирован для других величин сдвигов:

             MOV  CL,04     ;Установить фактор сдвига
             SHL  DX,CL     ;Сдвинуть DX влево на 4 бита
             MOV  BL,AH     ;Сохранить AH в BL
             SHL  AX,CL     ;Сдвинуть AX влево на 4 бита
             SHL  BL,CL     ;Сдвинуть BL вправо на 4 бита
             OR   DL,BL     ;Записать 4 бита из BL в DL

Деление

Операция деления для беззнаковых данных выполняется
командой DIV, a для знаковых – IDIV. Ответственность за
подбор подходящей команды лежит на программисте. Существуют
две основные операции деления:
Деление «слова на байт». Делимое находится в регистре AX,
а делитель – в байте памяти или а однобайтовом регистре.
После деления остаток получается в регистре AH, а частное –
в AL. Так как однобайтовое частное очень мало (максимально
+255 (шест.FF) для беззнакового деления и +127 (шест.7F) для
знакового), то данная операция имеет ограниченное
использование.

              |   AX   |              |  AH   |  AL   |
 До деления:  | Делимое|    После:    |Остаток|Частное|

Деление «двойного слова на слово». Делимое находится в
регистровой паре DX:AX, а делитель – в слове памяти или а
регистре. После деления остаток получается в регистре DX, а
частное в регистре AX. Частное в одном слове допускает
максимальное значение +32767 (шест.FFFF) для беззнакового
деления и +16383 (шест.7FFF) для знакового.

            |   DX   ||   AX   |        |  AH   ||  AL   |
 До деления:|Ст.часть||Мл.часть|  После:|Остаток||Частное|
            |     Делимое      |

В единственном операнде команд DIV и IDIV указывается
делитель. Рассмотрим следующую команду:

                       DIV  DIVISOR

Если поле DIVISOR определено как байт (DB), то операция
предполагает деление слова на байт. Если поле DIVISOR
определено как слово (DW), то операция предполагает деление
двойного слова на слово.
При делении, например, 13 на 3, получается разельтат 4
1/3. Частное есть 4, а остаток – 1. Заметим, что ручной
калькулятор (или программа на языке BASIC) выдает в этом
случае результат 4,333…. Значение содержит целую часть (4)
и дробную часть (,333). Значение 1/3 и 333… есть дробные
части, в то время как 1 есть остаток от деления.

Беззнаковое деление: Команда DIV

Команда DIV делит беззнаковые числа. На рис.12.5 в
процедуре D10DIV дано четыре примера деления: слово на байт,
байт на байт, двойное слово на слово и слово на слово.
Первый пример команды DIV делит шест.2000 (8092) на шест.80
(128). В результате остаток 00 получается в регистре AH, а
частное шест.40 (64) – в регистре AL.
Второй пример команды DIV выполняет прежде расширение
байта BYTE1 до размеров слова. Так как здесь предполагается
беззнаковая величина, то в примере левый бит регистра AH
равен нулю. В результате деления остаток – шест. 12 получает
ся в регистре AH, а частное шест.05 – в регистре AL.
Третий пример команды DIV генерирует остаток шест. 1000 в
регистре DX и частное шест. 0080 в регистре AX.
В четвертом примере команды DIV сначала выполняется
расширение слова WORD1 до двойного слова в регистре DX.
После деления остаток шест.0000 получится в регистре DX, а
частное шест. 0002 – в регистре AX.

Знаковое деление: Команда IDIV

Команда IDIV (Integer DIVide) выполняет деление знаковых
чисел. На рис.12.5 в процедуре E10IDIV используются те же
четыре примера деления, что и в процедуре D10DIV, но вместо
команд DIV записаны команды IDIV. Первый пример команды
IDIV делит шест.2000 (положительное число) на шест.80 (отри
цательное число). Остаток от деления – шест. 00 получается в
регистре AH , а частное – шест. C0 (-64) – в регистре AL.
Команда DIV, используя те же числа, генерирует частное +64.
Шестнадцатиричные результаты трех остальных примеров
деления приведены ниже:

 Пример команды IDIV        Остаток        Частное
             2              EE (-18)       FB (-5)
             3            1000 (4096)    0080 (128)
             4            0000           0002

Только в примере 4 вырабатывается такой же результат, что и
для команды DIV. Таким образом, если делимое и делитель
имеют одинаковый знаковый бит, то команды DIV и IDIV
генерируют одинаковый pезультат. Но, если делимое и делитель
имеют разные знаковые биты, то команда DIV генерирует положи
тельное частное, а команда IDIV – отрицательное частное.
Можно обнаружить это, используя отладчик DEBUG для трасси
ровки этих примеров.
Повышение производительности. При делении на степень
числа 2 (2, 4, и т.д.) более эффективным является сдвиг
вправо на требуемое число битов. В следующих примерах
предположим, что делимое находится в регистре AX:

        Деление на 2:       SHR  AX,1
        Деление на 8:       MOV  CL,3
                            SHR  AX,CL

Переполнения и прерывания

Используя команды DIV и особенно IDIV, очень просто
вызвать пеpеполнение. Прерывания приводят (по крайней мара
в системе, используемой при тестировании этих программ) к
непредсказуемым результатам. В операциях деления предполага
ется, что частное значительно меньше, чем делимое. Деление
на ноль всегда вызывает прерывание. Но деление на 1
генерирует частное, которое равно делимому, что может также
легко вызвать прерывание.
Рекомендуется использовать следующее правило: если
делитель – байт, то его значение должно быть меньше, чем
левый байт (AH) делителя: если делитель – слово, то его
значение должно быть меньше, чем левое слово (DX) делителя.
Проиллюстрируем данное правило для делителя, равного 1:

 Операция деления:          Делимое   Делитель  Частное
 Слово на байт:                0123        01     (1)23
 Двойное слово на слово:  0001 4026      0001   (1)4026

В обоих случаях частное превышает возможный размер. Для того
чтобы избежать подобных ситуаций, полезно вставлять перед
командами DIV и IDIV соответствующую проверку. В первом из
следующих примеpов предположим, что DIVBYTE – однобайтовый
делитель, а делимое находится уже в регистре AX. Во втором
примере предположим, что DIVWORD – двухбайтовый делитель, а
делимое находится в регистровой паре DX:AX.

        Слово на байт            Двойное слово на байт
        CMP  AH,DIVBYTE          CMP  DX,DIVWORD
        JNB  переполнение        JNB  переполнение
        DIV  DIVBYTE             DIV  DIVWORD

Для команды IDIV данная логика должна учитывать тот факт,
что либо делимое, либо делитель могут быть отрицательными, а
так как сравниваются абсолютные значения, то необходимо
использовать команду NEG для временного перевода
отрицательного значения в положительное.

Деление вычитанием

Если частное слишком велико, то деление можно выполнить с
помощью циклического вычитания. Метод заключается в том, что
делитель вычитается из делимого и в этом же цикле частное
увеличивается на 1. Вычитание продолжается, пока делимое
остается больше делителя. В cледующем примере, делитель
находится в регистре AX, а делимое – в BX, частное
вырабатывается в CX:

        SUB  CX,CX     ;Очистка частного
   C20: CMP  AX,BX     ;Если делимое < делителя,
        JB   C30       ;  то выйти
        SUB  AX,BX     ;Вычитание делителя из делимого
        INC  CX        ;Инкремент частного
        JMP  C20       ;Повторить цикл
   С30: RET            ;Частное в CX, остаток в AX

В конце подпрограммы регистр CX будет содержать частное,
а AX – oстаток. Пример умышленно примитивен для демонстрации
данной техники деления. Если частное получается в регистро
вой паре DX:AX, то необходимо сделать два дополнения:

  1. В метке C20 сравнивать AX и BX только при нулевом DX.

  2. После команды SUB вставить команду SBB DX,00.

Примечание: очень большое частное и малый делитель могут
вызвать тысячи циклов.

Преобразование знака

Команда NEG обеспечивает преобразование знака двоичных
чисел из положительного в отрицательное и наоборот.
Практически команда NEG устанавливает противоположные
значения битов и прибавляет 1. Примеры:

             NEG  AX
             NEG  BL
             NEG  BINAMT    (байт или слово в памяти)

Преобразование знака для 35-битового (или большего) числа
включает больше шагов. Предположим, что регистровая пара
DX:AX содержит 32-битовое двоичное число. Так как команда
NEG не может обрабатывать два регистра одновременно, то ее
использование приведет к неправильному результату. В следую
щем примере показано использование команды NOT:

             NOT  DX   ;Инвертирование битов
             NOT  AX   ;Инвертирование битов
             ADD  AX,1 ;Прибавление 1 к AX
             ADC  DX,0 ;Прибавление переноса к DX

Остается одна незначительная проблема: над числами,
представленными в двоичном формате, удобно выполнять арифме
тические операции, если сами числа определены в программе.
Данные, вводимые в программу с дискового файла, могут также
иметь двоичный формат. Но данные, вводимые с клавиатуры,
представленны в ASCII-формате. Хотя ASCII-коды удобны для
отображения и печати, они требуют специальных преобразований
в двоичный формат для арифметических вычислений. Но это уже
тема следующей главы.

Математические сопроцессоры (описание)

Системная плата компьютера содержит пустое гнездо,
зарезервированное для числового процессора Intel 8087 (или
80287). Сопроцессор 8087 действует совместно с 8088, а сопро
цессор 80287 действует совместно с 80286. Каждый сопроцессор
имеет собственный набор команд и средства для операций с
плавающей запятой для выполнения экспоненциальных,
логарифмических и тригонометрических функций. Сопроцессор
содержит восемь 80-битовых регистров с плавающей запятой,
которые могут представить числовые значения до 10 в 400 сте
пени. Математические вычисления в сопроцессоре выполняются
примерно в 100 раз быстрее, чем в основном процессоре.
Основной процессор выполняет специальные операции и
передает числовые данные в сопроцессор, который выполняет
необходимые вычисления и возвращает результат. Для ассембли
рования с помощью транслятора MASM, необходимо добавлять
параметр /E или /R, например, MASM /R.

Добавить комментарий