Байт-код

Байт-код или байтко́д (byte-code), иногда также используется термин псевдоко́д — машинно-независимый код низкого уровня, генерируемый транслятором и исполняемый интерпретатором. Большинство инструкций байт-кода эквивалентны одной или нескольким командам ассемблера. Трансляция в байт-код занимает промежуточное положение между компиляцией в машинный код и интерпретацией.
Байт-код называется так, потому что длина каждого кода операции — один байт, но длина кода команды различна. Каждая инструкция представляет собой однобайтовый код операции от 0 до 255, за которым следуют такие параметры, как регистры или адреса памяти. Это в типичном случае, но спецификация байт-кода значительно различается в языке.
Программа на байт-коде обычно выполняется интерпретатором байт-кода (обычно он называется виртуальной машиной, поскольку подобен компьютеру). Преимущество — в портируемости, т. е. один и тот же байт-код может исполняться на разных платформах и архитектурах. То же самое преимущество дают интерпретируемые языки. Однако, поскольку байт-код обычно менее абстрактный, более компактный и более «компьютерный» чем исходный код, эффективность байт-кода обычно выше чем чистая интерпретация исходного кода, предназначенного для правки человеком. По этой причине многие современные интерпретируемые языки на самом деле транслируют в байт-код и запускают интерпретатор байт-кода. К таким языкам относятся Perl, PHP и Python. Программы на Java обычно передаются на целевую машину в виде байт-кода, который перед исполнением транслируется в машинный код «на лету» — с помощью JIT-компиляции. В стандарте открытых загрузчиков Open Firmware фирмы Sun Microsystems байт код представляет операторы языка Forth.
В то же время возможно создание процессоров, для которых данный байт-код является непосредственно машинным кодом (такие процессоры существуют, например, для Java и Forth).
Также некоторый интерес представляет p-код (p-code), который похож на байт-код, но физически может быть менее лаконичным и сильно варьироваться по длине инструкции. Он работает на очень высоком уровне, например «напечатать строку» или «очистить экран». P-код повсеместно используется в СУБД и некоторых реализациях BASIC и Паскаля.
Языки и среды программирования, использующие байткод
- Байткод Java выполняется виртуальной машиной Java (Java virtual machine).
- Smalltalk.
- Python.
- Виртуальная машина Parrot
- Платформа Microsoft .NET использует Intermediate Language (IL), исполняемый с помощью Common Language Runtime (CLR). См. Управляемый код.
- PHP.
- Forth
Ссылки
Понимаем байт-код EVM: Часть 1
Если Вы начали читать эту статью, я думаю, вы уже знаете, что означает EVM. Если Вам действительно нужны некоторые основы, пожалуйста, загуглите “Ethereum Virtual Machine”. Основная цель этой серии статей — помочь разобраться с байт-кодом EVM на случай, если вы будете вовлечены в какую-либо работу по аудиту контрактов на уровне байт-кода или разрабатывать декомпилятор байт-кода EVM.
Теперь давайте начнем с некоторых самых базовых элементов байт-кода EVM. EVM — это виртуальная машина на основе стека. Если у вас есть опыт работы с любой из подобных виртуальных машин (например, Java VM, DVM, .NET VM), вам не составит особого труда понять ее основную идею.
По сути, байт-код — это машинный язык для виртуальной машины. Этот код, безусловно, не предназначен для чтения человеком так же, как обычный человеческий код. Байт-код может быть скомпилирован с помощью высокоуровневых языков EVM.
На данный момент самым популярным из них является Solidity. Чтобы лучше понять байт-код виртуальной машины, я буду использовать несколько простых примеров для демонстрации. Итак, давайте начнем с самого простого примера:
Вы можете спросить, почему я не использовал HelloWorld в качестве стандартного примера. Это связано с тем, что обычно в примере HelloWorld используется строковая переменная, а для нашего байт-кода строковая переменная является динамической переменной длины, и позже мы поговорим об этом в другой статье.
После компиляции кода мы получаем следующий байт-код:
Полная строка: 608060405234801561001057600080fd5b5060fd8061001f6000396000f3006080604052600436106049576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff1680631003e2d214604e578063b69ef8a814608c575b600080fd5b348015605957600080fd5b5060766004803603810190808035906020019092919050505060b4565b6040518082815260200191505060405180910390f35b348015609757600080fd5b50609e60cb565b6040518082815260200191505060405180910390f35b600081600054016000819055506000549050919050565b600054815600a165627a7a72305820006fbc72124720df0674199be8fdbc61b441b312b57ede5096a750c7264f8c330029
Мы подробно разберем, что означает эта строка.
Во-первых, если вы внимательно посмотрите на строку, вы поймете, что это строка шестнадцатеричного формата для представления фрагмента двоичного файла.
Действительно, реальный байткод виртуальной машины на самом деле представляет собой двоичную строку, но для того, чтобы лучше показать его другим, он всегда должен быть представлен в шестнадцатеричном формате.
Код операции — это инструкция EVM. Каждый код операции сам по себе представляет собой 8-битное беззнаковое целое число. Например, 0x00 означает STOP, 0x01 означает ADD. Чтобы понять все значения кодов операций, можно подглядеть в желтую бумагу Ethereum.
На данный момент мы не будем рассматривать все коды операций, чтобы объяснить их значения. Нам просто нужно знать их основы и объяснить новые коды операций, когда с ними столкнемся. Итак, давайте начнем с первой части байткода EVM: 6080604052.
Если мы заглянем в желтую бумагу, то обнаружим там следующие коды:
Из приведенного выше фрагмента кода мы можем видеть 2 кода операции, PUSH1 и MSTORE. PUSH1 означает поместить 1-байтовое целое число в стек для дальнейшего использования. PUSHы могут быть вплоть до 32.
В EVM все целые числа имеют длину от 1 до 32 байт. Коды операций семейства PUSH — единственные, которые поставляются с операндами в байткоде виртуальной машины, потому что для остальных кодов операций они будут использовать значения в стеке.
В этом примере первые два PUSH1 поместят 0x80 и 0x40 в стек, затем MSTORE будет использовать 2 элемента в стеке для операции записи в память. Итак, приведенный выше фрагмент кода на самом деле является кодом EVM на ассемблере: mstore(0x40,0x80).
После того, как MSTORE использует 2 элемента в стеке, они будут извлечены. Обычно результат кода операции будет помещен в стек для последующего использования. Однако MSTORE не имеет возвращаемого значения, поэтому он ничего не поместит в стек.
Таким образом, если вы продолжите просматривать весь байт-код, вы получите весь список кодов операций. Но прежде чем мы продолжим изучение дополнительных кодов операций, давайте поговорим еще о двух концепциях в среде EVM — memory и storage.
Memory — это читаемая и записываемая структура, предназначенная для вычисления хэша и внешних вызовов или возвратов. Память сбрасывается как стек всякий раз, когда запускается EVM. Отличие от стека заключается в том, что доступ к памяти возможен по адресу.
В предыдущем примере MSTORE сохранит указанное значение 0x80 в соответствующий адрес 0x40. Вы можете задаться вопросом о значении этого действия. На самом деле, адрес 0x40 в памяти EVM зарезервирован для “указателя свободной памяти”, поэтому, когда коду EVM потребуется использовать некоторую память, он получит указатель свободной памяти из 0x40. Кроме того, если вы не хотите, чтобы эта память была переполнена будущей операцией, вам необходимо обновить значение в 0x40, чтобы будущая операция больше не использовала ту же память.
Помимо memory и стека, storage-переменные — это те, которые содержат состояния. Таким образом, переменные storage не будут сбрасываться каждый раз при перезапуске EVM. Вы можете использовать storage как словарь или хэш-таблицу. Все, что изменилось в storage, будет записано в блокчейн Ethereum. Коды операций, связанные с хранением, — это SLOAD и SSTORE. Мы подробнее поговорим о storage-переменных при анализе более сложных структур, таких как mapping или array.
Основываясь на этой информации, давайте продолжим работу со строкой байткода:
Этот фрагмент кода немного длинный, но не беспокойтесь об этом. Давайте пройдемся по этому вопросу шаг за шагом.
CALLVALUE поместит msg.value в стек, затем DUP1 продублирует это значение в стеке и проверит, равно ли оно 0 или нет, используя ISZERO. Если значение ISZERO, полученное из стека, равно 0, этот код операции поместит значение TRUE в стек для следующих инструкций.
Следующий PUSH2 поместит кодовый адрес 0x0010 в стек для перехода. JUMP — это инструкция условного перехода, которая использует 2 элемента из стека. Один предназначен для результата условия, а другой — для адреса перехода. Если условие (в данном случае это значение ISZERO(msg.value)) выполнено, выполнение перейдет к 0x0010, в противном случае код завершится REVERT(0,0).
Таким образом, байт-код с адреса 0x05—0x0F можно перевести как: if(msg.value != 0) revert();
Причина, по которой мы не увидели эту строку в нашем исходном коде, заключается в том, что эта проверка была введена компилятором для non-payable функции.
Если вы упорядочите стек вручную, вы можете увидеть, что существует инструкция CODECOPY(0x0,0x001F,0xC7).
Это означает, что он скопирует код 0xC7 байт из смещения 0x1F в память (0x0, 0xC7). Затем код вызовет RETURN(0x0,0xC7), чтобы передать скопированные данные обратно в EVM. До сих пор Вы, возможно, догадывались о логике этой операции и о том, какова функциональность этого фрагмента байт-кода.
Скорее всего, весь фрагмент байт-кода, сгенерированный компилятором Remix, состоит из нескольких частей. Набор из 0-0x1E является частью контракта для создания. Этот код будет вызываться только во время создания смарт-контракта. Он вызовет конструктор контракта, а также скопирует часть кода во время выполнения в EVM для создания.
После создания учетной записи контракта будет вызвана часть кода из 0x1F-(0x1F+0xC7) для будущих транзакций по этому контракту, и функция конструктора больше не будет вызываться. Кроме того, вы, возможно, обнаружили, что в части создания байт-кода нет никаких инструкций JUMP или JUMPI.
Чтобы доказать правильность наших предположений, давайте создадим другой код Solidity с функцией конструктора:
После компиляции его с помощью Remix мы получаем такой код:
По-видимому, код длиннее предыдущего, так как мы определили там функцию конструктора. Итак, давайте разберем код операции на более читаемые коды:
Мы можем видеть некоторый аналогичный код, установленный в начале и в конце. Но код, установленный между 0x12 и 0x25, является новым. Итак, давайте сосредоточимся на этой новой части.
Сначала, в коде операции, установленном 0x12 и 0x14, была вызвана MLOAD(0x40) для получения значения из памяти по адресу 0x40. Из предыдущего раздела мы уже знали, что адрес 0x40 в памяти содержит указатель свободной памяти в EVM. В данном случае это 0x80.
Затем, после упорядочивания стека с помощью PUSH и DUP, он будет иметь [. 0x20, 0x00FA, 0x80] в стеке перед вызовом CODECOPY. Таким образом, код вызовет CODECOPY(0x80, 0x00FA, 0x20).
По-видимому, это действие не было показано в предыдущем демонстрационном байт-коде. Это как-то связано с новым кодом, который мы поместили внутри функции конструктора. Он копирует последние 32 байта данных из кода в адрес свободной памяти. Скорее всего, это значение параметра во время развертывания контракта. Давайте продолжим работу с более поздним байт-кодом.
В наборе инструкций 0x1D – 0x21 код добавил 0x20 к текущему указателю свободной памяти 0x80 и сохранил его обратно по адресу 0x40 с помощью MSTORE(0x40, 0x80+0x20).
Затем команда в 0x22 поместит значение, возвращаемое MLOAD(0x80), в стек, который представляет собой 32-байтовое значение, скопированное из кода. Более поздний код в 0x23, 0x25 сохранит значение в хранилище со смещением 0x0, используя SSTORE(0x0, LOAD(0x80)). Итак, вкратце, инструкции между 0x12 и 0x25 в основном выполняют некоторую операцию, подобную SSTORE(0x0,CODECOPY(0x80, 0x00FA, 0x20)).
По-видимому, во время развертывания нового контракта инициализированные параметры указываются в конце байт-кода виртуальной машины в полезной нагрузке данных транзакции. Затем в процессе создания функция конструктора получит параметр с помощью CODECOPY.
До сих пор мы говорили об основах байт-кода EVM, включая три типа структур данных в EVM: стек, память и хранилище, некоторые обычные коды операций, участвующие в создании смарт-контракта, способ передачи параметров конструктора и структуру скомпилированного байт-кода EVM.
Зачем нужен байт-код?
Я работаю с Python и C#, и я понимаю, зачем нужен байт-код (и зачем нужен в других интерпретируемых языках). Платформонезависимость — это действительно здорово.
Но зачем заставлять целевую машину пережёвывать байт-код посредством виртуальной машины/JIT-компилятора? Почему нельзя один раз скомпилировать под целевую архитектуру при первом запуске/обращении или при установке/деплое (Ahead-Of-Time, короче)?
Причина, по которой возник сей глупый вопрос, так же глупа и банальна: ведь программа, написанная на C, будет работать быстрее аналогичной по функционалу программы, написанной на любом интерпретируемом языке.
- Вопрос задан более трёх лет назад
- 8204 просмотра
Комментировать
Решения вопроса 0
Ответы на вопрос 2
Знаю и умею всё
Особенность многих jit компиляторов в том что они умеют оптимизировать код на лету, используя статистику выполнения программы,
например hotpath оптимизация считает количество попаданий в ту или иную часть программы, и генерит машинный код только для кусков кода где программа реально часто выполняется.
Что это дает: за счет этого jit оптимизатор может разместить куски часто выполняющегося машинного кода очень близко друг к другу — так что они все целиком будут умещаться например в кэше процессора, и да — jit компилятор порой за счет этого обгоняет прекомпилированный машинный код.
Есть еще куча оптимизаций например касающаяся языков которые поддерживают closures, как показывает практика большинство клозур используются в коде с одинаковыми переменными окружения, что позволяет не выполнять кучу работы по сохранению окружения и тп — а просто заинлайнить клозуру — другое дело что на этапе компиляции понять это невозможно, а вот на этапе выполнения сохранить hash окружения и если он не меняется то инлайнить код — легко
Есть еще куча подобных оптимизаций которые реально помогают динамическим языкам работать почти наравне по скорости с С на некоторых задачах, яркий пример luajit
И до кучи динамические языки зачастую невозможно заранее перенести в native код чтобы сам этот код не прератился в некорый интерпретатор байткода, вот хорошо про это написано:
stackoverflow.com/questions/15626611/can-regular-j.
(c# кстати нединамический поэтому для него насколько я помню была какая то тулзень для прекомпиляции в native код — но я уже лет сто 😉 не писал на c# поэтому точно не помню)
Разница между машинным кодом и байт-кодом
В статье рассматривается ключевая разница между байт-кодом и машинным кодом на примере Java. Байт-код — это промежуточное представление программы, состоящее из инструкций, которые выполняются виртуальной машиной, в то время как машинный код — это непосредственно исполняемый код, специфичный для конкретной аппаратной платформы (компьютера).
Байт-код
Байт-код — это низкоуровневое платформенно-независимое промежуточное представление программы, которое выполняется виртуальной машиной. Одним из примеров языков программирования, использующих байт-код, является Java. Байт-код представляет собой результат компиляции программы, например:

Байт-код выполняется любой подходящей реализацией виртуальной машины (JVM для Java), независимо от того, на какой ОС она установлена. Таким образом, байт-код позволяет однажды скомпилированной программе выполняться на разных платформах, поддерживающих соответствующую виртуальную машину.
Исходный код Java-программы содержится в .java-файлах. Они состоят из объявления и реализации классов, методов, переменных и других конструкций. Файлы .java являются текстовыми файлами, понятными разработчику, и используются в процессе разработки и отладки программы. Файлы .class представляют собой бинарные файлы, содержащие скомпилированный байт-код Java. Они генерируются компилятором Java и являются платформенно-независимым представлением Java-программы. Файлы .class содержат инструкции, понятные для JVM.
При компиляции Java-программы для каждого Java-класса создается соответствующий .class файл, в котором находится скомпилированное в байт-код представление Java-класса. Если в одном .java файле находится несколько классов, то будет создано несколько соответствующих .class файлов. При этом в каждом .class файле содержится представление только одного Java-класса. Подробно о классах мы поговорим позже, а пока можно считать, что Java-класс — это основная структурная единица Java-программы.

Машинный код
Машинный код — это набор инструкций, которые понятны процессору конкретной архитектуры. Он является бинарным (состоит из 0 и 1 ). Различные типы процессоров имеют свои собственные наборы инструкций и форматы машинного кода. Процессор способен понимать и выполнять только машинные инструкции, поэтому программы, написанные на высокоуровневых языках программирования, должны быть скомпилированы в машинный код, чтобы быть исполнимыми на конкретной платформе. В случае с компилируемыми языками программирования, такими как С/C++ или Go, все так и происходит: исходный код приложения компилируется сразу в машинный код целевой платформы (компьютера). Однако интерпретируемые языки программирования, такие как Python или R, позволяют генерировать машинные инструкции во время выполнения программы интерпретатором. Java является гибридом, который совмещает компилируемый и интерпретируемый подходы. Сперва Java-приложение компилируется в промежуточное представление — байт-код, который затем может быть выполнен любым подходящим JRE, используя реализацию JVM:

Примечание: Зачастую JVM посредством JIT-компилятора оптимизирует машинный код, чтобы уменьшить количество инструкций, необходимых для выполнения программы, и отправляет машинные инструкции оптимизированными пакетами — «пачками». Принцип работы JIT-компилятора заключается в том, что при запуске Java-приложения, код программы сначала интерпретируется виртуальной машиной Java (JVM) построчно, а затем, если какой-то участок кода многократно повторяется, этот участок компилируется в машинный код (который напрямую выполняется центральным процессором) посредством JIT-компилятора. Также JIT автоматически оптимизирует код, убирая неиспользуемые инструкции и применяя другие оптимизации, что также способствует ускорению работы приложения.
Разница между машинным кодом и байт-кодом
| Байт-код | Машинный код |
| Промежуточное представление программы. | Набор низкоуровневых инструкций, понятных процессору определенной архитектуры. |
| Платформенно-независимый (может выполняться на любой платформе, на которой установлена подходящая виртуальная машина). | Платформенно-зависимый. |
| Выполняется виртуальной машиной. | Выполняется CPU. |
| Генерируется компилятором из исходного кода программы на языке программирования интерпретируемого типа (однако не каждый интерпретируемый язык программирования использует байт-код). | Является результатом компиляции исходного кода программы на языке программирования компилируемого типа. |