Ассемблер, который я буду использовать - NASM (Netwide Assembler, nasm.2y.net). Этот выбор объясняется тем, что:
Во первых, он мультиплатформенный, т.е. для портирования программы на разные ОС
достаточно только изменить код взаимодействия с системой, а всю программу переписывать не
нужно
Во вторых, он, его синтаксис непротиворечив и недвусмысленен, в чем схож с AT&T ассемблером для UNIX
В третьих, он имеет привычный Intel-синтаксис, т.е. программист на MASM или TASM
сможет без особых проблем перейти на NASM
А теперь перейдем к первой программе:
;Листинг 01 - минимальная программа для Linux
;Приемы оптимизации не применяются для упрощения кода
global _start
_start:
mov eax, 4
mov ebx, 1
mov ecx, msg
mov edx, msglen
int 0x80
mov eax, 1
mov ebx, 0
int 0x80
section .data
msg: db "Linux rulez 4ever",0x0A,0
msglen equ $-msg
Рассмотрим программу поподробнее:
Знак ';' (точка с запятой) означает комментарий - все что находится правее
этого символа ассемблер игнорирует
global _start - директива global указывает ассемблеру сделать глобальной
(экспортируемой) метку "_start". Подробнее об экспортируемых метках см. ниже
_start: - объявление метки с именем "_start". Фактически это означает, что
в программе будет определена константа _start, которая будет иметь значение равное
адресу, по которому объявлена данная метка
Предыдущие три строчки были
директивами ассемблера, т.е. не являлись командами процессора, и не
преобразовывались при компиляции в машинный код. Следущие строчки являются именно
командами процессора:
mov eax, 4 - машинная команда MOV копирует данные из второго операнда в первый. В
данном случае первый операнд - это регистр EAX (подробнее о регистрах - в
следующем уроке). Второй операнд - это константа (определенное в момент
компилирования и неизменяемое значение). Результатом выполнения этой команды будет то,
что в регистре EAX окажется число 4. Операнды команды разделяются запятой
mov ebx, 1 - то же самое, но помещается единица в регистр EBX
mov ecx, msg - на первый взгляд эта команда отличается от двух предыдущих, но она
тоже выполняет перемещение данных, только в данном случае используется константа
msg, которая определена ниже и регистр ECX
mov edx, msglen - содержимое определенной ниже константы msglen помещается
в регистр EDX
int 0x80 - команда int процессора вызывает т.н. программное
прерывание. Грубо говоря - программное прерывание - это команда перехода выполнения
программы в определенный операционной системой обработчик прерывания. Всего
процессор поддерживает 256 обработчиков для 256 прерываний и операнд этой команды
указывает на обработчик какого прерывания нужно передать выполнение программы.
0x80 - 80 в шестнадцатеричной системе счисления (на шестнадцатеричную систему
указывают первые два символа: 0x). В случае ОС Linux, прерывание с номером 0x80
является системным вызовом - передачей управления ядру системы с целью выполнения
каких-либо действий. В регистре EAX должен находится номер системного вызова, в
зависимости от которого ядро системы будет выполнять какие-либо действия. В данном случае
мы помещаем в EAX число 4, т.е. указываем ядру выполнить системный вызов номер 4 (write).
Этот системный вызов используется для записи данных в файл или на консоль (которая тоже в
принципе представлена файлом). В EBX мы поместили дескриптор(идентификатор)
консоли - stdout. В ECX и EDX содержатся адрес начала сообщения (адрес первого
байта) и длина сообщения в байтах. Т.е этот системный вызов должен выполнить вывод
строчки, находящейся по адресу msg, на консоль.
mov eax, 1 - в EAX помещается 1 - номер системного вызова "exit"
mov ebx, 0 - в EBX помещается 0 - параметр вызова "exit" означает код с
которым завершится выполнение программы
int 0x80 - системный вызов. После системного вызова "exit" выполнение программы
завершается
section .data Директива ассемблера section определяет следующие данные, как
находящиеся в указанном в качестве параметра сегменте. Сегмент .text - сегмент
кода, в котором должен находиться исполняемый код программы и чтение из которого
запрещено. Сегмент .data - сегмент данных, в котором должны находиться
данные программы. Выполнение (передача управления) на сегмент данных запрещена. Поскольку
следующие строчки нашей программы - данные, то мы определяем сегмент данных.
msg: db "Linux rulez 4ever",0x0A,0 - вначале мы определяем метку msg
(напоминаю, что метка - текущий адрес), и сразу после нее - строчку, т.е. метка
msg будет указывать на первый байт строки. Директива db указывает
ассемблеру поместить в данном месте байт данных. Несколько байт могут быть разделены
запятой. Если нужно поместить символ, то запись 'X' означает код символа 'X', а
форма записи "abcde" эквивалентна 'a', 'b', 'c', 'd', 'e'. Код символа 0x0A
означает переход строки, а нулевой байт является концом строки. Поскольку вызов write
знает точно, сколько байт нужно выводить, то нулевой байт в конце строки необязателен, но
мы его все равно поставим :). Он необходим для программ, взаимодействующих с GLIBC, т.к.
функции стандартной библиотеки Си вычисляют длину строки, как расстояние между первым
байтом и ближайшим нулевым байтом.
msglen equ $-msg - директива equ определяет константу, расположенную слева
от директивы и присваивает ей значение, находящееся справа. Символ $ является
специальной константой ассемблера, значение которой всегда равно адресу по которому она
находится, т.е в данном случае выражение $ - msg как раз будет равно длине строки,
т.к. в данном месте программы $ равно адресу следующего за строкой байта.
Результат этой директивы - мы определили константу msglen, значение которой равно
длине определенной выше строки.
Результат работы ассемблера - это объектный файл. Так как мы компилируем программу под
Linux, то нам необходим объектный файл формата ELF (Executable and Linkable Format).
Получить его можно следующей командой:
nasm -felf prog01.asm -o prog01.o
Полученный объектный файл необходимо скомпоновать. Такое название это действие
получило потому, что с его помощью можно компоновать несколько объектных файлов в один
исполняемый. Если в каком-нибудь из объектных файлов существуют экспортируемые функции
или переменные, то они доступны всем компонуемым объектным файлам. Существует функция,
которая должна быть определена всегда - это точка входа - "_start". С этой функции
начинается выполнение программы.
Компоновка:
ld prog01.o -o prog01
Поскольку мы не использователи никаких библиотек, а взаимодействовали напрямую с ядром
системы, то при компоновке мы указываем только наш объектный файл.
После выполнения этой команды файл "prog01" будет исполняемым файлом нашей
программы.
GLIBC - стандартная библиотека Си от GNU.
Если вы программируете на ассемблере под Linux, то использование функций из этой
библиотеки - хороший способ сократить размер программы и затраченные усилия. Безусловно,
использование их замедляет программу, но это всего лишь значит, что их не стоит
использовать в критических участках - циклах. Если же вы используете GLIBC скажем для
форматированного вывода на консоль, то вряд ли вы заметите какое-нибудь замедление.
Более того - использование GLIBC в большинстве случаев сделает вашу программу легко
портируемой на многие другие UNIX-платформы.
В качестве примера рассмотрим программу, которая импортирует функцию puts (вывод на
консоль null-terminated строки)
;Точка входа "_start" на самом деле находится
;в подключаемом *.o файле стандартной библиотеки Си
;Она передает управление на функцию "main",
;которая должна находиться в нашей программе
global main
;Внешние функции
extern exit
extern puts
;Сегмент кода:
section .text
;Функция main:
main:
;Параметры передаются в стеке:
push dword msg
call puts
;По конвенции Си вызывающая процедура должна
;очищать стек от параметров самостоятельно:
sub esp, 4
;Завершение программы с кодом выхода 0:
push dword 0
call exit
ret
;Сегмент данных
section .data
msg: db "An example of interfacing with GLIBC.",0x0D,0
Компиляция:
nasm -felf inglibc.asm
Компоновка:
Для вызова компоновщика с нужными параметрами мы не будем заморачиваться с командой
ld, а воспользуемся GCC, который сам определит, что нужно нашему объектному
файлу:
gcc inglibc.o -o inglibc
Разделяемые объекты (shared objects) в Linux
являются аналогами .DLL в Windows. Находятся они обычно в /usr/lib и
имеют расширение .so. Что они из себя представляют? Это исполняемые
файлы формата ELF, которые экспортируют некоторые функции. В качестве примера создадим библиотеку chomp.so, которая будет
экспортировать функцию chomp (отрезание последнего символа строки, если
это символ новой строки '\n')
;Экспортирование функцию chomp:
global chomp
;Объявление функции chomp:
chomp:
;В качестве параметра функция берет строку
;(точнее указатель на нее)
;Первые четыре байта - адрес возврата,
;значит нам нужны вторые четыре байта
mov eax, [esp+4]
;Теперь в EAX адрес строки
xor ecx, ecx
;Цикл - поиск нулевого символа (конца строки):
.loop
mov dl, [eax+ecx] ;Символ - в DL
inc ecx ;Увеличим счетчик цикла
cmp dl, 0 ;Если не 0
jne .loop ;То вернуться в начало цикла
;Уменьшение ECX на 2:
dec ecx
dec ecx
;Последний символ строки поместим в DL:
mov dl, [eax+ecx]
;Если это не символ новой строки:
cmp dl, 0x0A
;То выйти
jne .quit
;иначе отрезать его
;(поместить на его место символ конца строки)
mov [eax+ecx], byte 0
.quit:
;Завершение функции
ret
Компиляция:
nasm -felf chomp.asm -o chomp.o
Компоновка:
ld chomp.o -shared -o chomp.so
Системный вызов Linux "read" (#3) предназначен для
чтения из файла с текущей позиции. Также он может быть использован для
чтения данных введенных с клавиатуры (используется файловый дескриптор
02 - stdin).
Ниже приведена программа, которая выведет введенные с клавиатуры символы на экран.
global _start
_start:
mov eax, 3 ;Вызов #3
mov ebx, 2 ;Дескриптор stdin
mov ecx, buffer ;Адрес буфера для хранения введенных данных
mov edx, 10 ;Максимальная длина ввода
int 0x80 ;Прерывание - системный вызов
mov eax, 4 ;Вызов #4 (write)
mov ebx, 1 ;Дескриптор stdout
;Системный вызов не изменил содержимое регистров ECX и EDX
; поэтому следующие две строчки не нужны
;mov ecx, buffer;Адрес строки для вывода
;mov edx, 10 ;Длина выводимых данных
int 0x80 ;Системный вызов
xor eax, eax ;Обнуление регистра EAX
inc eax ;Инкремент - увеличение на единицу
int 0x80 ;Системный вызов
section .data ;Начало сегмента данных
buffer: resb 10
Директива ассемблера resb 10 предназначена для
резервирования указанного количества байт. Содержимое этих байт не
определено, но поскольку они находятся в сегменте данных, то их
содержимое будет равно нулю.
Команда xor операнд1, операнд2
на самом деле выполняет логическую операцию "исключающее или" над
каждым битом операндов, т.е. какой-либо бит результата равен 1 только в
том случае, если значения соотвествующих битов операндов различны. Эта
операция чаще всего используется для обнуления регистров - очевидно,
что если операнды равны, то все биты результата будут равны 0.
Команды inc операнд увеличивает содержимое операнда на единицу. Для занесения единицы в регистр лучше использовать не mov reg, 1, а последовательность команд:
xor reg, reg
inc reg
поскольку команда mov в четырехбайтный регистр занимает пять байт, а
указанная выше последовательность - только 3 байта. Аналогичным образом
для занесения в регистр двойки лучше воспользоваться командой xor и дважды применить команду inc - это займет четыре байта
Иногда (особенно часто это случается при разработке ОС) перед
программистом встает задача обеспечения взаимодействия между различными
модулями, одна часть которых написана на ассемблере для повышения
быстродействия, а другая - на Си (или каком-нибудь другом
высокоуровневом языке программирования). Взаимодействие между ними
(скомпилированными как разные объектные файлы) осуществляется следующим
образом (я покажу на примере NASM и GCC):
Для того чтобы функция, написанная на NASM стала доступна из GCC, ее необходимо объявить глобальной:
global function_name
Если же программа на ассемблере использует какую-нибудь функцию
экспортируемую из модуля, написанного на Си, то ее необходимо объявить
внешней:
extern function_name
Конвенции вызова функций
Конвенция вызова функций используемая в Си предполагает передачу аргументов в стеке в обратном порядке, т.е., например, вместо
printf("%i",value);
на ассемблере необходимо написать:
push dword value
push dword format
call printf
К тому же вызванная функция не очищает стек от параметров, поэтому это должна сделать вызывающая функция, например:
add esp, 8 - если были переданы два параметра
Доступ к параметрам
Если функция, написанная на ассемблере, была вызвана из программы,
написанной на Си, то доступ к переданным параметрам можно получить
следующим образом:
push ebp - EBP будет использоваться
mov ebp, esp - сохранить значение ESP
mov eax, [ebp+8] - для того, чтобы запросить последний параметр
из списка, к нему надо обратиться как к ebp+8 (первые четыре байта в
стеке - это адрес возврата, помещенный туда командой call, а вторые четыре байта - это сохраненный в начале функции регистр EBP), для получения второго - ebp+12 и т.д.
Значение esp необходимо сохранять, потому что в процессе исполнения функции оно может меняться
Такая функция должна завершиться командами pop ebp и ret
В некоторых форматах объектных файлов, компилятор будет добавлять
подчеркивание к адресу функции, поэтому чтобы функция, написанная на
ассемблере, была доступна как function_name, ее необходимо объявить как _function_name
Ядро Linux предоставляет системный вызов #2 fork
для "ветвления" процесса. Этот системный вызов создает дочерний
процесс, который отличается от создавшего его только идентификатором
процесса. Дочерний процесс получает память родительского, причем
используется метод COW - copy on write (копирование при записи), т.е.
память действительно копируется только тогда, когда в нее производится
запись, а до этого таблицы страниц обеих процессов указывают на одну и
ту же область памяти
Как программе отличить в каком из процессов она выполняется? Очень
просто: родительскому процессу fork возвращает PID дочернего, а
дочернему возвращает 0.
Рассмотрим программу, которая разветвится на две части с помощью
системного вызова fork. Одна из частей получит управление в
родительском процессе, вторая - в дочернем
global _start
_start:
;Системный вызов #2 fork:
mov eax, 2
int 0x80
;Проверка возвращаемого значения
test eax, eax
;Если ноль - то это дочерний процесс:
jz child
;Иначе - это родительский процесс:
mov eax, 4
mov ebx, 1
mov ecx, msg1
mov edx, msg1len
int 0x80
jmp short quit
child:
mov eax, 4
mov ebx, 1
mov ecx, msg2
mov edx, msg2len
int 0x80
quit:
mov eax, 1
int 0x80
section .data
msg1: db "I am the parent process",0x0A
msg1len equ $-msg1
msg2: db "I am the child process",0x0A
msg2len equ $-msg2
Невозможно предсказать, какая из надписей появится первой - та,
которая выводится родительским процессом или та, которая выводится
дочерним. Запустите программу несколько раз и вы увидите, что в
появлении надписей нет единого порядка. Действительно - для ядра
системы эти две ветки стали различными процессами и порядок их
выполнения уже на совести менеджера процессов.
|