Добавлен семинар 2 по языкам программирования
This commit is contained in:
+282
@@ -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. обработать все остальные символы как [[#Неопределенность|неопределенное поведение]].
|
||||||
|
|
||||||
|
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
## Семинары
|
## Семинары
|
||||||
- [Среда выполнения. Основы языка ассемблера](Среда%20выполнения.%20Основы%20языка%20ассемблера.md)
|
- [Среда выполнения. Основы языка ассемблера](Среда%20выполнения.%20Основы%20языка%20ассемблера.md)
|
||||||
|
- [Вызов функций. Использование стека. Конечные автоматы](Вызов%20функций.%20Использование%20стека.%20Конечные%20автоматы.md)
|
||||||
- (и тд)
|
- (и тд)
|
||||||
|
|
||||||
## Ссылки
|
## Ссылки
|
||||||
|
|||||||
Reference in New Issue
Block a user