В данной статье мы коснёмся основ без которых программирование на ассемблере невозможно, я расскажу кое-что о системах счисления, коснусь организации памяти и рассмотрю пустую программу.
Системы счисления
Прежде всего я хотел бы остановиться на способах записи целых чисел. Начнём с примера: запись 32167 означает следующее: 32167=3*10^4 + 2*10^3 + 1*10^2 + 6*10^1 + 7*10^0 \quad (1)
То есть любое число можно представить в виде суммы:
\sum\limits_{k=0}^N a_k*10^k
Где a k – цифра от 0 до 9, N – количество цифр в записи числа. Такая запись числа называется записью в десятичной системе счисления. В принципе, в вышеупомянутой сумме 10 можно заменить на любое другое натуральное число, это число будет называться основанием системы счисления. Например в восьмеричной системе счисления число представляется в виде суммы:
\sum\limits_{k=0}^N a_k*8^k
В данном случае требуется не 10 цифр, а 8, от 0 до 7. Обычно, если требуется указать систему счисления, её основание записывается как нижний индекс справа от числа(основание записывается в десятичной системе счисления), например:
76647_{8}=7*8^4 + 6*8^3 + 6*8^2 + 4*8^1 + 7*8^0 \quad (2)
в свою очередь:
76647_{8}=32167_{10}
то есть сумма (1) равна суме (2).
В системе счисления с основанием n существует n цифр, например в десятичной – десять, в восьмеричной – восемь. Если же нужно использовать систему счисления с основанием большим десяти, то придётся добавлять новые цифры, например в шестнадцатеричной системе счисления 16 цифр: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, A, B, C, D, E, F. Цифры A-F, равны 10-15 соответственно. Чтобы лучше понять рассмотрим несколько примеров:
A_{16}=10_{10} \\ F_{16}=15_{10} \\ A3_{16}=10*16^1 + 3*16^0=163_{10} \\ F2E_{16}=15*16^2 + 2*16^1 + 14*16^0=3886_{10}
Подробнее почитать о системах счисления можно в википедии
Что касается fasm-а, то в нём числа можно записывать в 4 разных системах счисления: в двоичной, в восьмеричной, в десятичной и в шестнадцатеричной. Чтобы записать число в десятичной системе счисления можно либо не указывать систему счисления вообще, либо дописать к числу справа букву “d”, например: 32167 или 32167d. Чтобы записать в восьмеричной нужно дописать к числу справа букву “o”, например: 32167o равно числу 13431. Чтобы записать в двоичной нужно дописать справа букву “b”, например: 100110b равно числу 38. Записать число в шестнадцатеричной системе счисления можно несколькими способами: можно дописать слева последовательность “0x”, можно дописать слева “$” и можно дописать справа букву “h”, но в последнем случае число должно начинаться с одной из десятичных цифр. Пример: 0xFE=$FE=0FEh=254, запись FEh приведёт к ошибке.
Компьютер работает с числами в двоичной системе счисления. Один двоичный знак называется битом, соответственно число состоящее из n двоичных знаков называется n-битным (или n-разрядным). Например число 11010010b – 8-ми битное. Группа из 8 бит называется байтом(byte), из 2-х байт – словом(word), из 4-х байт – двойным словом(double word), из 8-и байт – счетверённым словом(quad word), и т.д.
Организация памяти
Каждая программа (её код и данные) располагается в оперативной памяти. Оперативная память состоит из ячеек размером в 1 байт. Для того чтобы обращаться к этим ячейкам, каждой из них сопоставлено некоторое число, называемое адресом (в нашем случае — 64-битное) (см. рис. 1).
Адреса принято записывать в 16-ричной системе счисления. Множество адресов вместе с сопоставленными им ячейками памяти называется адресным пространством. В адресном пространстве не каждому адресу сопоставлена какая-то ячейка памяти. Существует физическое адресное пространство, в нём каждой ячейке оперативной памяти сопоставлен единственный адрес. В целях безопасности сделано так, что у каждого процесса адресное пространство своё, т.е. процессу доступна не вся оперативная память компьютера, а только некоторая её часть. Адресное пространство процесса называется виртуальным. Одной и той же ячейке памяти в разных адресных пространствах могут быть сопоставлены разные адреса.
У виртуальной памяти есть атрибуты доступа: запись, чтение, исполнение. Например, если память не доступна для записи, то попытка записать в неё приведёт к ошибке, если она не доступна для исполнения, то попытка исполнения кода, находящегося там приведёт к ошибке, и т.д.
Ещё хотелось бы добавить, что когда говорят “указатель на <что-то>”, то имеется ввиду виртуальный адрес, с которого начинается <что-то>.
Общий вид исходника
Начну с того, что коснусь структуры исполняемого файла.
Исполняемый файл Windows состоит из нескольких частей:
1)Заголовок – обязателен
2)Секции, их может быть любое количество
У каждой секции есть имя (состоит из 8 ANSI символов) и атрибуты, в них входят атрибуты доступа к памяти.
Теперь рассмотрим HelloWorld простейшую программу на fasm-е:
format PE64 console;формат файла, подсистема
entry start;точка входа
include 'win64w.inc';заголовки
section '.code' readable executable code;секция кода
proc start;процедура
nop
nop
nop
ret
endp ;конец процедуры
section '.data' readable writeable data; секция данных
dd ?;данные
Первой строчкой указывается формат файла(в нашем случае PE64 – это 64-битный исполняемый файл Windows PE-Portable Executable) и подсистема console(это значит что при запуске нашей программы у неё будет создана консоль).
Вторая строчка entry start
указывает точку входа, это — место с которого начинает исполняться код нашей программы.
include 'win64w.inc'
– подключение некоторых заголовочных файлов.
Слово section
обозначает начало новой секции, после него идёт имя в кавычках, а затем атрибуты.
Секцию кода мы пометили как доступную для чтения и исполнения, но не для записи, атрибут “code” указывает на то, что в данной секции находится код, он не обязателен, без него всё будет нормально работать.
Секция данных доступна для чтения и записи, атрибут “data” указывает на то, что в данной секции находятся данные, он, опять же, не обязателен. В принципе можно создавать любое количество секций(даже с одинаковыми именами) и класть код и данные в любые из них, главное, чтобы то, что предназначено для исполнения находилось в исполняемой памяти, а то, что для записи находилось в памяти доступной для записи.
В секции данных находится загадочная строка dd ?
, сейчас расскажу, что она означает.
Объявление данных
Для объявления данных существует ряд директив, изображённых в следующей таблице:
Размер (в байтах) | Объявление данных |
---|---|
1 | db,file |
2 | dw,du |
4 | dd |
6 | dp,df |
8 | dq |
10 | dt |
Чтобы объявить данные размером в байт нужно написать db <значение>
, например для объявления байта со значением 0xAB служит строчка db 0xAB
, остальные директивы предназначены для объявления данных большего размера. Если вместо значения после директивы стоит “?”, это означает, что вам безразлично, какое там будет значение. Следует так-же отметить, что директива dt не предполагает указания целых чисел, а предполагает числа с плавающей точкой. С помощью этих директив можно объявить несколько блоков данных подряд, например: db 12o,10,?
объявляет 3 байта подряд: сначала со значением 12(в восьмеричной системе счисления), далее 10, далее – безразлично. Строка dd 32167,12
объявляет 2 двойных слова со значениями 32167 и 12 соответственно. Чтобы заполнить участок памяти одним значением используется слово “dup”, например строка dd 12 dup (10)
объявляет 12 двойных слов со значением 10, строка dd 5 dup (3,6)
объявляет 5 пар двойных слов со значениями 3 и 6 соответственно.
Что касается директив db и du, они в качестве значения могут принимать строки (в кавычках), например: строка db 'Hello world!',0
объявляет строку в формате ANSI с нулём на конце, а du 'Hello world!',0
объявляет такую-же строку только в формате юникод (UTF-16).
Директива file принимает в качестве значения имя файла (file <имя файла>
), после компиляции в этом месте программы будет находиться содержимое файла <имя файла>. Чтобы в коде можно было обращаться к определённым данным нужно дать им какое-нибудь имя. Данные с именем мы будем называть переменными. Имя может состоять из латинских букв, цифр и знака подчёркивания (хотя буквы могут быть не только латинские, но и любые другие, но я настоятельно не рекомендую их использовать). Имя пишется перед директивой объявления данных, например так: qwe dd 32167
, в данном случае мы объявили переменную с именем “qwe”, размером 4 байта, у которой начальное значение равно 32167.
Регистры
Перед тем, как подходить к писанию кода необходимо обсудить регистры. Всего есть 16 регистров общего назначения (вообще регистров больше, но общего назначения – 16). Имена 8-и из них: rax,rcx,rdx,rbx,rsp,rbp,rsi,rdi, все остальные называются r8-r15, нижние 32 бита регистров называются: eax,ecx,edx,ebx,esp,ebp,esi,edi, остальные r8d-r15d. Нижние 16 бит называются ax,cx,dx,bx,sp,bp,si,di, остальные r8w-r15w. Нижние 8 бит называются al,cl,dl,bl,spl,bpl,sil,dil, остальные r8b-r15b. Так же можно обращаться верхним 8-и битам нижних 16 бит первых 4-х регистров по именам: ah,ch,dh,bh. Так же есть регстр – указатель инструкций rip, и регистр флагов rflags. rip всегда указывает на следующую инструкцию (которая идёт после той, которая выполняется), регистры изображены на рис. 2
Собственно программа
Если вы скомпилируете программу приведённую выше, то обнаружите при запуске, что она ничего не делает, потому что она действительно ничего не делает! Выше я говорил про точку входа, она начиналась со строки proc start
, и закончилась на endp
, что представляют собой эти макросы (proc и endp), я пока говорить не буду, скажу лишь что объявив таким образом процедуру к ней можно обращаться из любого места программы. Внутри процедуры “start” трижды написана команда “nop”, так вот:
nop — команда, которая ничего не делает.
Далее я хотел бы ввести ещё одну команду:
int3 (без пробела) — команда точки останова. Если программа находится под отладчиком, то как только исполнение доходит до этой инструкции программа останавливается, что будит видно в отладчике. Если же программа не находится под отладчиком, то выполнение этой инструкции немедленно приводит к ошибке.
Попробуйте добавить int3 перед 3-мя nop-ами в приведённой в статье программе, скомпилируйте и откройте отладчиком, вы увидите что-то вроде того, что изображено на рис. 3.
Это — точка останова внутри системного кода. Продолжите выполнение программы (в WinDbg — F5), после чего вы увидите в окне дизассемблера что-то вроде этого:
push rbp
mov rbp,rsp
int3
nop
nop
nop
leave
ret
“push rbp\mov rbp,rsp” и “leave\ret” — это пролог и эпилог процедуры соответственно, позже я объясню, что это и зачем.
Нажмите несколько раз “step over” (в WinDbg – F10) или “step into” (в WinDbg – F8) и вы увидите как отладчик переходит от одной инструкции к другой.
На этом – всё, в следующей статье я рассмотрю основные машинные инструкции и расскажу несколько подробнее про содержимое этих окон в WinDbg, если вы используете другой отладчик, то разница, в общем, небольшая.