34 KiB
Содержание
- #Введение
- #Переходы
- #Локальные метки
- #Регистры
- #Хранение данных в памяти
- #Указатели
- #Функции
- #Определение функции
- #Возврат из функций
- #Функции с аргументами
- #Возврат значений из функции
- #Соглашение вызова
- #Стек
- #Использование стека для локальных переменных
- #Выравнивание стека
- #Конечный автомат
- #Введение в конечные автоматы
- #Разбор числа со знаком
- #Пять состояний процесса
- #Другие ситуации использования автоматов
- #Чем автоматы непривычны
- #Автоматы и регулярные выражения
- #Недетерминизм
- #Неопределенность
- #Полнота
- #Реализация
- #Введение в конечные автоматы
Введение
Переходы
Как известно, все инструкции в языке ассемблера выполняются последовательно. Но порядок выполнения инструкций можно явно изменить.
Переходы - инструкции, которые явно меняют порядок выполнения программы.
Безусловный переход к необходимой инструкции по адресу выполняется с помощбю инструкции jmp. Команду jmp addr можно интерпретировать как команду mov rip, addr (сама такая команда, к слову, исполнена быть #Регистры.
Но переходы также бывают и условными, то есть исполняются только при определенном условии. Чаще всего такие условия устанавливаются по #Регистры eflags/rflags во время исполнения программы. Почти для каждого флага (коих всего шесть - CF (Carry Flag), PF (Parity Flag), AF (Auxiliary Carry Flag), ZF (Zero Flag), SF (Sign Flag) и OF (Overflow Flag), но AF при условных переходах не используется) существует как минимум по две инструкции перехода: если флаг установлен в ноль или в единицу. Помимо этого, существуют другие полезные инструкции, такие как ja, jb (для беззнаковых числе), jg, jl (для знаковых чисел), jae, jle и подобные.
Для того, чтобы поставить необходимые для перехода флаги, в основном используются инструкция test или инструкция cmp, которые эти флаги ставят. Для проверки какого-либо регистра reg на ноль чаще всего используют команду test reg, reg.
Локальные метки
Для того, чтобы выделять различные участки подпрограммы, используются локальные метки. Локальные метки начинаются с точки. Такие метки должны находится внутри глобальных меток. Для доступа внутри последней глобальной метки можно использовать название в точности соответствующее названию переменной. Если же необходимо обратиться к этой метке извне последней глобальной, то для этого можно соединить названия этой метки и локальной метки, получив одну метку с точкой посередине.
Регистры
У каждого регистра существует собственное назначение. Так, регистры rax и rdx используются для получения значений из функций, регистры rdi, rsi, rdx, rcx, r8 и r9 - для передачи аргументов в функцию, rbp и rsp - для обращения к стеку, r10 и r11 - для временных значений, а регистры rbx, r12, r13, r14 и r15 - регистры общего назначения. Также существуют специальные регистры, такие как rip или rflags, которые контролируют процесс исполнения и их нельзя изменить напрямую.
Перед началом выполнения программы значения большинства регистров являются мусором и не заданы явно. Поэтому при написании программы нужно либо явно задавать значения регистров, либо считать, что в них записано рандомное число.
Хранение данных в памяти
Можно реализовать косвенную адресацию. Для этого регистр, с которого берется адрес, можно заключить в квадратные скобки. Тогда программа перейдет по адресу, указанному в регистре, и возьмет столько байт, сколько ей нужно, в зависимости от операции.
Существует несколько способов хранения данных в памяти. Big endian сохраняет несколько значений начиная с самого старшего значения. Little endian сохраняет несколько значений начиная с самого младшего значения. Intel в основном использует little endian из-за того, что в некоторых моментах он удобнее: если есть значение длиной 8 байт, которое занимает не все из них (то есть один или несколько самых старших байт равны нулю), то начиная чтение с адреса читаться будут сразу же значимые значения, а не просто нули.
Помимо этого существует еще и middle endian. Если есть несколько значений, то сначала записывается половина из них (самые младшие) в обратном порядке (от большего к меньшему), а после - оставшаяся половина, также в обратном порядке.
Указатели
Указатели - это адреса ячеек памяти. Они могут быть сохранены в регистры или ячейки памяти. Длина указателя - 8 байт.
Данные в основном занимают несколько ячеек. В указателе нет никакой информации о длине хранимых данных.
Для сохранения по указателю данных, длина хранения которых не указана, можно использовать специальные указатели размера: byte, word, dword, qword. По факту, можно думать об этом как о спецификации типов данных. Так, перед загрузкой значения в стек можно указать word, dword или qword для обозначения размера выделенной памяти под значение.
Существует несколько способов закодировать операнды в инструкциях: напрямую (инструкция хранится в памяти, операнды - часть этой памяти и имеют собственные адреса; по факту - прямая загрузка операнда в память), через регистр (необходимое значение сначала передается в регистр, а потом уже используется), прямым доступом к памяти (по адресу напрямую обратится к операнду или взять значение адреса напрямую из регистра) и по индексу с размерностью и расположением (имеем базу, индекс, размер и расположение; точный адрес высчитывается как база + индекс*размер + расположение; база и индекс - значения, заданные напрямую, или регистры, размер - количество байт (1, 2, 4 или 8), а расположение - всегда заданно напрямую).
Функции
Функции позволяют отделить логически различные части кода друг от друга. Такое построение кода помогает использовать абстракции и создавать более сложные программы.
Для того, чтобы в языке ассемблера определить функцию, необходимо:
- создать для нее метку;
- выделить код функции отдельно по метке.
По факту, функции работают как #Переходы с возвратом в место вызова. При вызове функции адрес возврата сохраняется в #Стек. Для вызова функции используется инструкция call (буквально call addr можно разложить как push rip и jmp addr, хотя вручную такие команды выполнить #Регистры).
Можно расписать вызов функции по шагам:
- Сохранить все важные значения #Соглашение%20вызова регистров (их можно сохранить в стеке).
- Выставить необходимые #Функции%20с%20аргументами в нужные регистры.
- Вызвать функцию инструкцией
call. - Сохранить #Возврат значений из функции функции из
rax. - Вернуть #Соглашение%20вызова регистры в исходное положение.
Иногда функцию также называют подпрограммой, чтобы отделить функцию от таковых в языках высокого уровня.
Определение функции
В качестве примера выделения функции можно рассмотреть простейший пример программы на ассемблере. Есть смысл выделить в этой программе функцию выхода: тогда можно будет обращаться к ней из любого места программы.
Для определения функции необходимо использовать метку, которая на эту функцию будет указывать: в данном случае, очевидно, метка будет называться exit. После метки идет выделенный для этой функции код. Для вызова функции в программе будет использоваться инструкция call.
Таким образом получим новую программу.
section .data
message: db 'hello, world!', 10
section .text
global _start
exit: ; Это метка начала функции exit
mov rax, 60 ; Это функция exit
xor rdi, rdi ; Это функция exit
syscall ; Это функция exit
_start:
mov rax, 1
mov rdi, 1
mov rsi, message
mov rdx, 14
syscall
call exit ; Это вызов функции exit
Возврат из функций
Вообще, подпрограмма обязана иметь возможность быть вызвана отовсюду и вернутся в место вызова. Для того, чтобы завершить функцию и вернутся к месту ее вызова, используется инструкция ret (буквально команду ret можно интерпретировать как pop rip).
Не стоит завершать функцию, если #Стек не находится в том же положении, в котором был в начале инструкции. Иначе программа в качестве адреса возврата из #Стек возьмет первую попавшуюся переменную, которая в этот момент будет на вершине стека, и скорее всего переход не удастся.
При выполнении подпрограммы для завершения программы с системным выходом exit можно не писать возврат из подпрограммы, так как программа все равно уже не будет работать.
При этом инструкцию call можно заменить на инструкцию jmp в случае, если возврат из подпрограммы не подразумевается (к примеру, в подпрограмме выполняется системный вызов exit).
Функции с аргументами
Аргумент - кусок данных, который нужен функции для работы.
Существует три способа передать аргументы в функцию: загрузить их в регистры, в #Стек или напрямую в память.
Функции могут иметь сколько угодно аргументов. Как упоминалось #Регистры, для получения первых шести из них используются регистры rdi, rsi, rdx, rcx, r8 и r9 по порядку. Остальные аргументы ложатся в #Стек в обратном порядке.
Возврат значений из функции
Очень часто основная цель функций - это получение каких-либо значений на основе полученных #Функции с аргументами и возврат этого значения обратно в программу, которая эту функцию вызвала. Поэтому необходим механизм для возврата значений из функции. Сделать это можно теми же способами, что и передать аргументы в функцию.
Тем не менее, для возврата результата функции принято использовать регистр rax. Если нужно вернуть два аргумента, то для второго можно использовать rdx.
Некоторые системные вызовы тоже возвращают значения.
Соглашение вызова
Существуют правила, в соответствии с которыми стоит писать функции.
Такие правила гарантируют возможность свободно переписывать функции и подменять их реализации. Иначе же вызов функций станет ненадежным: что-то сломается или сразу при использовании, или позже при замене реализации функции.
Очевидно, регистры могут изменятся внутри функции. Поэтому регистры делят на callee-saved (регистры, которые по завершению функции должны оказаться теми же, что и в начале) и caller-saved (регистры, которые по завершению функции могут быть изменены). Всего callee-saved регистров семь: rbx, rbp, rsp, r12, r13, r14, r15. Все остальные - caller-saved.
Стек
Стек располагается в нижних ячейках памяти и читается снизу вверх (то, что снизу, было сложено туда раньше). В ассемблере на вершину стека (то есть последний сложенный туда элемент) указывает регистр rsp (stack pointer - указатель стека). При увеличении количества элементов в стеке значение регистра rsp уменьшается, и наоборот - при уменьшении количества элементов в стеке значение регистра rsp увеличивается.
Использование стека для локальных переменных
Выделить память в стеке - значит уменьшить указатель стека так, чтобы между его новым значением и последним значимым элементом образовалось незанятое пространство для данных.
Всего существует три способа выделить память в стеке (для, к примеру, трех значений - каждое из них 8 байт, итого 24).
Первый способ:
sub rsp, 24 -> add rsp, 24
Второй способ:
push 0 pop
push 0 -> pop
push 0 pop
Третий способ:
push rbp mov rsp, rbp
mov rbp, rsp -> pop rbp
sub rsp, 24
Первыми двумя способами указатель стека просто передвигается вверх. Разница между ними заключается в том, что в первом случае на месте освобожденных значений может быть любой мусор. Во втором же случае все используемые значения будут гарантированно нулями.
Третий способ выделения памяти в стеке работает немного иначе.
Фрейм стека - диапазон памяти в стеке, используемый в отдельной #Функции.
Как известно, в rsp хранится адрес вершины стека. Для обращения к переменным внутри стека используется относительная адресация от вершины стека. Но это не всегда удобно: некоторые переменные могут иметь разное смещение в разных частях программы, ведь значение вершины стека может изменяться. Поэтому есть смысл сохранять состояние вершины в отдельный регистр и обращаться к переменным относительно него, тогда все смещения будут постоянными. Для этого принято использовать регистр rbp (base pointer).
Из-за того, что регистр rbp является callee-saved, его необходимо отдельно сохранять в начале вызываемой подпрограммы. Это показано в третьем способе выделения памяти в стеке.
Выделение памяти под стек также иногда называется прологом, а в ассемблере существует инструкция enter, которая реализует третий способ пролога. В то же время освобождение памяти в стеке также иногда называется эпилогом, и в ассемблере для него существует инструкция leave.
Выравнивание стека
При работе со стеком принято выравнивать значение стека по 16 байтам. Это значит, что при выравнивании стека под каждое значение внутри отводится по 16 байт. В самом начале стек изначально выравнен по 16, так как в начале программы у функции _start нет адреса возврата.
В основном такое требование существует, потому что помимо 64-битных регистров существуют также и 128-битные, и как раз для значений, которые они могут хранить, иногда было бы важно, чтобы стек был выровнен.
Конечный автомат
Данный раздел является логическим продолжением лекции.
Модель вычислений - язык, на котором описывается решение проблемы.
Проблема, нахождение решения которой в одной модели может быть очень сложной, в другой может решаться тривиально. Именно по этому важно знать несколько моделей вычислений и использовать их в тех местах, в которых они дадут наибольшую пользу по сравнению с другими моделями.
Одной из таких моделей вычислений являются конечные автоматы.
Детерминированные конечные автоматы - абстрактная машина для чтения строки, следующая некоторым правилам.
Введение в конечные автоматы
Чаще всего конечные автоматы используются для контроля внутренних устройств и поиска подстрок, которые удовлетворяют определенному шаблону.
Некоторые автоматы при переходе между состояниями могут демонстрировать определенную реакцию (то есть выполнять какое-то действие).
Разбор числа со знаком
Для того, чтобы разобрать число со знаком с помощью конечного автомата, потребуется три состояния.
Алгоритм работы:
- A - начальное состояние
- A -> B, если "+" или "-"
- A -> C, если "0", "1", "2", "3", "4", "5", "6", "7", "8" или "9"
- B - первый символ числа - знак
- B -> C, если "0", "1", "2", "3", "4", "5", "6", "7", "8" или "9"
- C - принимающее состояние
- C -> C, если "0", "1", "2", "3", "4", "5", "6", "7", "8" или "9"
Пять состояний процесса
Как известно, у процесса есть пять основных состояний: new, ready, running, waiting и terminated. Жизненный цикл процесса с такими состояниями несложно представить в виде конечного автомата.
Алгоритм работы:
- new - начальное состояние
- admit: new -> ready
- ready - процесс готов к работе
- dispatch: ready -> running
- running - процесс выполняется
- interrupt: running -> ready
- I/O or event wait: running -> waiting
- release: running -> terminated
- waiting - процесс ждет
- I/O or event completion: waiting -> ready
- terminated - принимающее состояние
Другие ситуации использования автоматов
Чаще всего конечные автоматы используют для управления роботами или машинами (в таком случае, событием будут показания сенсоров, а реакцией - какое-либо действие), или для контроля сетевых протоколов (в таком случае, событием будет прием пакетов разного типа, а действием - ответы на эти пакеты).
Существует много ситуаций, для которых можно построить конечный автомат, но не во всех из них это будет уместно.
Очевидно, не все программы можно рассматривать как конечные автоматы. Эта модель вычислений не Тьюринг-полная, и не может анализировать сложные рекурсивно структурированные тексты.
Чем автоматы непривычны
Главное различие между автоматами и стандартной моделью компьютерных вычислений заключается в том, что у автомата нет памяти, совсем никакой. Вместо этого есть только конечный набор фиксированных состояний.
В конечных автоматах нет условных переходов. Все, что существует - это состояния и переходы между ними.
Из-за этого автоматы удобны для описания таких алгоритмов, в которых действия и условия глобальны.
В отличие от стандартных языков программирования, автоматы можно легко проанализировать, что помимо прочего позволяет легко писать программы на их основе.
Автоматы и регулярные выражения
Регулярные выражения - один из способов кодирования конечных автоматов.
В основном используется два подхода для проверки строки на соответствие регулярному выражению.
Первый подход - подход "в лоб". То есть, проверка всех строк, подходящих для регулярного выражение, на соответствие данной строке. Этот подход далеко не самый оптимальный: бывают случаи, когда необходимо перебрать миллиарды различных комбинаций, чтобы найти необходимое или понять, что подходящей комбинации не существует.
Второй подход - построить конечный автомат на основе данного регулярного выражения. В основном, такие автоматы являются #Недетерминизм.
Недетерминизм
Недетерминизм противоположен детерминизму.
В отличие от детерминированных автоматов, недетерминированные могут иметь несколько функций перехода для одного и того же состояния и входного символа.
В случаях с несколькими переходами автомат выполняет все эти переходы, и дальше уже вместо одного состояния рассматривает все полученные (то есть, вместо одного состояния - множество состояний). Таким образом, каждое из таких состояний является равноправным.
Неопределенность
Неопределенное поведение - состояние, при котором для текущего входящего символа нет функции перехода.
Так как неопределенное поведение рассматривается чаще в математике, чем в инженерии, здесь рассматриваются только определенные состояния. Все неопределенные состояния классифицируются как ошибки.
Полнота
Полный автомат - автомат, у которого нет неопределенного поведения.
Это означает, что из каждого состояния для каждого возможного события есть переход.
Неполный автомат можно легко достроить до полного, добавив к нему новые состояния.
Кодировать имеет смысл только полные автоматы.
Реализация
Есть несколько шагов, для того чтобы реализовать конечный автомат на ассемблере или C.
- Дополнить автомат до #Полнота.
- Внедрить программу для получения каждого следующего символа.
- Для каждого состояния необходимо...
- создать метку;
- вызвать программу чтения символов;
- прочитать полученный символ и вызвать необходимую #Переходы (перейти к другой метке состояния);
- обработать все остальные символы как #Неопределенность.