Compare commits

..

2 Commits

3 changed files with 284 additions and 1 deletions
@@ -0,0 +1,282 @@
## Содержание
- [[#Введение]]
- [[#Переходы]]
- [[#Локальные метки]]
- [[#Регистры]]
- [[#Хранение данных в памяти]]
- [[#Указатели]]
- [[#Функции]]
- [[#Определение функции]]
- [[#Возврат из функций]]
- [[#Функции с аргументами]]
- [[#Возврат значений из функции]]
- [[#Соглашение вызова]]
- [[#Стек]]
- [[#Использование стека для локальных переменных]]
- [[#Выравнивание стека]]
- [[#Конечный автомат]]
- [[#Введение в конечные автоматы]]
- [[#Разбор числа со знаком]]
- [[#Пять состояний процесса]]
- [[#Другие ситуации использования автоматов]]
- [[#Чем автоматы непривычны]]
- [[#Автоматы и регулярные выражения]]
- [[#Недетерминизм]]
- [[#Неопределенность]]
- [[#Полнота]]
- [[#Реализация]]
## Введение
### Переходы
Как известно, все инструкции в языке ассемблера выполняются последовательно. Но порядок выполнения инструкций можно явно изменить.
>**Переходы** - инструкции, которые явно меняют порядок выполнения программы.
Безусловный переход к необходимой инструкции по адресу выполняется с помощбю инструкции `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), а расположение - всегда заданно напрямую).
## Функции
Функции позволяют отделить логически различные части кода друг от друга. Такое построение кода помогает использовать абстракции и создавать более сложные программы.
Для того, чтобы в языке ассемблера определить функцию, необходимо:
1. создать для нее [метку](Среда%20выполнения.%20Основы%20языка%20ассемблера.md#Секции,%20метки%20и%20комментарии);
2. выделить код функции отдельно по [метке](Среда%20выполнения.%20Основы%20языка%20ассемблера.md#Секции,%20метки%20и%20комментарии).
По факту, функции работают как [[#Переходы|переход]] с возвратом в место вызова. При вызове функции адрес возврата сохраняется в [[#Стек|стек]]. Для вызова функции используется инструкция `call` (буквально `call addr` можно разложить как `push rip` и `jmp addr`, хотя вручную такие команды выполнить [[#Регистры|не получится]]).
Можно расписать вызов функции по шагам:
1. Сохранить все важные значения [[#Соглашение%20вызова|caller-saved]] регистров (их можно сохранить в стеке).
2. Выставить необходимые [[#Функции%20с%20аргументами|аргументы]] в нужные регистры.
3. Вызвать функцию инструкцией `call`.
4. Сохранить [[#Возврат значений из функции|результат работы]] функции из `rax`.
5. Вернуть [[#Соглашение%20вызова|caller-saved]] регистры в исходное положение.
Иногда функцию также называют подпрограммой, чтобы отделить функцию от таковых в языках высокого уровня.
### Определение функции
В качестве примера выделения функции можно рассмотреть [простейший пример](Среда%20выполнения.%20Основы%20языка%20ассемблера.md#Простейшая%20программа) программы на ассемблере. Есть смысл выделить в этой программе функцию выхода: тогда можно будет обращаться к ней из любого места программы.
Для определения функции необходимо использовать [метку](Среда%20выполнения.%20Основы%20языка%20ассемблера.md#Секции,%20метки%20и%20комментарии), которая на эту функцию будет указывать: в данном случае, очевидно, метка будет называться `exit`. После [метки](Среда%20выполнения.%20Основы%20языка%20ассемблера.md#Секции,%20метки%20и%20комментарии) идет выделенный для этой функции код. Для вызова функции в программе будет использоваться инструкция `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`.
Некоторые [системные вызовы](Среда%20выполнения.%20Основы%20языка%20ассемблера.md#Команды) тоже возвращают значения.
### Соглашение вызова
Существуют правила, в соответствии с которыми стоит писать функции.
Такие правила гарантируют возможность свободно переписывать функции и подменять их реализации. Иначе же вызов функций станет ненадежным: что-то сломается или сразу при использовании, или позже при замене реализации функции.
Очевидно, регистры могут изменятся внутри функции. Поэтому регистры делят на **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-битные, и как раз для значений, которые они могут хранить, иногда было бы важно, чтобы стек был выровнен.
## Конечный автомат
*Данный раздел является логическим продолжением [лекции](Формальные%20грамматики%20и%20теория%20автоматов.md).*
>**Модель вычислений** - язык, на котором описывается решение проблемы.
Проблема, нахождение решения которой в одной модели может быть очень сложной, в другой может решаться тривиально. Именно по этому важно знать несколько моделей вычислений и использовать их в тех местах, в которых они дадут наибольшую пользу по сравнению с другими моделями.
Одной из таких моделей вычислений являются [конечные автоматы](Формальные%20грамматики%20и%20теория%20автоматов.md#Конечный%20автомат).
>**Детерминированные конечные автоматы** - абстрактная машина для чтения строки, следующая некоторым правилам.
### Введение в конечные автоматы
Чаще всего конечные автоматы используются для контроля внутренних устройств и поиска подстрок, которые удовлетворяют определенному шаблону.
Некоторые автоматы при переходе между состояниями могут демонстрировать определенную реакцию (то есть выполнять какое-то действие).
#### Разбор числа со знаком
Для того, чтобы разобрать число со знаком с помощью конечного автомата, потребуется три состояния.
Алгоритм работы:
- 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 - принимающее состояние
#### Другие ситуации использования автоматов
Чаще всего конечные автоматы используют для управления роботами или машинами (в таком случае, событием будут показания сенсоров, а реакцией - какое-либо действие), или для контроля сетевых протоколов (в таком случае, событием будет прием пакетов разного типа, а действием - ответы на эти пакеты).
Существует много ситуаций, для которых можно построить конечный автомат, но не во всех из них это будет уместно.
Очевидно, не все программы можно рассматривать как конечные автоматы. Эта модель вычислений не [Тьюринг-полная](https://ru.wikipedia.org/wiki/Полнота_по_Тьюрингу), и не может анализировать сложные рекурсивно структурированные тексты.
### Чем автоматы непривычны
Главное различие между автоматами и стандартной моделью компьютерных вычислений заключается в том, что у автомата нет памяти, совсем никакой. Вместо этого есть только конечный набор фиксированных состояний.
В конечных автоматах нет условных переходов. Все, что существует - это состояния и переходы между ними.
Из-за этого автоматы удобны для описания таких алгоритмов, в которых действия и условия глобальны.
В отличие от стандартных языков программирования, автоматы можно легко проанализировать, что помимо прочего позволяет легко писать программы на их основе.
### Автоматы и регулярные выражения
[Регулярные выражения](Формальные%20грамматики%20и%20теория%20автоматов.md#Регулярные%20выражения%20и%20автоматы) - один из способов кодирования конечных автоматов.
В основном используется два подхода для проверки строки на соответствие регулярному выражению.
Первый подход - подход "в лоб". То есть, проверка всех строк, подходящих для регулярного выражение, на соответствие данной строке. Этот подход далеко не самый оптимальный: бывают случаи, когда необходимо перебрать миллиарды различных комбинаций, чтобы найти необходимое или понять, что подходящей комбинации не существует.
Второй подход - построить конечный автомат на основе данного регулярного выражения. В основном, такие автоматы являются [[#Недетерминизм|недетерминированными]].
### Недетерминизм
Недетерминизм противоположен [детерминизму](Формальные%20грамматики%20и%20теория%20автоматов.md#ДКА%20и%20НКА).
В отличие от детерминированных автоматов, недетерминированные могут иметь несколько функций перехода для одного и того же состояния и входного символа.
В случаях с несколькими переходами автомат выполняет все эти переходы, и дальше уже вместо одного состояния рассматривает все полученные (то есть, вместо одного состояния - множество состояний). Таким образом, каждое из таких состояний является равноправным.
### Неопределенность
>**Неопределенное поведение** - состояние, при котором для текущего входящего символа нет функции перехода.
Так как неопределенное поведение рассматривается чаще в математике, чем в инженерии, здесь рассматриваются только определенные состояния. Все неопределенные состояния классифицируются как ошибки.
### Полнота
>**Полный автомат** - автомат, у которого нет неопределенного поведения.
Это означает, что из каждого состояния для каждого возможного события есть переход.
Неполный автомат можно легко достроить до полного, добавив к нему новые состояния.
Кодировать имеет смысл только полные автоматы.
### Реализация
Есть несколько шагов, для того чтобы реализовать конечный автомат на ассемблере или C.
1. Дополнить автомат до [[#Полнота|полноты]].
2. Внедрить программу для получения каждого следующего символа.
3. Для каждого состояния необходимо...
1. создать [метку](Среда%20выполнения.%20Основы%20языка%20ассемблера.md#Секции,%20метки%20и%20комментарии);
2. вызвать программу чтения символов;
3. прочитать полученный символ и вызвать необходимую [[#Переходы|функцию перехода]] (перейти к другой [метке](Среда%20выполнения.%20Основы%20языка%20ассемблера.md#Секции,%20метки%20и%20комментарии) состояния);
4. обработать все остальные символы как [[#Неопределенность|неопределенное поведение]].
@@ -8,7 +8,7 @@
- [[#История]]
- [[#Иерархия Хомского]]
- [[#Конечный автомат]]
- [#ДКА и НКА]]
- [[#ДКА и НКА]]
- [[#Регулярные выражения и автоматы]]
- [[#Построение Томпсона]]
- [[#Минимизация]]
@@ -5,6 +5,7 @@
## Семинары
- [Среда выполнения. Основы языка ассемблера](Среда%20выполнения.%20Основы%20языка%20ассемблера.md)
- [Вызов функций. Использование стека. Конечные автоматы](Вызов%20функций.%20Использование%20стека.%20Конечные%20автоматы.md)
- (и тд)
## Ссылки