Как написать процедуру на ассемблере

 

Процедура (подпрограмма) — это основная функциональная единица декомпозиции (разделения на несколько частей) некоторой задачи. Процедура представляет собой группу команд для решения конкретной подзадачи и обладает средствами получения управления из точки вызова задачи более высокого приоритета и возврата управления в эту точку. В простейшем случае программа может состоять из одной процедуры. Процедуру можно определить и как правильным образом оформленную совокупность команд, которая, будучи однократно описана, при необходимости может быть вызвана в любом месте программы.
Функция – процедура, способная возвращать некоторое значение.

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

Для описания последовательности команд в виде процедуры в языке ассемблера используются две директивы: PROC и ENDP.

Синтаксис описания процедуры:

ИмяПроцедуры PROC язык расстояние
; тело процедуры
ИмяПроцедуры ENDP

В заголовке процедуры (директиве PROC) обязательным является только задание имени процедуры. Атрибут расстояние может принимать значения near или far и характеризует возможность обращения к процедуре из другого сегмента кода. По умолчанию атрибут расстояние принимает значение near, и именно это значение используется при выборе плоской модели памяти FLAT.

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

  • в начале программы (до первой исполняемой команды);
  • в конце (после команды, возвращающей управление операционной системе);
  • промежуточный вариант — тело процедуры располагается внутри другой процедуры или основной программы;
  • в другом модуле.

Размещение процедуры в начале сегмента кода предполагает, что последовательность команд, ограниченная парой директив PROC и ENDP, будет размещена до метки, обозначающей первую команду, с которой начинается выполнение программы. Эта метка должна быть указана как параметр директивы END, обозначающей конец программы:


.code
myproc proc near
ret
myproc endp
start proc
call myproc

start endp
end start

В этом фрагменте после загрузки программы в память управление будет передано первой команде процедуры с именем start.
Объявление имени процедуры в программе равнозначно объявлению метки, поэтому директиву PROC в частном случае можно рассматривать как форму определения метки в программе.

Размещение процедуры в конце программы предполагает, что последовательность команд, ограниченная директивами PROC и ENDP, будет размещена после команды, возвращающей управление операционной системе.


.code
start proc
call myproc

start endp
myproc proc near
ret
myproc endp
end start

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


.code
start proc
jmp ml
myproc proc near
ret
myproc endp
ml:

start endp
end start

Последний вариант расположения описаний процедур — в отдельном модуле — предполагает, что часто используемые процедуры выносятся в отдельный файл. Файл с процедурами должен быть оформлен как обычный исходный файл и подвергнут трансляции для получения объектного кода. Впоследствии этот объектный файл на этапе компоновки объединяется с файлом, в котором эти процедуры используются. Этот способ предполагает наличие в исходном тексте программы еще некоторых элементов, связанных с особенностями реализации концепции модульного программирования в языке ассемблера. Вариант расположения процедур в отдельном модуле используется также при построении Windows-приложений на основе вызова API-функций.

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

  • call ИмяПроцедуры@num — вызов процедуры (подпрограммы).
  • ret число — возврат управления вызывающей программе.

число — необязательный параметр, обозначающий количество байт, удаляемых из стека при возврате из процедуры.

@num – количество байт, которое занимают в стеке переданные аргументы для процедуры (параметр является особенностью использования транслятора MASM).

Объединение процедур, расположенных в разных модулях

Особого внимания заслуживает вопрос размещения процедуры в другом модуле. Так как отдельный модуль — это функционально автономный объект, то он ничего не знает о внутреннем устройстве других модулей, и наоборот, другим модулям также ничего не известно о внутреннем устройстве данного модуля. Но каждый модуль должен иметь такие средства, с помощью которых он извещал бы транслятор о том, что некоторый объект (процедура, переменная) должен быть видимым вне этого модуля. И наоборот, нужно объяснить транслятору, что некоторый объект находится вне данного модуля. Это позволит транслятору правильно сформировать машинные команды, оставив некоторые их поля незаполненными. Позднее, на этапе компоновки настраивает модули и разрешает все внешние ссылки в объединяемых модулях.

Для того чтобы объявить о видимых извне объектах, программа должна использовать две директивы MASM: extern и public. Директива extern предназначена для объявления некоторого имени внешним по отношению к данному модулю. Это имя в другом модуле должно быть объявлено в директиве public. Директива public предназначена для объявления некоторого имени, определенного в этом модуле и видимого в других модулях. Синтаксис этих директив следующий:

extern имя:тип, …, имя:тип
public имя, …, имя

Здесь имя — идентификатор, определенный в другом модуле. В качестве идентификатора могут выступать:

  • имена переменных;
  • имена процедур;
  • имена констант.

Тип определяет тип идентификатора. Указание типа необходимо для того, чтобы транслятор правильно сформировал соответствующую машинную команду. Действительные адреса будут вычислены на этапе компоновки, когда будут разрешаться внешние ссылки. Возможные значения типа определяются допустимыми типами объектов для этих директив:

  • если имя — это имя переменной, то тип может принимать значения byte, word, dword, qword и tbyte;
  • если имя — это имя процедуры, то тип может принимать значения near или far; в компиляторе MASM после имени процедуры необходимо указывать число байтов в стеке, которые занимают аргументы функции:

    extern p1@0:near

  • если имя — это имя константы, то тип должен быть abs.

Пример использования директив extern и public для двух модулей

;Модуль 1
.586
.model flat, stdcall
.data
extern p1@0:near
.code
start proc
call p1@0
ret
start endp
end start

;Модуль 2
.586
.model flat, stdcall
public p1
.data
.code
p1 proc
ret
p1 endp
end

Исполняемый модуль находится в программе Модуль 1, поскольку содержит метку start, с которой начинается выполнение программы (эта метка указана после директивы end в программе Модуль 1). Программа вызывает процедуру p1, внешнюю, содержащуюся в файле Модуль 2. Процедура p1 не имеет аргументов, поэтому описывается в программе Модуль 1 с помощью директивы

extern p1@0:near

@0 – количество байт, переданных функции в качестве аргументов
near – тип функции (для плоской модели памяти всегда имеет тип near).

Вызов процедуры осуществляется командой

call p1@0

Организация интерфейса с процедурой

Аргумент — это ссылка на некоторые данные, которые требуются для выполнения возложенных на модуль функций и размещенных вне этого модуля. По аналогии с макрокомандами рассматривают понятия формального и фактического аргументов. Исходя из этого, формальный аргумент можно рассматривать не как непосредственные данные или их адрес, а как местодержатель для действительных данных, которые будут подставлены в него с помощью фактического аргумента. Формальный аргумент можно рассматривать как элемент интерфейса модуля, а фактический аргумент — это то, что фактически передается на место формального аргумента.

Переменные — это данные размещенные в регистре или ячейке памяти, которые могут в дальнейшем подвергаться изменению.

Константы — данные, значения которых не могут изменяться.

Сигнатура процедуры (функции) — это имя функции, тип возвращаемого значения и список аргументов с указанием порядка их следования и типов.

Семантика процедуры (функции) — это описание того, что данная функция делает. Семантика функции включает в себя описание того, что является результатом вычисления функции, как и от чего этот результат зависит. Обычно результат выполнения зависит только от значений аргументов функции, но в некоторых модулях есть понятие состояния. Тогда результат функции может зависеть от этого состояния, и, кроме того, результатом может стать изменение состояния. Логика этих зависимостей и изменений относится к семантике функции. Полным описанием семантики функций является исполняемый код функции или математическое определение функции.

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

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

  • через регистры;
  • через общую область памяти;
  • через стек;
  • с помощью директив extern и public.

Передача аргументов через регистры – это наиболее простой в реализации способ передачи данных. Данные, переданные подобным способом, становятся доступными немедленно после передачи управления процедуре. Этот способ очень популярен при небольшом объеме передаваемых данных.

Ограничения на способ передачи аргументов через регистры:

  • небольшое число доступных для пользователя регистров;
  • нужно постоянно помнить о том, какая информация в каком регистре находится;
  • ограничение размера передаваемых данных размерами регистра. Если размер данных превышает 8, 16 или 32 бита, то передачу данных посредством регистров произвести нельзя. В этом случае передавать нужно не сами данные, а указатели на них.

Передача аргументов через общую область памяти – предполагает, что вызывающая и вызываемая программы используют некоторую область памяти как общую. Для организации такой области памяти используется атрибут комбинирования сегментов common. Наличие этого атрибута указывает компоновщику, как нужно комбинировать сегменты, имеющие одно имя: все сегменты, имеющие одинаковое имя в объединяемых модулях, будут располагаться компоновщиком, начиная с одного адреса оперативной памяти. Это значит, что они будут перекрываться в памяти и, следовательно, совместно использовать выделенную память. Данные в сегментах common могут иметь одинаковые имена. Главное – структура общих сегментов. Она должна быть идентична во всех модулях использующих обмен данными через общую память.
Недостатком этого способа в реальном режиме работы микропроцессора является отсутствие средств защиты данных от разрушения, так как нельзя проконтролировать соблюдение правил доступа к этим данным.

Передача аргументов через стек наиболее часто используется для передачи аргументов при вызове процедур. Суть этого способа заключается в том, что вызывающая процедура самостоятельно заносит в стек передаваемые данные, после чего передает управление вызываемой процедуре. При передаче управления процедуре микропроцессор автоматически записывает в вершину стека 4 байта. Эти байты являются адресом возврата в вызывающую программу. Если перед передачей управления процедуре командой call в стек были записаны переданные процедуре данные или указатели на них, то они окажутся под адресом возврата.
Стек обслуживается тремя регистрами:

  • SS — указатель дна стека (начала сегмента стека);
  • ESP — указатель вершины стека;
  • EBP — указатель базы.

Микропроцессор автоматически работает с регистрами ESS и ESP в предположении, что они всегда указывают на дно и вершину стека соответственно. По этой причине их содержимое изменять не рекомендуется. Для осуществления произвольного доступа к данным в стеке архитектура микропроцессора имеет специальный регистр EBP. Так же, как и для регистра ESP, использование EBP автоматически предполагает работу с сегментом стека.
Перед использованием этого регистра для доступа к данным стека его содержимое необходимо правильно инициализировать, что предполагает формирование в нем адреса, который бы указывал непосредственно на переданные данные. Для этого в начало процедуры рекомендуется включить дополнительный фрагмент кода. Он имеет свое название — пролог процедуры. Код пролога состоит всего из двух команд:

push ebp
mov ebp, esp

Первая команда сохраняет содержимое ebр в стеке с тем, чтобы исключить порчу находящегося в нем значения в вызываемой процедуре. Вторая команда пролога настраивает ebp на вершину стека. После этого можно не волноваться о том, что содержимое esp перестанет быть актуальным, и осуществлять прямой доступ к содержимому стека.

Конец процедуры также должен содержать действия, обеспечивающие корректный возврат из процедуры. Фрагмент кода, выполняющего такие действия, имеет свое название — эпилог процедуры. Код эпилога должен восстановить контекст программы в точке вызова процедуры из вызывающей программы. При этом, в частности, нужно откорректировать содержимое стека, убрав из него ставшие ненужными аргументы, передававшиеся в процедуру. Это можно сделать несколькими способами:

  • используя последовательность из n команд pop xx. Лучше всего это делать в вызывающей программе сразу после возврата управления из процедуры;
  • откорректировать регистр указателя стека esp на величину 4*n, например, командой

    add esp,NN

    где NN=4*n (n — количество аргументов). Это также лучше делать после возврата управления вызывающей процедуре;

  • используя машинную команду ret n в качестве последней исполняемой команды в процедуре, где n — количество байт, на которое нужно увеличить содержимое регистра esp после того, как со стека будут сняты составляющие адреса возврата. Этот способ аналогичен предыдущему, но выполняется автоматически микропроцессором.

Программа, содержащая вызов процедуры с передачей аргументов через стек:

.586
.model flat, stdcall
.stack 4096
.data
.code
proc_1 proc        ; начало процедуры
push ebp           ; пролог: сохранение EBP
mov ebp, esp       ; пролог: инициализация EBP
mov eax, [ebp+8]   ; доступ к аргументу 4
mov ebx, [ebp+12] ; доступ к аргументу 3
mov ecx, [ebp+16] ; доступ к аргументу 2
pop ebp            ; эпилог: восстановление EBP
ret 12
proc_1 endp
main proc
push 2
push 3
push 4
call proc_1
ret
main endp
end main

Стек программы

Для доступа к аргументу 4 достаточно сместиться от содержимого ebр на 8 байт (4 байта хранят адрес возврата в вызывающую процедуру, и еще 4 байта хранят значение регистра ebр, помещенное в стек данной процедурой), для аргумента 3 — на 12 и т. д.

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

  • Команда enter подготавливает стек для обращения к аргументов, имеет 2 операнда :
    первый определяет количество байт в стеке, используемых для хранения локальных идентификаторов процедуры;
    второй определяет уровень вложенности процедуры.
  • Команда leave подготавливает стек к возврату из процедуры, не имеет операндов.

proc_1 proc
enter 0,0
mov eax, [ebp+8]
mov ebx, [ebp+12]
mov ecx, [ebp+16]
leave
ret 12
proc_1 endp

Передача аргументов с помощью директив extern и public используется в случаях, если

  • оба модуля используют сегмент данных вызывающей программы;
  • у каждого модуля есть свой собственный сегмент данных;
  • модули используют атрибут комбинирования сегментов public в директиве сегментации segment.

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

;Модуль 1
.586
.model flat, stdcall
.data
STR1    DB «Программа»,0
STR2    db «Меня зовут Лена «,0
extern p1@0:near
public STR1, STR2
.code
start proc
call p1@0
ret
start endp
end start

;Модуль 2
.586
.model flat, stdcall
public p1
extern STR1:BYTE, STR2:BYTE
EXTERN MessageBoxA@16:NEAR

.code
p1 proc
PUSH     0
PUSH     OFFSET STR1
PUSH     OFFSET STR2
PUSH     0
CALL     MessageBoxA@16
ret
p1 endp
end

Способы передачи аргументов в процедуру

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

Возврат результата из процедуры

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

  • С использованием регистров. Ограничения здесь те же, что и при передаче данных, — это небольшое количество доступных регистров и их фиксированный размер. Данный способ является наиболее быстрым, поэтому его есть смысл использовать для организации критичных по времени вызова процедур.
  • С использованием общей области памяти. Этот способ удобен при возврате большого количества данных, но требует внимательности в определении областей данных и подробного документирования для устранения неоднозначностей.
  • С использованием стека. Здесь, подобно передаче аргументов через стек, также нужно использовать регистр ebр. При этом возможны следующие варианты:
    — использование для возвращаемых аргументов тех же ячеек в стеке, которые применялись для передачи аргументов в процедуру. То есть предполагается замещение ставших ненужными входных аргументов выходными данными;
    — предварительное помещение в стек наряду с передаваемыми аргументами фиктивных аргументов с целью резервирования места для возвращаемого значения. При использовании этого варианта процедура, конечно же, не должна пытаться очистить стек командой ret. Эту операцию придется делать в вызывающей программе, например командой pop.

Назад: Язык ассемблера

Статья основана на материале xrnd с сайта asmworld (из учебного курса по программированию на ассемблер 16-битного процессора 8086 под DOS).

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

Команды CALL и RET

Для работы с процедурами предназначены команды CALL и RET. С помощью команды CALL выполняется вызов процедуры. Эта команда работает почти также, как команда безусловного перехода (JMP), но с одним отличием — одновременно в стек сохраняется текущее значение регистра IP. Это позволяет потом вернуться к тому месту в коде, откуда была вызвана процедура. В качестве операнда указывается адрес перехода, который может быть непосредственным значением (меткой), 16-разрядным регистром (кроме сегментных) или ячейкой памяти, содержащей адрес.

Возврат из процедуры выполняется командой RET. Эта команда восстанавливает значение из вершины стека в регистр IP. Таким образом, выполнение программы продолжается с команды, следующей сразу после команды CALL. Обычно код процедуры заканчивается этой командой. Команды CALL и RET не изменяют значения флагов (кроме некоторых особых случаев в защищенном режиме). Небольшой пример разных способов вызова процедуры:

use16                   ;Генерировать 16-битный код
org 100h                ;Программа начинается с адреса 100h
 
    mov ax,myproc
    mov bx,myproc_addr
    xor si,si
 
    call myproc         ;Вызов процедуры (адрес перехода - myproc)
    call ax             ;Вызов процедуры по адресу в AX
    call [myproc_addr]  ;Вызов процедуры по адресу в переменной
    call word [bx+si]   ;Более сложный способ задания адреса ;)
 
    mov ax,4C00h        ;
    int 21h             ;/ Завершение программы
 
;----------------------------------------------------------------------
;Процедура, которая ничего не делает
myproc:
    nop                 ;Код процедуры
    ret                 ;Возврат из процедуры
;----------------------------------------------------------------------
myproc_addr dw myproc   ;Переменная с адресом процедуры

Ближние и дальние вызовы процедур

Существует 2 типа вызовов процедур. Ближним называется вызов процедуры, которая находится в текущем сегменте кода. Дальний вызов — это вызов процедуры в другом сегменте. Соответственно существуют 2 вида команды RET — для ближнего и дальнего возврата. Компилятор FASM автоматически определяет нужный тип машинной команды, поэтому в большинстве случаев не нужно об этом беспокоиться.

В учебном курсе мы будем использовать только ближние вызовы процедур.

Передача параметров

Очень часто возникает необходимость передать процедуре какие-либо параметры. Например, если вы пишете процедуру для вычисления суммы элементов массива, удобно в качестве параметров передавать ей адрес массива и его размер. В таком случае одну и ту же процедуру можно будет использовать для разных массивов в вашей программе. Самый простой способ передать параметры — это поместить их в регистры перед вызовом процедуры.

Возвращаемое значение

Кроме передачи параметров часто нужно получить какое-то значение из процедуры. Например, если процедура что-то вычисляет, хотелось бы получить результат вычисления. А если процедура что-то делает, то полезно узнать, завершилось действие успешно или возникла ошибка. Существуют разные способы возврата значения из процедуры, но самый часто используемый — это поместить значение в один из регистров. Обычно для этой цели используют регистры AL и AX. Хотя вы можете делать так, как вам больше нравится.

Сохранение регистров

Хорошим приёмом является сохранение регистров, которые процедура изменяет в ходе своего выполнения. Это позволяет вызывать процедуру из любой части кода и не беспокоиться, что значения в регистрах будут испорчены. Обычно регистры сохраняются в стеке с помощью команды PUSH, а перед возвратом из процедуры восстанавливаются командой POP. Естественно, восстанавливать их надо в обратном порядке. Примерно вот так:

myproc:
    push bx             ;Сохранение регистров
    push cx
    push si
    ...                 ;Код процедуры
    pop si              ;Восстановление регистров
    pop cx
    pop bx
    ret                 ;Возврат из процедуры

Пример

Для примера напишем процедуру для вывода собщения в рамке и протестируем её работу, выведя несколько сообщений. В качестве параметра ей будет передаватся адрес строки в регистре BX. Строка должна заканчиваться символом ‘$’. Для упрощения процедуры можно разбить задачу на подзадачи и написать соответствующие процедуры. Прежде всего нужно вычислить длину строки, чтобы знать ширину рамки. Процедура get_length вычисляет длину строки (адрес передаётся также в BX) и возвращает её в регистре AX.

Для рисования горизонтальной линии из символов предназначена процедура draw_line. В DL передаётся код символа, а в CX — количество символов, которое необходимо вывести на экран. Эта процедура не возвращает никакого значения. Для вывода 2-х символов конца строки написана процедура print_endline. Она вызывается без параметров и тоже не возвращает никакого значения. Коды символов для рисования рамок можно узнать с помощью таблицы символов кодировки 866 или можно воспользоваться стандартной программой Windows «Таблица символов», выбрав шрифт Terminal.

use16                   ;Генерировать 16-битный код
org 100h                ;Программа начинается с адреса 100h
    jmp start           ;Переход на метку start
;----------------------------------------------------------------------
msg1    db 'Hello!$'
msg2    db 'asmworld.ru$'
msg3    db 'Press any key...$'
;----------------------------------------------------------------------
start:
    mov bx,msg1
    call print_message  ;Вывод первого сообщения
    mov bx,msg2
    call print_message  ;Вывод второго сообщения
    mov bx,msg3
    call print_message  ;Вывод третьего сообщения
 
    mov ah,8            ;Ввод символа без эха
    int 21h
 
    mov ax,4C00h        ;
    int 21h             ;/ Завершение программы
 
;----------------------------------------------------------------------
;Процедура вывода сообщения в рамке
;В BX передаётся адрес строки
print_message:
    push ax             ;Сохранение регистров
    push cx
    push dx
 
    call get_length     ;Вызов процедуры вычисления длины строки
    mov cx,ax           ;Копируем длину строки в CX
    mov ah,2            ;Функция DOS 02h - вывод символа
    mov dl,0xDA         ;Левый верхний угол
    int 21h
    mov dl,0xC4         ;Горизонтальная линия
    call draw_line      ;Вызов процедуры рисования линии
    mov dl,0xBF         ;Правый верхний угол
    int 21h
    call print_endline  ;Вызов процедуры вывода конца строки
 
    mov dl,0xB3         ;Вертикальная линия
    int 21h
    mov ah,9            ;Функция DOS 09h - вывод строки
    mov dx,bx           ;Адрес строки в DX
    int 21h
    mov ah,2            ;Функция DOS 02h - вывод символа
    mov dl,0xB3         ;Вертикальная линия
    int 21h
    call print_endline  ;Вызов процедуры вывода конца строки
 
    mov dl,0xC0         ;Левый нижний угол
    int 21h
    mov dl,0xC4         ;Горизонтальная линия
    call draw_line
    mov dl,0xD9         ;Правый нижний угол
    int 21h
    call print_endline  ;Вызов процедуры вывода конца строки
 
    pop dx              ;Восстановление регистров
    pop cx
    pop ax
    ret                 ;Возврат из процедуры
 
;----------------------------------------------------------------------
;Процедура вычисления длины строки (конец строки - символ '$').
;В BX передаётся адрес строки.
;Возвращает длину строки в регистре AX.
get_length:
    push bx             ;Сохранение регистра BX
    xor ax,ax           ;Обнуление AX
str_loop:
    cmp byte[bx],'$'    ;Проверка конца строки
    je str_end          ;Если конец строки, то выход из процедуры
    inc ax              ;Инкремент длины строки
    inc bx              ;Инкремент адреса
    jmp str_loop        ;Переход к началу цикла
str_end:
    pop bx              ;Восстановление регистра BX
    ret                 ;Возврат из процедуры
 
;----------------------------------------------------------------------
;Процедура рисования линии из символов.
;В DL - символ, в CX - длина линии (кол-во символов)
draw_line:
    push ax             ;Сохранение регистров
    push cx
    mov ah,2            ;Функция DOS 02h - вывод символа
drl_loop:
    int 21h             ;Обращение к функции DOS
    loop drl_loop       ;Команда цикла
    pop cx              ;Восстановление регистров
    pop ax
    ret                 ;Возврат из процедуры
 
;----------------------------------------------------------------------
;Процедура вывода конца строки (CR+LF)
print_endline:
    push ax             ;Сохранение регистров
    push dx
    mov ah,2            ;Функция DOS 02h - вывод символа
    mov dl,13           ;Символ CR
    int 21h
    mov dl,10           ;Символ LF
    int 21h
    pop dx              ;Восстановление регистров
    pop ax
    ret                 ;Возврат из процедуры

Результат работы программы выглядит вот так:

Отладчик Turbo Debugger

Небольшое замечание по поводу использования отладчика. В Turbo Debugger нажимайте F7 («Trace into»), чтобы перейти к коду вызываемой процедуры. При нажатии F8(«Step over») процедура будет выполнена сразу целиком.

Упражнение

Объявите в программе 2-3 массива слов без знака. Количество элементов каждого массива должно быть разным и храниться в отдельной 16-битной переменной без знака. Напишите процедуру для вычисления среднего арифметического массива чисел. В качестве параметров ей будет передаваться адрес массива и количество элементов, а возвращать она будет вычисленное значение. С помощью процедуры вычислите среднее арифметическое каждого массива и сохраните где-нибудь в памяти. Выводить числа на экран не нужно, этим мы займемся в следующей части.

Процедуры в ассемблере

Процедуры в ассемблере.

Процедуры в ассемблере будут рассмотрены в четырёх статьях, в которых мы изучим общие понятия и определения процедур, использование стека для передачи параметров,  а также использование прерываний DOS — как разновидности функций ядра операционки (статьи 15-19: «Процедуры (функции)», «Стек», «Конвенции вызова функции», «Упрощаем вызов функции в TASM», «Прерывания DOS»).

Начнём изучать функции на примере нашей программы goblin.com. Сразу определимся, что понятия: процедура, функция, подпрограмма в языках программирования, включая ассемблер, являются синонимами и обозначают одно и то же. Именно в качестве равнозначных синонимов мы будем использовать эти названия.

Изучаем процедуры на примере goblin.com.

Пакет всего необходимого, включая исходники (DOS-1.rar) можно скачать с нашего сайта по ссылке.

Полный код нашей подопытной программы:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

48

49

50

51

52

53

54

55

56

57

58

59

;goblin.asm

.model tiny ; for СОМ

.code ; code segment start

org 100h ; offset in memory = 100h (for COM)

start: main proc

begin:

mov ah,09h

mov dx,offset prompt

int 21h

inpt:

mov ah,01h

int 21h

cmp al,‘m’

je mode_man

cmp al,‘w’

je mode_woman

call goblin

jmp begin

mode_man:

mov addrs,offset man; указатель на процедуру в addrs

jmp cont

mode_woman:

mov addrs,offset woman; указатель на процедуру в addrs

cont:

call word ptr addrs; косвенный вызов процедуры

mov ax,4c00h

int 21h

main endp

man proc

mov ah,09h

mov dx,offset mes_man

int 21h

ret

man endp

woman proc

mov ah,09h

mov dx,offset mes_womn

int 21h

ret

woman endp

goblin proc

mov ah,09h

mov dx,offset mes_gobl

int 21h

ret

goblin endp

;DATA

addrs dw 0;for procedure adress

prompt db ‘Are you Man or Woman [m/w]? : $’

mes_man db 0Dh,0Ah,«Hello, Strong Man!»,0Dh,0Ah,‘$’ ; строка для вывода. Вместо ASCII смвола ‘$’ можно написать машинный код 24h

mes_womn db 0Dh,0Ah,«Hello, Beautyful Woman!»,0Dh,0Ah,‘$’ ; строка для вывода

mes_gobl db 0Dh,0Ah,«Hello, Strong and Beautyful GOBLIN!»,0Dh,0Ah,24h ; строка для вывода. 24h = ‘$’ .

len = $ mes_gobl

end start

Goblin.com включает в себя несколько подпрограмм:

  • main proc
  • man proc
  • woman proc
  • goblin proc

Каждая подпрограмма имеет определённую задачу и, будучи написанной один раз может вызываться в процессе исполнения программы неоднократно. Процедуры в ассемблере не являются обязательным элементом программы, а просто повышают её наглядность (в Си и СРР это не так). Процедура упрощает код, делает его более структурированным, сокращают его размер.

Вызов процедуры в ассемблере.

Вызов процедуры в ассемблере осуществляется командой call (call — вызов). После вызова, процедура исполняет свой код и программа возвращается в точку возврата (выполняет дальнейший код, следующий за вызовом — командой call).

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

Процедура в ассемблере обозначается названием и начинается оператором proc (procedure — процедура). Заканчивается процедур оператором end proc (end procedure — конец процедуры) и командой ret (return — возврат). В главной функции (main в нашем случае) команда ret после выхода в систему DOS не обязательна.

имя_процедуры proc

тело процедуры (код)

ret
endp

Для удобной читаемости кода можно указывать имя (название) соответствующей процедуры также перед оператором endp.

имя_процедуры proc

тело процедуры (код)

ret
имя_процедуры endp

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

Работа с процедурами на примере программы goblin.com.

Процедуры man proc, woman proc, goblin proc идентичны (на примере woman proc):

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

woman proc

mov ah,09h

mov dx,offset mes_womn

int 21h

ret

woman endp

...

cmp al,‘m’

je mode_man

cmp al,‘w’

je mode_woman

call goblin

jmp begin

mode_man:

mov addrs,offset man; указатель на процедуру в addrs

jmp cont

mode_woman:

mov addrs,offset woman; указатель на процедуру в addrs

cont:

call word ptr addrs; косвенный вызов процедуры

...

addrs dw 0;для указателя на процедуру

Вызов процедур goblin — прямой:

Вызов процедур woman и man косвенный (через указатель):

...

mov addrs,offset man; указатель на процедуру в addrs

...

mov addrs,offset woman; указатель на процедуру в addrs

...

call word ptr addrs; косвенный вызов процедуры

В организации работы процедуры задействованы команды CALL, RET, а также стек.

CALL (CALL).

Вызов процедуры с запоминанием в стеке точки возврата:

call адрес перехода.

Примеры для CALL:

woman proc

mov ah,09h;аргумент для прерывания int 21h. 09h в ah — вывод на экран, адрес выводимой строки — в dx

mov dx,offset mes_womn; адрес выводимой строки

int 21h;вызов прерывания

ret;

woman endp

...

call woman; прямой вызов процедуры

mov addrs,offset woman; указатель на процедуру в addrs

call word ptr addrs; косвенный вызов процедуры

...

addrs dw 0; для указателя на процедуру

mes_womn db 0Dh,0Ah,«Hello, Beautyful Woman!»,0Dh,0Ah,‘$’ ; строка для вывода

RET (RETurn).

Возврат из процедуры обратно к месту вызова.

В действительности микропроцессор имеет три вида команды ret:

  • ret и retn — возврат из процедур ближнего типа
  • retf — возврат из процедур дальнего типа.

Выбор сделает транслятор, поэтому достаточно использовать ret.

Отдельные языки программирования требуют, чтобы процедура очищала стек от переданных параметров. Поэтому команда ret имеет необязательный параметр — число, который обозначает количество байт или слов (в зависимости от установленного атрибута размера адреса), удаляемых из стека по окончанию работы процедуры.

Примеры для RET:

woman proc

mov ah,09h;аргумент для прерывания int 21h. 09h в ah — вывод на экран, адрес выводимой строки — в dx

mov dx,offset mes_womn; адрес выводимой строки

int 21h;вызов прерывания

ret;

woman endp

my_proc proc

...

ret 8

my_proc endp

Необходимо отметить, что процедура может иметь параметры, через которые ей передаются данные, а также через которые она передаёт возвращаемые значения. Это может быть реализовано через регистры (аналогичный метод используют знакомые нам прерывания), а также через указатели на выделенные участки памяти (в нижеприведённом примере: mov dx,offset mes_womn; адрес выводимой строки).

Система передачи данных системных функций Windows, а также принятые способы реализации процедур современных операционок будут рассмотрены в цикле статей 32-битного и 64-битного программирования.

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

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

Команды CALL и RET

Для работы с процедурами предназначены команды CALL и RET. С помощью команды CALL выполняется вызов процедуры. Эта команда работает почти также, как команда безусловного перехода (JMP), но с одним отличием — одновременно в стек сохраняется текущее значение регистра IP. Это позволяет потом вернуться к тому месту в коде, откуда была вызвана процедура. В качестве операнда указывается адрес перехода, который может быть непосредственным значением (меткой), 16-разрядным регистром (кроме сегментных) или ячейкой памяти, содержащей адрес.

Возврат из процедуры выполняется командой RET. Эта команда восстанавливает значение из вершины стека в регистр IP. Таким образом, выполнение программы продолжается с команды, следующей сразу после команды CALL. Обычно код процедуры заканчивается этой командой. Команды CALL и RET не изменяют значения флагов (кроме некоторых особых случаев в защищенном режиме). Небольшой пример разных способов вызова процедуры:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
use16                   ;Генерировать 16-битный код
org 100h                ;Программа начинается с адреса 100h
 
    mov ax,myproc
    mov bx,myproc_addr
    xor si,si
 
    call myproc         ;Вызов процедуры (адрес перехода - myproc)
    call ax             ;Вызов процедуры по адресу в AX
    call [myproc_addr]  ;Вызов процедуры по адресу в переменной
    call word [bx+si]   ;Более сложный способ задания адреса ;)
 
    mov ax,4C00h        ;
    int 21h             ;/ Завершение программы
 
;----------------------------------------------------------------------
;Процедура, которая ничего не делает
myproc:
    nop                 ;Код процедуры
    ret                 ;Возврат из процедуры
;----------------------------------------------------------------------
myproc_addr dw myproc   ;Переменная с адресом процедуры

Ближние и дальние вызовы процедур

Существует 2 типа вызовов процедур. Ближним называется вызов процедуры, которая находится в текущем сегменте кода. Дальний вызов — это вызов процедуры в другом сегменте. Соответственно существуют 2 вида команды RET — для ближнего и дальнего возврата. Компилятор FASM автоматически определяет нужный тип машинной команды, поэтому в большинстве случаев не нужно об этом беспокоиться.

В учебном курсе мы будем использовать только ближние вызовы процедур.

Передача параметров

Очень часто возникает необходимость передать процедуре какие-либо параметры. Например, если вы пишете процедуру для вычисления суммы элементов массива, удобно в качестве параметров передавать ей адрес массива и его размер. В таком случае одну и ту же процедуру можно будет использовать для разных массивов в вашей программе. Самый простой способ передать параметры — это поместить их в регистры перед вызовом процедуры.

Возвращаемое значение

Кроме передачи параметров часто нужно получить какое-то значение из процедуры. Например, если процедура что-то вычисляет, хотелось бы получить результат вычисления 🙂 А если процедура что-то делает, то полезно узнать, завершилось действие успешно или возникла ошибка. Существуют разные способы возврата значения из процедуры, но самый часто используемый — это поместить значение в один из регистров. Обычно для этой цели используют регистры AL и AX. Хотя вы можете делать так, как вам больше нравится.

Сохранение регистров

Хорошим приёмом является сохранение регистров, которые процедура изменяет в ходе своего выполнения. Это позволяет вызывать процедуру из любой части кода и не беспокоиться, что значения в регистрах будут испорчены. Обычно регистры сохраняются в стеке с помощью команды PUSH, а перед возвратом из процедуры восстанавливаются командой POP. Естественно, восстанавливать их надо в обратном порядке. Примерно вот так:

myproc:
    push bx             ;Сохранение регистров
    push cx
    push si
    ...                 ;Код процедуры
    pop si              ;Восстановление регистров
    pop cx
    pop bx
    ret                 ;Возврат из процедуры

Пример

Для примера напишем процедуру для вывода собщения в рамке и протестируем её работу, выведя несколько сообщений. В качестве параметра ей будет передаватся адрес строки в регистре BX. Строка должна заканчиваться символом ‘$’. Для упрощения процедуры можно разбить задачу на подзадачи и написать соответствующие процедуры. Прежде всего нужно вычислить длину строки, чтобы знать ширину рамки. Процедура get_length вычисляет длину строки (адрес передаётся также в BX) и возвращает её в регистре AX.

Для рисования горизонтальной линии из символов предназначена процедура draw_line. В DL передаётся код символа, а в CX — количество символов, которое необходимо вывести на экран. Эта процедура не возвращает никакого значения. Для вывода 2-х символов конца строки написана процедура print_endline. Она вызывается без параметров и тоже не возвращает никакого значения. Коды символов для рисования рамок можно узнать с помощью таблицы символов кодировки 866 или можно воспользоваться стандартной программой Windows «Таблица символов», выбрав шрифт Terminal.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
use16                   ;Генерировать 16-битный код
org 100h                ;Программа начинается с адреса 100h
    jmp start           ;Переход на метку start
;----------------------------------------------------------------------
msg1    db 'Hello!$'
msg2    db 'asmworld.ru$'
msg3    db 'Press any key...$'
;----------------------------------------------------------------------
start:
    mov bx,msg1
    call print_message  ;Вывод первого сообщения
    mov bx,msg2
    call print_message  ;Вывод второго сообщения
    mov bx,msg3
    call print_message  ;Вывод третьего сообщения
 
    mov ah,8            ;Ввод символа без эха
    int 21h
 
    mov ax,4C00h        ;
    int 21h             ;/ Завершение программы
 
;----------------------------------------------------------------------
;Процедура вывода сообщения в рамке
;В BX передаётся адрес строки
print_message:
    push ax             ;Сохранение регистров
    push cx
    push dx
 
    call get_length     ;Вызов процедуры вычисления длины строки
    mov cx,ax           ;Копируем длину строки в CX
    mov ah,2            ;Функция DOS 02h - вывод символа
    mov dl,0xDA         ;Левый верхний угол
    int 21h
    mov dl,0xC4         ;Горизонтальная линия
    call draw_line      ;Вызов процедуры рисования линии
    mov dl,0xBF         ;Правый верхний угол
    int 21h
    call print_endline  ;Вызов процедуры вывода конца строки
 
    mov dl,0xB3         ;Вертикальная линия
    int 21h
    mov ah,9            ;Функция DOS 09h - вывод строки
    mov dx,bx           ;Адрес строки в DX
    int 21h
    mov ah,2            ;Функция DOS 02h - вывод символа
    mov dl,0xB3         ;Вертикальная линия
    int 21h
    call print_endline  ;Вызов процедуры вывода конца строки
 
    mov dl,0xC0         ;Левый нижний угол
    int 21h
    mov dl,0xC4         ;Горизонтальная линия
    call draw_line
    mov dl,0xD9         ;Правый нижний угол
    int 21h
    call print_endline  ;Вызов процедуры вывода конца строки
 
    pop dx              ;Восстановление регистров
    pop cx
    pop ax
    ret                 ;Возврат из процедуры
 
;----------------------------------------------------------------------
;Процедура вычисления длины строки (конец строки - символ '$').
;В BX передаётся адрес строки.
;Возвращает длину строки в регистре AX.
get_length:
    push bx             ;Сохранение регистра BX
    xor ax,ax           ;Обнуление AX
str_loop:
    cmp byte[bx],'$'    ;Проверка конца строки
    je str_end          ;Если конец строки, то выход из процедуры
    inc ax              ;Инкремент длины строки
    inc bx              ;Инкремент адреса
    jmp str_loop        ;Переход к началу цикла
str_end:
    pop bx              ;Восстановление регистра BX
    ret                 ;Возврат из процедуры
 
;----------------------------------------------------------------------
;Процедура рисования линии из символов.
;В DL - символ, в CX - длина линии (кол-во символов)
draw_line:
    push ax             ;Сохранение регистров
    push cx
    mov ah,2            ;Функция DOS 02h - вывод символа
drl_loop:
    int 21h             ;Обращение к функции DOS
    loop drl_loop       ;Команда цикла
    pop cx              ;Восстановление регистров
    pop ax
    ret                 ;Возврат из процедуры
 
;----------------------------------------------------------------------
;Процедура вывода конца строки (CR+LF)
print_endline:
    push ax             ;Сохранение регистров
    push dx
    mov ah,2            ;Функция DOS 02h - вывод символа
    mov dl,13           ;Символ CR
    int 21h
    mov dl,10           ;Символ LF
    int 21h
    pop dx              ;Восстановление регистров
    pop ax
    ret                 ;Возврат из процедуры

Результат работы программы выглядит вот так:

Отладчик Turbo Debugger

Небольшое замечание по поводу использования отладчика. В Turbo Debugger нажимайте F7 («Trace into»), чтобы перейти к коду вызываемой процедуры. При нажатии F8 («Step over») процедура будет выполнена сразу целиком.

Упражнение

Объявите в программе 2-3 массива слов без знака. Количество элементов каждого массива должно быть разным и храниться в отдельной 16-битной переменной без знака. Напишите процедуру для вычисления среднего арифметического массива чисел. В качестве параметров ей будет передаваться адрес массива и количество элементов, а возвращать она будет вычисленное значение. С помощью процедуры вычислите среднее арифметическое каждого массива и сохраните где-нибудь в памяти. Выводить числа на экран не нужно, этим мы займемся в следующей части 🙂 Результаты можете писать в комментариях.

Следующая часть »

Процедуры

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

-процедуры;

-макроподстановки (макроассемблер);

-генерация и обработка программных
прерываний.

В данной главе рассматриваются только
основные понятия, относящиеся к вызову
процедур. Ввиду важности этого вопроса
мы продолжим его изучение в главе 15 в
контексте темы модульного программирования
на ассемблере. Актуальная для
программирования под Windows проблема
разработки библиотек DLL на ассемблере
описана в [8]. Макроассемблеру посвящена
глава 14.

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

Для описания последовательности команд
в виде процедуры в языке ассемблера
используются две директивы: PROC и ENDP.

Среди большого количества операндов
директивы PROC следует особо выделить
[расстояние]. Этот атрибут может принимать
значения NEAR или FAR и характеризует
возможность обращения к процедуре из
другого сегмента кода. По умолчанию
атрибут [расстояние] принимает значение
NEAR.

Процедура может размещаться в любом
месте программы, но так, чтобы на нее
случайным образом не попало управление.
Если процедуру просто вставить в общий
поток команд, то процессор воспримет
команды процедуры как часть этого потока
и, соответственно, начнет выполнять эти
команды. Учитывая это обстоятельство,
есть следующие варианты размещения
процедуры в программе: в начале программы
(до первой исполняемой команды); в конце
программы (после команды, возвращающей
управление операционной системе);промежуточный
вариант — внутри другой процедуры или
основной программы (в этом случае
необходимо предусмотреть обход процедуры
с помощью команды безусловного перехода
J М Р);в другом модуле (библиотеке DLL).

Размещение процедуры в начале сегмента
кода предполагает, что последовательность
команд, ограниченная парой директив
PROC и ENDP, будет размещена до метки,
обозначающей первую команду, с которой
начинается выполнение программы. Эта
метка должна быть указана как параметр
директивы END, обозначающей конец
программы:

model small

.stack 100h

.data

.code

my_proc procnear

ret

my_proc endp

start:

end start

Объявление имени процедуры в программе
равнозначно объявлению метки, поэтому
директиву PROC в частном случае можно
рассматривать как завуалированную
форму определения программной метки.
Поэтому сама исполняемая программа
также может быть оформлена в виде
процедуры, что довольно часто и делается
с целью пометить первую команду программы,
с которой должно начаться выполнение.
При этом не забывайте, что имя этой
процедуры нужно обязательно указывать
в заключительной директиве END. Такой
синтаксис мы уже неоднократно использовали
в своих программах. Так, последний
рассмотренный фрагмент эквивалентен
следующему:

model small

.stack 100h

.data

.code

my_proc procnear

ret

my_proc endp

start proc

start endp

end start

В этом фрагменте после загрузки программы
в память управление будет передано
первой команде процедуры с именем start.

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

model small

.stack 100h

.data

.code

start:

..

mov ax,4c00h

int 21h ;возврат управления операционной
системе

my_proc procnear

ret

my_proc endp

end start

Промежуточный вариант расположения
тела процедуры предполагает ее размещение
внутри другой процедуры или основной
программы. В этом случае необходимо
предусмотреть обход тела процедуры,
ограниченного директивами PROC и ENDP, с
помощью команды безусловного перехода
JМР:

model small

.stack 100h

.data

.code

start:

jmp ml

my_proc procnear

ret

my_proc endp

ml:

mov ax,4c00h

int 21h ;возврат управления операционной
системе

end start

Последний вариант расположения описаний
процедур — в отдельном сегменте кода
— предполагает, что часто используемые
процедуры выносятся в отдельный файл,
который должен быть оформлен как обычный
исходный файл и подвергнут трансляции
для получения объектного кода. Впоследствии
этот объектный файл с помощью утилиты
tlink можно объединить с файлом, в котором
данные процедуры используются. С утилитой
tlink мы познакомились в главе 6. Этот
способ предполагает наличие в исходном
тексте программы еще некоторых элементов,
связанных с особенностями реализации
концепции модульного программирования
в языке ассемблера. Поэтому в полном
объеме этот способ будет рассмотрен в
главе 15.

Как обратиться к процедуре? Так как имя
процедуры обладает теми же атрибутами,
что и обычная метка в команде перехода,
то обратиться к процедуре, в принципе,
можно с помощью любой команды перехода.
Но есть одно важное свойство, которое
можно использовать благодаря специальному
механизму вызова процедур. Суть состоит
в возможности сохранения информации о
контексте программы в точке вызова
процедуры. Под контекстом понимается
информация о состоянии программы в
точке вызова процедуры. В системе команд
процессора есть две команды для работы
с контекстом — CALL и RET.

Команда CALL осуществляет вызов процедуры
(подпрограммы). Синтаксис команды:

call [модификатор] имя_процедуры

Подобно команде JMP команда CALL передает
управление по адресу с символическим
именем имя_процедуры, но при этом в стеке
сохраняется адрес возврата (то есть
адрес команды, следующей после команды
CALL).

Команда RET считывает адрес возврата из
стека и загружает его в регистры CS и
EIP/IP, тем самым возвращая управление на
команду, следующую в программе за
командой CALL Синтаксис команды:

ret [число]

Необязательный параметр [число] обозначает
количество элементов, удаляемых из
стека при возврате из процедуры. Размер
элемента определяется хорошо знакомыми
нам параметрами директивы SEGMENT — use!6 и
use32 (или соответствующим параметром
упрощенных директив сегментации). Если
указанпараметр usel6, то [число] — это
значение в байтах; если use32 — в словах.

Для команды CALL, как и для JMP, актуальна
проблема организации ближних и дальних
переходов. Это видно из формата команды,
где присутствует параметр [модификатор].
Как и в случае команды JMP, вызов процедуры
командой CALL может быть внутрисегментным
и межсегментным.

При внутрисегментном вызове процедура
находится в текущем сегменте кода

(имеет тип near), и в качестве адреса
возврата команда CALL сохраняет только

содержимое регистра IP/EIP, что вполне
достаточно.

При межсегментном вызове процедура
находится в другом сегменте кода (имеет
тип far), и для осуществления возврата
команда CALL должна запомнить содержимое
обоих регистров (CS и IP/EIP), при этом в
стеке сначала запоминается содержимое
регистра CS, затем — регистра IP/EIP.

Важно отметить, что одна и та же процедура
не может быть одновременно процедурой
ближнего и дальнего типов. Таким образом,
если процедура используется в текущем
сегменте кода, но может вызываться и из
другого сегмента программы, то она
должна быть объявлена процедурой типа
far. Подобно команде JMP, существуют четыре
разновидности команды CALL. Какая именно
команда будет сформирована, зависит от
значения модификатора в команде вызова
процедуры CALL и атрибута дальности в
описании процедуры. Если процедура
описана в начале сегмента данных с
указанием дальности в ее заголовке, то
при ее вызове параметр [модификатор]
можно не указывать: транслятор сам
разберется, какую команду CALL ему нужно
сформировать. Если же процедура описана
после ее вызова, например, в конце
текущего сегмента или в другом сегменте,
то при ее вызове нужно указать ассемблеру
тип вызова, чтобы он мог за один проход
правильно сформировать команду CALL.
Значения модификатора такие же, как и
у команды ЗМР, за исключением значения
SHORT PTR.

С директивой PROC используются еще
несколько директив: ARG, RETURNS, LOCAL, USES. Их
назначение — помочь программисту
выполнить некоторые рутинные действия
при вызове и возврате из процедуры
(заодно и повысив надежность кода).
Директивы ARG и RETURNS назначают входным и
выходным параметрам процедуры,
передаваемым через стек, символические
имена. Директива USES в качестве параметров
содержит имена используемых в процедуре
регистров. При обработке этой директивы
ассемблер формирует входной и выходной
коды процедуры (из команд PUSH и POP),
обеспечивающие сохранение и восстановление
регистров. Директива LOCAL предназначена
для выделения кадра стека для локальных
переменных, что позволяет экономить
память, занимаемую программой в целом.
Подробно эти директивы обсуждаются в
главе 15.

Необходимо заметить, что в данном разделе
приведена информация о порядке описания
процедур, принятом в TASM. Описание и
использование процедур в MASM имеет
особенности, о которых можно узнать из
материала главы 15.

Последний и, наверное, самый важный
вопрос, возникающий при работе с
процедурами, как правильно передать
параметры процедуре и вернуть результат?
Этот вопрос тесно связан с концепцией
модульного программирования и подробно
будет рассматриваться в главе 15. С
примерами использования процедур вы
можете познакомиться в листингах
подпрограмм, предназначенных для
вычисления четырех основных арифметических
действий с двоичными и десятичными
(BCD) числами и находящихся среди прилагаемых
к книге файлов в каталоге главы 8. Кроме
того, вопросы организации рекурсивных
и вложенных процедур рассмотрены в [8].

Соседние файлы в папке Лекция 6

  • #
  • #

В этой небольшой статье познакомимся с процедурами в Assembler, так как их понимание и грамотное использование открывает большие возможности перед программистом. Пример программы будет направлен на то, чтобы вы лучше могли понять как их использовать.

Простые процедуры

Начнем с простой процедуры, которая складывает 2 числа:

.386
.model flat,stdcall
option casemap:none

.code
start:
mov ax,2
mov bx,3
call Summa ; вызов процедуры
ret

Summa proc ; описание процедуры
add ax,bx
ret
Summa endp

end start

Начало практически стандартное, кроме одной строки: «option casemap:none». Эта строка указывает Assembler, чтобы он различал регистры, так как по умолчанию он их не различает.

Далее перейдем к самому интересному, а именно процедуре Summa: как вы заметили, эта процедура описывается в самой последней части кода перед завершением программы и само описание завершается командой endp.

Думаю, саму процедуру пояснять нет смысла, но стоит пояснить, что в нашей программе встречается 2 слова ret. Так вот, тот ret, который входит в описание, выполнят лишь выход в основную программу, а ret в основной программе, как раз выходит уже из самой программы.
Также заметьте, что вызов процедуры Assembler производится с помощью команды Call.

Ну вроде бы все просто и легко, разобрались с процедурами, но все же это не так!
Поскольку мы с вами познаем все больше и больше, думаю стоит узнать, что все таки командой ret завершаться не всегда правильно, поэтому для этого существует своя процедура Assembler, которая помимо того, что завершает действие, еще и освобождает ресурсы. Эта процедура ExitProcess.
Процедура, которая принимает 1 параметр. А для того, чтобы использовать процедуры с параметром следует сначала задать прототип этой процедуры. Прототип задается с помощью слова «proto» , затем идут параметры.

.386
.model flat,stdcall
option casemap:none
includelib ..LIBkernel32.lib ; для процедуры нужно подключить библиотеку
ExitProcess proto :DWORD ; прототип с 1 параметром
.code
start:
mov ax,2
mov bx,3
call Summa
invoke ExitProcess,0  ; в этой строчке она вызывается вместо ret

Summa proc
add ax,bx
ret
Summa endp

end start

Прототип пишется ко всем процедурам с параметрами, и на самом деле, к нашей процедуре мы тоже могли бы использовать прототип с 2 параметрами. Это записалось бы следующим образом:

Summa proto :DWORD, :DWORD

Кстати, кто не знает, DWORD это всего лишь тип данных в Assembler, который занимает 4 байта, мы также могли использовать BYTE(1 байт) или WORD(2 байта).
А вызвать нашу процедуру могли бы так:

invoke Summa,2,3

Теперь, думаю, можем подвести итоги:

  • Процедуры с параметрами требуют объявления в самом начале (зарезервированное слово proto), описания(в самом коде) , их вызов производится с помощью слова invoke
  • Процедуры без параметров требуют описания(также в самом коде), их вызов можно производить с помощью команды Call

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

Скачать исходники

На этом я с вами прощаюсь, до следующей статьи!

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

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

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

В этой статье мы напишем с нуля калькулятор обратной польской записи (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.

Понравилась статья? Поделить с друзьями:
  • Как написать профильную математику на порог
  • Как написать прощание коллегам при увольнении
  • Как написать профиль персонажа
  • Как написать прощальный пост
  • Как написать прототип функции