Понимаем байт-код EVM: Часть 1
Оригинал — https://blog.trustlook.com/understand-evm-bytecode-part-1/
Канал — https://t.me/jetix37eth
Если Вы начали читать эту статью, я думаю, вы уже знаете, что означает 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 мы получаем такой код:
608060405234801561001057600080fd5b5060405160208061014683398101806040528101908080519060200190929190505050806000819055505060fd806100496000396000f3006080604052600436106049576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff1680631003e2d214604e578063b69ef8a814608c575b600080fd5b348015605957600080fd5b5060766004803603810190808035906020019092919050505060b4565b6040518082815260200191505060405180910390f35b348015609757600080fd5b50609e60cb565b6040518082815260200191505060405180910390f35b600081600054016000819055506000549050919050565b600054815600a165627a7a723058209494c57a0075e1335b7b0adeabecca32387ff8db3d573d798daa361f0d6f7eb90029
По-видимому, код длиннее предыдущего, так как мы определили там функцию конструктора. Итак, давайте разберем код операции на более читаемые коды:
Мы можем видеть некоторый аналогичный код, установленный в начале и в конце. Но код, установленный между 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.