Данное руководство содержит базовые сведения, необходимые при создании кода для процессоров Intel версии 80386 (и выше) с помощью ассемблера as [2] в системе Linux. Приложение содержит инструкции и модификации к программам-примерам для запуска их с использованием библиотек C вместо ядра Linux.
Различают два вида ассемблеров:Главные различия между этими двумя видами ассемблеров заключаются в порядке следования операндов в инструкциях, и специальных символах, используемых для обозначения констант, регистров, содержимого памяти, типов операндов ([2], разд.8.8). Генерируемый код использует одни и те же команды машинного языка, поэтому выбор синтаксиса - в основном дело вкуса.
В Web расположено много документов, описывающих, как программировать для процессоров Intel, например, Carter [4] - хорошее руководство по стилю Intel.
Наш первый пример демонстрирует вычисление количества цифр (десятичных разрядов), необходимых для вывода неотрицательного целого числа. Будем последовательно делить число на 10 до тех пор, пока оно не станет равно нулю.
.data # выделение памяти
n: .long 234 # число
length: .long 0 # результат
ten: .long 10 # делитель
.text # инструкции
.global _start # делаем _start глобальной
_start: movl $0, %ebx # используем ebx в качестве счетчика
movl n, %eax # копируем число в eax
nextdigit:
movl $0, %edx # подготовка к делению длинного целого
idivl ten # деление совмещенного значения
# регистров edx:eax на 10
# частное хранится в eax, остаток в edx
addl $1, %ebx # увеличиваем счетчик на 1
cmpl $0, %eax # сравнение eax с 0
jg nextdigit # если eax>0, переход на след. итерацию
movl %ebx, length # скопируем счетчик в память
# выход в ОС для завершения программы exit to OS kernel to terminate execution
movl $0, %ebx # первый аргумент - код выхода
movl $1, %eax # код функции sys_exit
int $0x80 # вызов прерывания ядра ОС
Программа состоит из двух секций: .data, в которой определяется выделение памяти для переменных, и .text, в которой содержатся инструкции. Секции могут идти друг за другом в любом порядке.
Каждая переменная имеет имя, тип, и начальное значение. Имени соответствует адрес переменной в памяти. Все переменные в нашем примере имеют тип .long, использующий 32 бита. n - имя анализируемого числа (234), length по завершению программы будет содержать количество цифр (в нашем случае - 3), а ten - делитель.
Первая строка секции .text содержит директиву, которая делает метку доступной за пределами своей секции. _start - метка, используемая по умолчанию загрузчиком, из которой загружается первая инструкция.
Различают восемь 32-битных регистров общего назначения. Наша программа использует три: eax, ebx, edx. Все обращения к регистрам начинаются со знака процента. Первая инстукция, movl $0, %ebx, установит значение ebx в 0. $0 - непосредственный операнд (его значение будет включено непосредственно в сгенерированный машинный код). Знак доллара необходим - без него будет использоваться значение в области памяти с адресом 0. Регистр ebx будем использовать для подсчета цифр.
Следующими двумя инструкциями movl подготовим деление 234 на 10. Первая, movl n, %eax, скопирует значение n в регистр eax
Деление будет произведено для 64-битного совмещенного значения регистров edx и eax. Установим значение первого регистра в 0. Инструкция idivl производит знаковое целочисленное деление, т.е. отрицательные числа представляются как два дополнения. Она имеет один операнд - делитель. Значение операнда берется из переменной ten.
После выполнения инструкции idivl, частное будет помещено в eax, а остаток - в edx.
Увеличим значение ebx на 1, чтобы посчитать цифру. Ее значение содержится в edx.
Затем значение eax сравнивается с 0, и если оно больше 0, следующая инструкция jg nextdigit снова переведет выполнение на метку nextdigit. Обратите внимание на порядок следования операндов!
После следующих двух делений, eax будет равно 0, перехода не произойдет, и значение ebx будет сохранено в переменной length.
Чтобы корректно завершить выполнение программы, нужно вызвать процедуру exit ядра ОС, с помощью прерывания. Эти действия выполняются в трех последних строках, с кодом выхода 0 (выход без ошибок).
Теперь программу можно транслировать в машинный код ассемблером, as, загрузить с помощью ld, и выполнить. На экран ничего не выведется.
> as -o digit1.o digit1.s > ld -o digit1 digit1.o > ./digit1 >
Для просмотра содержимого регистров и памяти во время выполнения программы, можно использовать отладчик ddd. Необходимо передать ассемблеру дополнительный аргумент, чтобы он сохранял символьную таблицу в исполняемом коде.
> as --gstabs -o digit1.o digit1.s > ld -o digit1 digit1.o > ddd digit1&
Контрольные точки добавляются и удаляются выбором соответствующего пункта меню, доступного по правому щелчку на номере строки. Контрольная точка на первой строке будет проигнорирована.
Переменные отображаются так же, по правому щелчку на имени.
Окно регистров открывается из меню: Status -> Registers.
Области памяти отображаются в окне: Data -> Memory.
.data v32: .long 0 # 32 бита, начальное значение 0 v16: .word 0xffff # 16 бит, все биты - единицы v8: .byte ▓- # 8 бит, ASCII-код символа "-" vs: .ascii "input" # строка в 5 байт vs0: .asciz "input" # строка в 6 байт, последний - 0 .align 4 # выравнивание по границе слова 32 бита stack: .skip 1024 # выделение 1024 байт tos = . # tos (вершина стека) - адрес следующего байта
Большинство функций C ожидают, что строка будет завершена нулевым байтом.
Директива .align 4 разместит следующий элемент данных на границе 32-битного адреса. При доступе к 32-битным словам необходимо указывать это выравнивание.
На метке stack: выделяются 1024 байта для записей об активации (?). При использовании Linux на машине с Intel-архитектурой нет необходимости выделять память под стек, т.к. загрузчик, ld, выделяет 2МБ памяти для этой цели. Регистр перемещения стека, esp, будет указывать на вершину стека.
Инструкция может иметь 5 разных видов операндов: константа, регистр, адрес, и непосредственный операнд.
Константа обозначается знаком доллара в начале, например: $10, $n. Во втором примере операнд обозначает адрес памяти, соответствующий переменной с именем n.
Операнд регистра начинается с знака процента. Выделяют восемь 32-битных регистров общего назначения: eax, ebx, ecx, edx, ebp, edi, esi, esp. Последний из них, esp, зарезервирован для использования в качестве указателя на вершину стека. Остальные программист может использовать по своему усмотрению, но некоторые инструкции требуют конкретные регистры для выполнения операций. Буквы e и x в названиях первых четырех регистров означают 32-битную длину. В регистре базы стека, ebp, по соглашению, хранится адрес текущей записи активации. edi и esi используются как индексы источника и приемника для строковых инструкций.
Первые четыре регистра могут также использоваться по частям (8 и 16 бит). Последние 8 бит eax называются al, последние 16 бит - ax. Первые 8 бит ax называются ah. Существуют аналогичные названия для частей регистров ebx, ecx и edx.
Операнд адреса ссылается на некоторое значение в памяти. Самый простой пример - метка переменной или инструкции. Ассемблер может рассчитывать значения простых арифметических выражений.| адрес | ссылается на |
| length | значение на метке length |
| length+4 | значение на 4 байта после length |
| length-4 | значение на 4 байта перед length |
| nextdigit | инструкция на метке nextdigit |
Некоторые операды хорошо подходят для доступа к значениям в ...
| адрес | ссылается на |
| (%ebp) | значение по адресу, содеращемуся в ebp |
| 4(%ebp) | значение по адресу ebp + 4 байта |
| stack(%eax) | значение по адресу stack+eax |
| (%ebp,%eax,4) | значение по адресу ebp+4*eax |
| stack(%ebp,%eax,4) | значение по адресу stack+ebp+4*eax |
Ниже представлен список основных инструкций, используемых при выполнении арифметических операций и копировании данных. Для каждой инструкции допустимые типы операндов обозначены набором букв r (регистр), a (адрес), c (константа), и числом (количество бит, участвующих в операции). Существуют такие же инстукции для операндов-байтов (8 бит) и слов (16 бит). Для них последнюю букву l в названии инструкции следует заменить на b (байт) или w (слово).
Инструкция может содержать не более одного операнда-адреса.| инструкция | операнды | действие |
| movl | rac32, ra32 | ra32 = rac32 |
| addl | rac32, ra32 | ra32 = ra32+rac32 |
| subl | rac32, ra32 | ra32 = ra32-rac32 |
| negl | ra32 | ra32 = -ra32 |
| incl | ra32 | ra32 = ra32+1 |
| decl | ra32 | ra32 = ra32-1 |
| imull | rac32, r32 | r32 = r32*rac32 |
| imull | ra32 | edx:eax = r32*eax |
| idivl | ra32 | eax = edx:eax /ra32, edx = остаток |
| notl | ra32 | ra32 = ! ra32, побитово, ложь = 0 |
| andl | rac32, ra32 | ra32 = ra32 & rac32, побитово |
| orl | rac32, ra32 | ra32 = ra32 | rac32, побитово |
| cmpl | rac32, rac32 | сравнение |
| leal | a32, r32 | r32 = a32 (загрузка исполнительного адреса) |
| jmp dest | |||
| jg dest | > | jge dest | ≥ |
| jl dest | < | jle dest | ≤ |
| je dest | = | jne dest | 6= |
Регистр инструкции, eip, содержит адрес инструкции, следующей по порядку выполнения. Инструкции перехода изменяют значение данного регистра, его нельзя изменить напрямую с помощью movl.
Для того, чтобы процедура сохранила адрес возврата (значение eip) и перешла обратно на этот адрес после завершения своей работы, необходима дополнительная инструкция call dest.
| инструкция | операнды | действие |
| call | dest | поместить в стек адрес возврата и перейти к dest |
| ret | извлечь из стека адрес возврата и перейти по нему | |
| ret | c32 | извлечь из стека адрес возврата и c32 байт |
| int | c32 | послать прерывание ядру ОС |
Инструкция call помещает адрес возврата в стек, полагая, что регистр esp указывает на вершину стека, и переходит к указанной процедуре. Т.к. стек растет в памяти "вниз", значение esp уменьшится на 4 (размер 32битного слова), и запишет по этому адресу значение адреса возврата.
Чтобы вернуться из подпрограммы, необходимо вызвать ret. Данная инструкция копирует в регистр команды значение слова, расположенного по адресу, указанному в регистре esp, и переходит по этому адресу.
Ниже показано, что происходит со стеком во время вызова процедуры. Стек растет в памяти "вниз". В начале программы esp указывает на текущий кадр. Местоположение esp обозначено жирной чертой.
Перед тем, как вызвать процедуру, поместим ее аргументы за областью памяти, в котором будет храниться адрес возврата (т.е. в более младших адресах памяти).
movl arg1, -8(%esp)
movl arg2, -12(%esp)
Указатель вершины стека остается на месте.
Далее мы вызываем процедуру по адресу label: call label. Адрес возврата помещается в стек:
Процедура сохраняет значение регистра вершиты стека в регистре базы стека, и меняет его таким образом, чтобы принимать параметры:
movl %esp, %ebp
subl $8, %esp
Завершая выполнение, процедура восстанавливает значение регистра вершины стека, и выполняет возврат:
movl %ebp, %esp
ret
Следующий пример является измененным вариантов первого. Код примера разбит на две процедуры.
Первая процедура вычисляет строковое представление данного числа и выводит его, используя функции ядра ОС. Вторая процедура выполняет выход из программы и возвращает управление ядру ОС.
.text
.global _start
_start:
movl $234, -8(%esp) # установить аргумент-число
call writeint
movl $0, -8(%esp) # установить аргумент-цифру
call exit
writeint:
movl %esp, %ebp # установить указатель на базу стека
subl $4, %esp # память для аргумента
movl -4(%ebp), %eax # копируем зн-е аргумента в eax
movl $10, %ebx # делитель
nextdigit:
movl $0, %edx # деление edx:eax ...
idivl %ebx # на
addl $'0, %edx # преобразование остатка в ascii
decl %esp # поместить в стек ...
movb %dl, (%esp) # цифра
cmp $0, %eax
jg nextdigit # переход, если eax>0
# печать строки с помощью функций ядра ОС
movl %ebp, %edx # вычислить ...
subl $4, %edx
subl %esp, %edx # третий аргумент: длина строки
movl %esp, %ecx # второй аргумент: адрес строки
movl $1, %ebx # первый аргумент: дескриптор файла
movl $4, %eax # номер функции sys_write для вызова
int $0x80 # прерывание ядру ОС для выполнения
movl %ebp, %esp # восстановление указателя стека
ret
exit:
movl -4(%esp), %ebx # первый аргумент: код ошибки
movl $1, %eax # номер функции sys_exit для вызова
int $0x80 # прерывание ядру ОС для выполнения
Т.к. в этих процедурах не вызываются другие процедуры и не используются глобальные переменные, их записи вызова не содержат динамических или статических ссылок.
Если вы запустите этот пример, возможно, что вы не увидите числа, т.к. подсказка командной строки может перекрыть вывод программы. Чтобы этого избежать, используйте less или перенаправление вывода.
В приложении содержатся примеры процедур, которые могут быть полезными в курсовом проекте.
Компилятор языка C предполагает, что аргументы помещаются в стек в обратном порядке, перед адресом возврата.
| ret adr | arg1 | arg2 | current frame |
В следующей программе используются три функции из стандартной библиотеки C. Со стандартного ввода считывается число с использованием функции scanf, затем к нему прибавляется 1, результат выводится с помощью printf. Программа завершается вызовом exit.
.text
.global main
main:
pushl $n # поместить в стек адрес n
pushl $sfmt # и адрес формата
call scanf # вызов scanf("%d", &n)
addl $8, %esp # извлечь из стека 2 аргумента
addl $1, n
pushl n # поместить в стек число
pushl $fmt # и первый аргумент, адрес "%d"
call printf # вызов printf("%d\n", eax)
addl $8, %esp # извлечь из стека 2 аргумента
pushl $0 # поместить в стек первый аргумент,
# код выхода = 0
call exit # вызов exit(0)
.data
n: .long 0 # число
fmt: .asciz "%d\n" # форматирующая строка для printf
sfmt: .asciz "%d" # форматирующая строка для scanf
Функция scanf использует два аргумента. Первый аргумент, "%d", является форматирующей строкой, задающей обработку введенных данных как целого числа. Второй аргумент должен содержать адрес области памяти, в которой будет сохранено полученное число.
Функция printf требует один или более аргументов. Первый аргумент - адрес области памяти, содержащей строку, в которой задан способ вывода остальных аргументов. В нашем случае, строка "%d\n" обозначает, что будет выведено целое число, а затем - символ новой строки. Второй аргумент - выводимое число.
Выход из программы осуществим с помощью функции exit с аргументом 0. Т.к. вызов вернет управление ОС, а не программе, нет смысла восстанавливать значение указателя на вершину стека.
Стандартная библиотека C является динамически связываемой. Это означает, что несколько процессов, использующих ее, могут обращаться к единственной ее копии в памяти. Для этого, компилятору необходима программа на ассемблере с меткой _start и вызовом процедуры с названием main. Это должна быть первая метка в нашей программе.
По соглашению, функция языка C сохраняет возвращаемое значение в регистре eax. Необходимо учесть, что значения всех остальных регистров, кроме esp, могут измениться. Скомпилируйте файл writen.s, используя команду gcc (или cc), и выполните программу:
> gcc -o writen writen.s > ./writen 234 235
Возможно также скомпилировать программу на языке C, чтобы потом изучать сгенерированные ассемблерные инструкции, с использованием аргумента -S:
gcc -S -o main.s main.c