Пишем виртуальную машину на c
[C++] Пишем виртуальную машину Erlang! Сегодня я снова у мамы программист! (Много сделано, github)
Всем чмоки вэтам чяти.
Сегодня я снова у мамы программист, ну на самом деле не буквально сегодня, а в течение последних месяцев четырёх.
Кочать без смс но с регистрацией, лайкать и френдить здесь: https://github.com/kvakvs/gluonvm
P.S. Особенно горжусь как я раскостылил самонаследование (CRTP) в классе Term в модуле include/term.h
Tiendil
> Зачем?
1. Потому что могу
2. Потому что хочу
3. Потому что не существует ВМ для Эрланга маленького размера, которую можно наоптимизировать на маленькие устройства. А это уже бизнес-opportunity (шанс выйти на рынок с хорошим предложением которое продастся).
ну с 1 и 2 ясно
А в каких областях 3 может понадобится? Жаббер-сервер заводить на Raspberry? Пробовал просто Erlang VM запускать на тех железках, для которых пишешь?
P.S. его ж изначально делали для работы на всяких маршрутизатарах и подобных вещах.
Tiendil
> А в каких областях 3 может понадобится? Жаббер-сервер заводить на Raspberry?
> Пробовал просто Erlang VM запускать на тех железках, для которых пишешь?
Люди пробовали уменьшить и публиковали свои результаты.
Варианты вроде 2-6 Мб собственно ВМ с либами + нужна же память для старта. Сошлись на том что 16-20 Мб памяти вообще надо чтоб что-то запустилось.
Моя йоба должна запускаться ниже отметки 2-4 Мб памяти, я над этим работаю, а цель финальная намного ниже > P.S. его ж изначально делали для работы на всяких маршрутизатарах и подобных вещах.
Да. Ну там есть возможность большую систему втыкнуть где обычный Э запустится.
Не жаббер допустим, а вот маленький вебсервер или ХМРР-клиент если на микрожелезке запустить это уже интересно для интернета вещей.
Tiendil
> Зачем?
закономерный вопрос. почему в любой ситуации когда кто-то что-то делает появляется человек с вопросом «зачем?» >_ 18:03, 8 ноя. 2015
Потому, что интересно. Раз человек делает что-то странное, значит надо узнать почему он это делает. Может мне тоже надо это же делать, а я торможу и вообще не в курсе.
Воу. А где спецификации по опкодам и всякому берёшь?
kvakvs
> Потому что не существует ВМ для Эрланга маленького размера, которую можно
> наоптимизировать на маленькие устройства
innuendo
> Тогда С, а не С++
ага, как только ты переименовал свой исходник с main.c на main.cpp размер бинарника сразу же увеличивается в четыре раза, лол
Если ты не в курсе, на маленьких устройствах может и не быть C++
innuendo
Ну это сильно маленькие. Хотя кресто библиотека сама пожирает кучу памяти, так что переименовав из с в спп съешь кучу памяти и этих мягких французских булочек.
Олсо сборщик мусора в виде реального сборщика мусора эрланга на 2мб памяти не взлетит.
innuendo
> Если ты не в курсе, на маленьких устройствах может и не быть C++
оно даже на атмелах есть. куда уж еще меньше-то? )
NightmareZ
> Воу. А где спецификации по опкодам и всякому берёшь?
Из файла спеков в оригинальной ВМ (скудно но для начала хватило).
Из исходников оригинальной ВМ, других хобби-проектов и конкурентов.
d.m.k
> ага, как только ты переименовал свой исходник с main.c на main.cpp размер
> бинарника сразу же увеличивается в четыре раза, лол
Попробуй включить оптимизацию в конжпеляторе. И не использовать буст и iostreams 🙂
innuendo
> Если ты не в курсе, на маленьких устройствах может и не быть C++
Поглядим ещё. На очень маленькое я и не помещусь даже если душу сотоне продам, а если будет клиент то грех и не переписать.
laMer007
> Олсо сборщик мусора в виде реального сборщика мусора эрланга на 2мб памяти не
> взлетит.
Ой да ладно. А на чём летают Java Card и Javascript на 64к устройствах? У них там думаешь аллоки? Тоже небось сборщик.
kvakvs
> И не использовать буст и iostreams 🙂
никогда и не использовал
Разработка стековой виртуальной машины и компилятора под неё (часть I)
В универе преподаватели, молодость которых приходилась на 70-80е годы, до объектно-ориентированного программирования убивались по теме разработке собственных языков (интерпретаторов, компиляторов) под предметные области. Всё это казалось невостребованным «старьём», но появление новых языков за последнее десятилетие (Go, Kotlin и множества других) повысили мой интерес к этой теме.
Решил в качестве хобби написать 32-bit стековую виртуальную машину и компилятор C подобного языка под неё, чтобы восстановить базовые навыки. Такая классическая Computer Science задачка для заполнения вечеров с пивом. Как предприниматель, я четко понимаю, что она никому не нужна, но такая практика нужна мне для эстетического инженерного удовольствия. Плюс когда об этом рассказываешь сам понимаешь глубже. С целью и мотивами определился. Начнём.
Так как это виртуальная машина, мне нужно определиться с её характеристиками:
CPU: 32-bitный набор команд, так как машина стековая, в основном операнды команд храним в стеке, из регистров только IP (Instruction Pointer) и SP (Stack Pointer), пока работаем с целыми числами со знаком (__int32), позже добавим остальные типы данных.
RAM: пусть памяти пока будет 65536 ячеек по 32-bit`а. Которую организуем просто. С нижних адресов в верх будут идти код (code/text) и данные (data, heap), а с верхних адресов вниз будет расти стек (stack). Дёшево и сердито.
Предусмотрим вот такие операции:
Пока первые 8 из 32 бит будут под команду (opcode), 1 бит зарезервируем под тип операнда (immediate или из стека), 3 бита под будущие типы данных (byte, short, int, long, char, float, double и что нибудь ещё), а остальные биты для чего нибудь применим. Пока не знаю.
В классе виртуальной машине сделаем такие методы, чтобы загружать исполняемый образ в память виртуальной машины (loadImage), запускать исполнение (run), позволять снаружи читать/писать память (readWord, writeWord), читать состояние регистров IP, SP. Из приватных методов сделаем вывод состояния машины printState (распечатка регистров и содержания стека), а также метод для системных вызовов systemCall, чтобы код виртуальной машины мог вызывать что-то снаружи (сделаем проброс какого-нибудь API).
В итоге получаем простенькую машину, которая умеет в арифметические и битовые операции, управлять ходом исполнения с условными переходами, вызывать функции (пока четкой конвенции по передачи аргументов через стек нет, но дальше доделаем).
Еще нам потребуется реализовать класс с помощью которого мы будем готовить исполняемый образ. Прикрутим к нему простые методы, которые будут записывать команды и данные, чтобы потом можно было этот образ «скормить» виртуальной машине.
И теперь можно попробовать сделать простенькую программу с циклом и вызовом функции, которая печатает «Hello, world from VM!» 10 раз, чтобы проверить, что виртуальная машина более менее работает. На ассемблере виртуальной машины (мнемоники пока будут очень условные, синтаксис до конца не придумал) это программа будет выглядеть примерно так:
Сейчас лень писать под эту задачу транслятор для ассемблера виртуальной машины, потому что делаем высокоуровневый язык, который будем сходу компилировать в команды виртуальной машины. Но чтобы это записать в исполняемый виртуальной машиной образ воспользуемся классом VMImage:
А затем запустим исполнение нашего образа на виртуальной машине, замерив время:
Получаем в консоли:
Ура! Классно! Работают операции со стеком, арифметика, команды условных переходов и вызов функций! Это воодушевляет. Видимо дальше буду развивать эту историю.
Разработка стековой виртуальной машины и компилятора под неё (итог)
Для завершения реализации компилятора потребовалось около месяца времени (вечерами), чтобы на практике познакомиться с такими темами как BNF (Backus Naur Form), Abstract Syntax Tree (AST), Symbol Table, способами генерации кода, разработки самого компилятора (front-end, back-end), а также модификации виртуальной машины CVM. Ранее с этими темами был не знаком, но благодаря комментаторам погрузился. Хоть затрагиваемых тем много, постараюсь рассказать очень лаконично. Но обо всём по порядку. Если вы не читали предыдущих частей, вот ссылки:
1. Описание грамматики Си подобного языка
По ходу изучения вопроса, заметил, что ещё более наглядно и удобно при разработке компилятора помогает вот такая графическая нотация (название нотации не знаю):
Сначала мы разделим входящий исходный код на последовательность лексем (tokens), о чем было рассказано ранее в предыдущей части II (вот тут). Затем, используя описанные выше грамматики из последовательности лексем построим абстрактное синтаксическое дерево (AST) и таблицу символов, чтобы упростить генерацию кода.
2. Абстрактное синтаксическое дерево (AST)
3. Таблица символов (Symbol Tree)
Допустим, у нас есть вот такой исходный код:
По этому исходному коду должно быть сформировано следующее дерево Таблиц символов, чтобы мы легко могли определять область видимости символов:
Чтобы реализовать дерево таблиц, опишем структуру данных SymbolTable и методы работы с ней. Например, добавлять символы указывая тип, искать символы как в самой таблице, так и в её родительских узлах вплоть до корня. Вот описание:
4. Генерация кода
Теперь, имея построенное AST и таблицу символов, мы можем начать генерировать код, последовательно обходя AST. По ходу генерации кода функций, мы также будем заполняем глобальную таблицу символов указывая фактические адреса функций, чтобы при вызовах могли их найти и указывать:
Вот несколько отрывков исходников по генерации кода блока, IF-ELSE и операторов:
Наряду с пользовательскими функциями в исходном коде скрипта, для ввода и вывода целых чисел в консоли, добавил в компилятор «системные» функции iput (integer put) и iget (integer get). А именно при генерации кода они автоматически добавляются в глобальную таблицу символов и при генерации автоматически заменяются на системный вызов виртуальной машины syscall.
5. Испытание компилятора: факториал
Вычисление факториала на C подобном языке (скрипт factorial.cvm). В коде есть лишние конструкции для проверки их работы (if-else, break):
В результате работы парсера построено следующее дерево:
Следующие таблицы символов:
И сгенерирован код для виртуальной машины:
Результат исполнения скрипта выдаёт:
6. Испытание компилятора: простые числа
Компиляция скрипта вычисления простых чисел (primenumber.cvm):
строит следующее дерево:
и таблицу символов:
Получаем такой сгенерированный код виртуальной машины:
и результат исполнения (верный):
7. В заключение
Подытоживая серию статей о разработке виртуальной машины и компилятора C подобного языка, хотелось бы отметить, что проект получился насыщенным по применению алгоритмов, структур данных и нетривиальных инженерных решений.
Чего только стоили конвенции по вызовам: как передавать аргументы, кто очищает стек. Как аллоцировать локальные переменные на стеке, если их объявление разбросано по телу функции. И много других подобных задач.
Очень интересный хобби-проект! Испытываю воодушевление, что получилось его реализовать и восстановить некоторые навыки программирования C/C++. Спасибо большое комментаторам, вы выступили менторами и направили в нужном направлении!
Хоть вся эта деятельность и является хобби, а днём я предприниматель, опять хочется вечером взяться за новую интересную и не тривиальную задачу по системному программированию. Всё таки в глубине души это моё.
Пишем собственную виртуальную машину
В этом руководстве я расскажу, как написать собственную виртуальную машину (VM), способную запускать программы на ассемблере, такие как 2048 (моего друга) или Roguelike (моя). Если вы умеете программировать, но хотите лучше понять, что происходит внутри компьютера и как работают языки программирования, то этот проект для вас. Написание собственной виртуальной машины может показаться немного страшным, но я обещаю, что тема удивительно простая и поучительная.
Окончательный код составляет около 250 строк на C. Достаточно знать лишь основы C или C++, такие как двоичная арифметика. Для сборки и запуска подходит любая Unix-система (включая macOS). Несколько API Unix используются для настройки ввода и отображения консоли, но они не являются существенными для основного кода. (Реализация поддержки Windows приветствуется).
Примечание: эта VM — грамотная программа. То есть вы прямо сейчас уже читаете её исходный код! Каждый фрагмент кода будет показан и подробно объяснён, так что можете быть уверены: ничего не упущено. Окончательный код создан сплетением блоков кода. Репозиторий проекта тут.
1. Оглавление
2. Введение
Что такое виртуальная машина?
Виртуальная машина — это программа, которая действует как компьютер. Она имитирует процессор с несколькими другими аппаратными компонентами, позволяя выполнять арифметику, считывать из памяти и записывать туда, а также взаимодействовать с устройствами ввода-вывода, словно настоящий физический компьютер. Самое главное, VM понимает машинный язык, который вы можете использовать для программирования.
Сколько аппаратного обеспечения имитирует конкретная VM — зависит от её предназначения. Некоторые VM воспроизводят поведение одного конкретного компьютера. У людей больше нет NES, но мы всё ещё можем играть в игры для NES, имитируя аппаратное обеспечение на программном уровне. Эти эмуляторы должны точно воссоздать каждую деталь и каждый основной аппаратный компонент оригинального устройства.
Другие VM не соответствуют никакому конкретному компьютеру, а частично соответствуют сразу нескольким! В первую очередь это делается для облегчения разработки ПО. Представьте, что вы хотите создать программу, работающую на нескольких компьютерных архитектурах. Виртуальная машина даёт стандартную платформу, которая обеспечивает переносимость. Не нужно переписывать программу на разных диалектах ассемблера для каждой архитектуры. Достаточно сделать только небольшую VM на каждом языке. После этого любую программу можно написать лишь единожды на языке ассемблера виртуальной машины.
Примечание: компилятор решает подобные проблемы, компилируя стандартный высокоуровневый язык для разных процессорных архитектур. VM создаёт одну стандартную архитектуру CPU, которая симулируется на различных аппаратных устройствах. Одно из преимуществ компилятора в том, что отсутствуют накладные расходы во время выполнения, как у VM. Хотя компиляторы хорошо работают, написание нового компилятора для нескольких платформ очень трудно, поэтому VM всё ещё полезны. В реальности на разных уровнях и VM, и компиляторы используются совместно.
Виртуальная машина Java (JVM) — очень успешный пример. Сама JVM относительно среднего размера, она достаточно мала для понимания программистом. Это позволяет писать код для тысяч разнообразных устройств, включая телефоны. После реализации JVM на новом устройстве любая написанная программа Java, Kotlin или Clojure может работать на нём без изменений. Единственными затратами будут только накладные расходы на саму VM и дальнейшее абстрагирование от машинного уровня. Обычно это довольно хороший компромисс.
VM не обязательно должна быть большой или вездесущной, чтобы обеспечить аналогичные преимущества. Старые видеоигры часто использовали небольшие VM для создания простых скриптовых систем.
VM также полезны для безопасной изоляции программ. Одно из применений — сборка мусора. Не существует тривиального способа реализовать автоматическую сборку мусора поверх C или C++, так как программа не может видеть собственный стек или переменные. Однако VM находится «вне» запущенной программы и может наблюдать все ссылки на ячейки памяти в стеке.
Ещё один пример такого поведения демонстрируют смарт-контракты Ethereum. Смарт-контракты — это небольшие программы, которые выполняются каждым узлом валидации в блокчейне. То есть операторы разрешают выполнение на своих машинах любых программ, написанных совершенно незнакомыми людьми, без какой-либо возможности изучить их заранее. Чтобы предотвратить вредоносные действия, они выполняются на VM, не имеющей доступа к файловой системе, сети, диску и т.д. Ethereum — также хороший пример переносимости. Благодаря VM можно писать смарт-контракты без учёта особенностей множества платформ.
3. Архитектура LC-3
Наша VM будет симулировать вымышленный компьютер под названием LC-3. Он популярен для обучения студентов ассемблеру. Здесь упрощённый набор команд по сравнению с x86, но сохраняются все основные концепции, которые используются в современных CPU.
Во-первых, нужно сымитировать необходимые аппаратные компоненты. Попытайтесь понять, что представляет собой каждый компонент, но не волнуйтесь, если не уверены, как он вписывается в общую картину. Начнём с создания файла на С. Каждый фрагмент кода из этого раздела следует поместить в глобальную область видимости этого файла.
Память
В компьютере LC-3 есть 65 536 ячеек памяти (2 16 ), каждая из которых содержит 16-разрядное значение. Это означает, что он может хранить всего 128 Кб — намного меньше, чем вы привыкли! В нашей программе эта память хранится в простом массиве:
Регистры
Регистр — это слот для хранения одного значения в CPU. Регистры подобны «верстаку» CPU. Чтобы он мог работать с каким-то фрагментом данных, тот должен находиться в одном из регистров. Но поскольку регистров всего несколько, в любой момент времени можно загрузить только минимальный объём данных. Программы обходят эту проблему, загружая значения из памяти в регистры, вычисляя значения в другие регистры, а затем сохраняя окончательные результаты обратно в память.
В компьютере LC-3 всего 10 регистров, каждый на 16 бит. Большинство из них —общего назначения, но некоторым назначены роли.
Как и память, будем хранить регистры в массиве:
Набор инструкций
Инструкция — это команда, которая говорит процессору выполнить какую-то фундаментальную задачу, например, сложить два числа. У инструкции есть опкод (код операции), указывающий тип выполняемой задачи, а также набор параметров, которые предоставляют входные данные для выполняемой задачи.
Каждый опкод представляет собой одну задачу, которую процессор «знает», как выполнить. В LC-3 всего 16 опкодов. Компьютер может вычислить только последовательность этих простых инструкций. Длина каждой инструкции 16 бит, а левые 4 бита хранят код операции. Остальные используются для хранения параметров.
Позже подробно обсудим, что делает каждая инструкция. На данный момент определите следующие опкоды. Удостоверьтесь, что сохраняют такой порядок, чтобы получать правильное значение enum:
Примечание: в архитектуре Intel x86 сотни инструкций, в то время как в других архитектурах, таких как ARM и LC-3, очень мало. Небольшие наборы инструкций называются RISC, а более крупные — CISC. Большие наборы инструкций, как правило, не предоставляют принципиально новых возможностей, но часто упрощают написание ассемблерного кода. Одна инструкция CISC может заменить несколько инструкций RISC. Однако процессоры CISC более сложны и дороги в проектировании и производстве. Это и другие компромиссы не позволяют назвать «оптимальный» дизайн.
Флаги условий
У каждого процессора множество флагов состояния для сигнализации о различных ситуациях. LC-3 использует только три флага условий, которые показывают знак предыдущего вычисления.
Мы закончили настройку аппаратных компонентов нашей виртуальной машины! После добавления стандартных включений (см. по ссылке выше) ваш файл должен выглядеть примерно так:
Здесь указаны ссылки на пронумерованные разделы статьи, откуда берутся соответствующие фрагменты кода. Полный листинг см. в рабочей программе — прим. пер.
4. Примеры на ассемблере
Теперь рассмотрим программу на ассемблере LC-3, чтобы получить представление, что фактически выполняет виртуальная машина. Вам не нужно знать, как программировать на ассемблере, или всё тут понимать. Просто постарайтесь получить общее представление, что происходит. Вот простой «Hello World»:
Как и в C, программа выполняет по одному оператору сверху вниз. Но в отличие от C, здесь нет вложенных областей <> или управляющих структур, таких как if или while ; только простой список операторов. Поэтому его гораздо легче выполнить.
Обратите внимание, что имена некоторых операторов соответствуют опкодам, которые мы определили ранее. Мы знаем, что в инструкциях по 16 бит, но каждая строка выглядит как будто с разным количеством символов. Как возможно такое несоответствие?
Это происходит потому что код, который мы читаем, написан на ассемблере — в удобочитаемой и доступной для записи форме обычным текстом. Инструмент, называемый ассемблером, преобразует каждую строку текста в 16-разрядную двоичную инструкцию, понятную виртуальной машине. Эта двоичная форма, которая по сути представляет собой массив 16-разрядных инструкций, называется машинным кодом и фактически выполняется виртуальной машиной.
Примечание: хотя компилятор и ассемблер играют схожую роль в разработке, они не одинаковы. Ассемблер просто кодирует то, что программист написал в тексте, заменяя символы их двоичным представлением и упаковывая их в инструкции.
Циклы и условия выполняются с помощью goto-подобной инструкции. Вот еще один пример, который считает до 10.
Примечание: для этого руководства необязательно учиться ассемблеру. Но если вам интересно, можете написать и собрать собственные программы LC-3 с помощью LC-3 Tools.
5. Выполнение программ
Ещё раз, предыдущие примеры просто дают представление, что делает VM. Для написания VM вам не нужно полное понимание ассемблера. Пока вы следуете соответствующей процедуре чтения и исполнения инструкций, любая программа LC-3 будет корректно работать, независимо от её сложности. В теории, VM может запустить даже браузер или операционную систему, как Linux!
Если глубоко задуматься, то это философски замечательная идея. Сами программы могут производить сколь угодно сложные действия, которые мы никогда не ожидали и, возможно, не сможем понять. Но в то же время вся их функциональность ограничивается простым кодом, который мы напишем! Мы одновременно знаем всё и ничего о том, как работает каждая программа. Тьюринг упоминал эту чудесную идею:
«Мнение о том, что машины не могут чем-либо удивить человека, основывается, как я полагаю, на одном заблуждении, которому в особенности подвержены математики и философы. Я имею в виду предположение о том, что коль скоро какой-то факт стал достоянием разума, тотчас же достоянием разума становятся все следствия из этого факта». — Алан М. Тьюринг
Процедура
Вот точное описание процедуры, которую нужно написать:
Начнём изучение этого процесса на примере основного цикла:
6. Реализация инструкций
Теперь ваша задача — сделать правильную реализацию для каждого опкода. Подробная спецификация каждой инструкции содержится в документации проекта. Из спецификации нужно узнать, как работает каждая инструкция, и написать реализацию. Это проще, чем кажется. Здесь я продемонстрирую, как реализовать две из них. Код для остальных можно найти в следующем разделе.
Инструкция ADD берёт два числа, складывает их и сохраняет результат в регистре. Спецификация в документации на стр. 526. Каждая инструкция ADD выглядит следующим образом:
Таким образом, мы знаем, где сохранить результат, и знаем первое число для сложения. Осталось только узнать второе число для сложения. Здесь две строки начинают различаться. Обратите внимание, что вверху 5-й бит равен 0, а внизу — 1. Этот бит соответствует или непосредственному режиму, или регистровому режиму. В регистровом режиме второе число хранится в регистре, как и первое. Оно отмечено как SR2 и содержится в битах со второго по нулевой. Биты 3 и 4 не используются. На ассемблере это будет написано так:
В непосредственном режиме вместо добавления содержимого регистра непосредственное значение внедряется в саму инструкцию. Это удобно, потому что программе не нужны дополнительные инструкции для загрузки этого числа в регистр из памяти. Вместо этого оно уже внутри инструкции, когда нам нужно. Компромисс в том, что там могут храниться только небольшие числа. Если быть точным, максимум 2 5 =32. Это наиболее полезно для увеличения счётчиков или значений. На ассемблере можно написать так:
Вот выдержка из спецификации:
Если бит [5] равен 0, то второй исходный операнд получают из SR2. Если бит [5] равен 1, то второй исходный операнд получают путём расширения значения imm5 до 16 бит. В обоих случаях второй исходный операнд добавляется к содержимому SR1, а результат сохраняется в DR. (стр. 526)
Примечание: если вас интересуют двоичные отрицательные числа, можете прочитать о дополнительном коде. Но это не существенно. Достаточно просто скопировать код выше и использовать его, когда спецификация говорит расширить значение.
В спецификации есть последнее предложение:
Коды условий задаются в зависимости от того, является ли результат отрицательным, нулевым или положительным. (стр. 526)
Ранее мы определили условие flags enum, а теперь пришло время использовать этих флаги. Каждый раз, когда значение записывается в регистр, нам нужно обновить флаги, чтобы указать его знак. Напишем функцию для повторного использования:
Теперь мы готовы написать код для ADD :
В этом разделе много информации, поэтому подведём итоги.
LDI означает «косвенную» или «непрямую» загрузку (load indirect). Эта инструкция используется для загрузки в регистр значения из места в памяти. Спецификация на стр. 532.
Вот как выглядит двоичная компоновка:
Это может показаться окольным путём для чтения из памяти, но так нужно. Инструкция LD ограничена адресным смещением 9 бит, тогда как память требует для адреса 16 бит. LDI полезна для загрузки значений, которые хранятся где-то за пределами текущего компьютера, но для их использования адрес конечного местоположения должен храниться рядом. Вы можете думать о ней как о локальной переменной в C, которая является указателем на некоторые данные:
Как и раньше, после записи значения в DR следует обновить флаги:
Коды условий задаются в зависимости от того, является ли результат отрицательным, нулевым или положительным. (стр. 532)
Вот код для данного случая: ( mem_read обсудим в следующем разделе):
7. Шпаргалка по инструкциям
В этом разделе — полные реализации оставшихся инструкций, если вы застряли.