EVM часть 1: загрузка пустого смарт-контракта

В этой статье мы познакомимся с тем:
  1. Что такое EVM
  2. Зачем нужна EVM
  3. Что делает пустой смарт-контракт с EVM

Следующая статья.

Полный список статей по теме тут.

Заходите в наш телеграмм канал — Blockchain Witnesses! Делитесь опытом или задавайте вопросы, если что-то непонятно.

Что такое EVM и зачем оно надо?

EVM — расшифровывается как Ethereum Virtual Machine. Или — виртуальная машина — название намекает, что это виртуальный компьютер. Т.е. это программа, которая моделирует работу компьютера. Моделируется работу процессора, памяти и других составляющих.
К чему такие сложности?
Все компьютеры на низком уровне имеют свою систему простых команд — инструкций. Этот набор инструкций — машинный язык — ассемблер.
Язык Solidity как и другие языки программирования адаптированы под человека. Имеют понятные название команд, разделение на модули, названия переменных, чтобы быстро читать код и запоминать и так далее. Компьютеру все это не нужно. Более того, компьютеру сложно понимать наши команды. Поэтому наши языки программирования транслируются в ассемблер перед выполнением.
Но если мы можем выполнять ассемблер на компьютере, зачем нам еще виртуальный компьютер? А затем, что ассемблер у разных компьютеров разный. Ассемблер компьютера Apple может сильно отличаться от ассемблера смартфона Android.

Хорошо, мы можем написать под каждый компьютер транслятор solidity в ассемблер. Ведь это так и так придется сделать.

А теперь представьте, что помимо Solidity мы хотим еще один язык для Ethereum. Например, Vyper. Получается, что еще и для Vyper придется писать транслятор под каждый компьютер.
Проще иметь один универсальный ассемблер, в который мы транслируем все языки Ethereum. А этот ассемблер мы один раз транслируем на каждый компьютер. Так вместо нескольких трансляторов нового языка эфира мы пишем один транслятор в ассемблер только под виртуальную машину EVM!
Поэтому мы имеем виртуальную машину ethereum, которая имеет свой универсальный машинный язык. Конечно, помимо этих причин, виртуальные машины имеют ряд преимуществ. Я Вам показал самое очевидное и простое.
Как мы уже упоминали, EVM это виртуальный компьютер со своей архитектурой и со своим ассемблером. Пока глубоко в архитектуру мы погружаться не будем. Скажем только, что архитектура EVM проста: процессор, память, стэк.

Искушенный читатель может спросить: «A как же регистры?». Регистров, которыми бы можно было явно управлять нет. И тем не менее можно сказать, что есть указатель на текущую инструкцию — pc, на вершину стэка. Однако, в явном виде инструкций для работы с ними нету.

Процессор исполняет инструкции, которые меняют стэк и память. А инструкции ассемблера примитивны. А оттого и непривычны.

В высокоуровневых языках программирования мы привыкли, что функции принимают аргументы и возвращают значения. И записываются они примерно так:

result = function(argument1, argument2)

Мы не задумываемся, где хранятся аргументы во время передачи функции и где хранится результат.

В ассемблере EVM аргументы для инструкций в основном передаются через стэк. Результат работы инструкции, если он есть, тоже записывается в стэк. Все делается через стэк. Т.е. если мы хотим выполнить инструкцию нам нужно:

  1. Положить аргументы в стэк
  2. Вызвать инструкцию
  3. Инструкция заберет аргументы из стэка
  4. Положит результат в стэк
  5. Теперь мы можем забрать результат из стэка

Важно: инструкции именно забирают аргументы из стэка. Т.е. читают и удаляют.

Чтобы было более понятно, давайте рассмотрим пример.

Пример выполнения инструкции

Давайте попробуем сложить два числа 1 и 2. Для этого нам понадобятся две инструкции:

Инструкция Что делает Что должно быть в стэке Что будет в стэке
PUSH1 X кладет в стэк X X
ADD складывает два значения из стэка A, B A + B

Алгоритм работы будет такой:

  1. Кладем первый аргумент в стэк
  2. Кладем второй аргумент в стэк
  3. Выполняем сложение
На ассемблере  EVM это будет выглядеть так:

После выполнения этих инструкций на вершине стэка у нас будет результат сложения — 3.

Список всех инструкций можно посмотреть тут.

Что делает пустой смарт-контракт

 Мы, как разработчики Solidity, привыкли, что смарт-контракт просто загружается в блокчейн, и , может быть, выполняется конструктор. Однако, эта простая загрузка в блокчейн на самом деле довольно таки интересный процесс. И мы на него сейчас посмотрим.

Для этого сделаем простой смарт-контракт:


Скомпилируем его, скажем в Remix и скопируем байт-код. Для этого в разделе компилятора, нажмем на кнопочку — compile. А затем на кнопочку bytecode.

В результате получим, что-то вроде этого:
6080604052348015600e575f80fd5b50603e80601a5f395ff3fe60806040525f80fdfea26469706673582212
204432e7be2a99ff8c0491fc93d2c2f1a04e825e634bceb7b4c0572d279081553464736f6c63430008190033
Это есть байткод. И его мы можем расшифровать согласно вот этой таблице кодов инструкций.
Чтобы ручками не расшифровывать воспользуемся декомпилятором. Например, вот этим.
На самом деле декомпиляторы немного туповатые и расшифровывают байткод в лоб. Т.е. они не задумываются, что какая-то часть байт-кода может быть просто данными.
Забегая вперед, скажу что последняя байт байткода — это так называемые auxularity данные. Данные, в которых содержится вспомогательная мета-информация. Например — версия solidity, hash. И так далее.
Код

6080604052348015600e575f80fd5b50603e80601a5f395ff3fe60806040525f80fdfe

auxularity — Метаданные
a26469706673582212204432e7be2a99ff8c0491fc93d2c2f1a04e825e634bceb7b4c0572d279081553464736f6c6343000 8 19 0033 — тут 8 — старшая версия solidity, а 0x19 = 25 — младшая часть версии solidity.

На самом деле, как таковой структуры байткода нет. Просто перед метаданными стоит инструкция выхода из программы. Поэтому метаданные никогда не выполняются процессором.

Итак, вставляем только код без метаданных в декомпилятор и получаем:

На самом деле, некоторые инструкции декомпилятор не распознает. Поэтому, идем на уже известный нам сайт с опкодами и редактируем вывод декомпилятора. Я еще поубирал ненужные комментарии.

Итак, давайте начнем разбирать.

Первые две инстуркции PUSH1 нам уже известны. Они помещают в стэк сначала 0x80, а потом 0x40. Зачем? А для этого смотрим следующую инструкцию MSTORE.

MSTORE — инструкция, которая по адресу, который записан в первом аргументе стэка записывает значение второго аргумента стэка. У нас в стэке лежат 0x80, 0x040.  Т.е. MSTORE запишет 0x80 в паять по адресу 0x40. Зачем это нужно?

Принято, что памятью в EVM работают 32-байтными кусочками. И память имеет следующую структуру:
0x0000 зарезервировано (используется для методов хэширования)
0x0020 зарезервировано (используется для методов хэширования)
0x0040 указатель на первый свободный слот памяти
0x0060 зарезервировано
0x0080 отсюда начинается свободная память
…. ….

В документации описано тут.

Таким образом, последовательность команд
Просто инициализируют указатель на свободную память! В дальнейшем, по мере заполнения свободной памяти, мы будем двигать этот указатель.

Идем дальше:

Помещает на стэк сколько эфира было передано во время вызова контракта.

Дублирует значение на стэке.


Помещает на стэк единицу, если предыдущее значение в стэке равно нулю. Таким образом проверяем, передан эфир во время загрузки контракта или нет.

Помещаем 0x0e на стэк.

Прыгаем в адрес на стэке — т.е. на 0x0e, если второе значние в стэке 1 — т.е. мы идем на 0x0e, если в контракт передали 0 эфира.

Если все же передан эфир, то дальше будет выполнена команда REVERT. В этом контракте нет payable конструктора.
Вот команды перед выходом:
Кладет в стэк 0.

Клонирует последнее значение в стэке.

Завершает исполнение контракта с возвратом данных по адресу c первого значения в стэке, размером со второе значение в стэке. А поскольку на стэке лежит 0 и 0. То возвращается 0 байт по адресу 0. Т.е. ровно ничего.
Если все же эфир не передавался, то попадаем сюда
Эта инструкция просто помечает адрес, куда можно прыгать инструкцией JUMPI. Действие, казалось бы бессмысленное, ведь адрес прыжка итак указывается. Но мы помним, что ассемблер — это язык не для человека, а для машины. Возможно, эта инструкция нужна для оптимизации. Но нас это сейчас не интересует.

Аргументы по стэку для CODECOPY:

адрес, куда копировать
адрес, откуда копировать
размер копируемых данных

В нашем случае копируется код размером 0x3e, начиная с 0x1a в 0. Т.е. будет скопирован код контракта и метаданные. Дальше мы увидим, какой код копируется.

А сейчас идут инструкции для выхода из программы.

Помещаем в стэк 0.


Выходим из программы, возвращая данные по адресу в стэке с размером стэке.

Мы помним, что в строке 0010 мы положили 0x3e. А затем зачем-то продублировали эти данные инструкцией DUP1. Давайте поместим этот кусок байткода в  EVM отладчик и посмотрим, что будет до выполнения команд  с адреса 0010.

Сначала стэк пустой.

Выполнив все инструкции до CODECOPY в стэке появятся четыре значения. В том числе продублированное 0x3e. Мы помним, что 0x3e — это размер кода, который мы собираемся скопировать.
После выполнения CODECOPY — в стэке останется только 0x3e. CODECOPY  забрала три аргумента.
А теперь мы добавили 0. И в стэке как раз для выполнения команды RETURN у нас два аргумента. Размер кода и адрес откуда брать код (мы же предварительно код скопировали по адресу 0).
Команда RETURN возьмет два аргумента со стэка и вернет код по адресу 0 размером 0x3e.

Последняя команда:

Эта команда не ASSERT, как ее расшифровывает декомпилятор. А invalid opcode. Судя по всему ее вставляют для того, чтобы зарезервировать место. Или чтобы код начинался чуть позже по каким-то причинам. Например, если предполагается, что на это место будет что-то скопировано.
Давайте теперь посмотрим код, который идет дальше.
Далее идет тот код, который будет скопирован в память по адресу 0x0. Как видим, тут уже знакомая нам стандартная инициализация указателя на свободную память:
И дальше выход из программы — с возвратом пустого значения. REVERT берет из стэка два аргумента — откуда брать результат и какой размер. Поскольку PUSH0 отправляет на стэк 0, а DUP1 дублирует его, то мы понимаем, что REVERT вернет 0 байт по нулевому адресу. Т.е. ничего не вернет.
У внимательного читателя могут возникнуть вопросы:
  1. Почему после PUSH0 идет DUP1 а не тот же PUSH0 ведь результат будет тот же. Программы, которые транслируют Solidiy в байткод достаточно прямолинейны. И какие-то блоки инструкций написаны в общем виде. И это, повод для низкоуровневой оптимизации. Чем мы и займемся в следующих статьях. Например, как мы знаем, каждая команда имеет свою стоимость. Например инструкция PUSH0  (2 единицы газа) стоит дешевле инструкции DUP1 (3 единицы газа).  Поэтому замена DUP1 на PUSH0 сэкономит нам единицу газа.
  2. Мы встретили две команды выхода из программы REVERT и RETURN. И обе возвращают данные. В чем же разница? REVERT отменяет все изменения контракта, которые были сделаны в storage. Как правило, эту инструкцию применяют когда произошла какая-то ошибка в контракте.
Давайте подитожим. что же делает код пустого смарт-контракта на самом деле при загрузке в блокчейн:
  1. Инициализирует указатель свободной памяти
  2. Проверяет, передавался ли эфир
  3. Если эфир передавался, то выходим с ошибкой, если нет, то прыгаем на следующий участок кода.
  4. Копируем эффектиный код контракта и метаданные в память по нулевому адресу.
  5. Возвращаем скопированные данные в блокчейн — эти данные запишутся в блокчейн.
  6. Поскольку контракт у нас пустой, то скопированный код просто проинициализирует указатель свободной памяти и выйдет.

Первые 4 пункта и есть выполнение конструктора контракта. Единожды выполненный код конструктора больше не требуется, поэтому в блокчейн возвращается код контракта без конструктора.

Резюме

В этой статье мы познакомились в EVM и основными инструкциями. Разобрались, как смарт-контракт загружается в блокчейн.

Следующая статья.

Полный список статей по теме тут.

Заходите в наш телеграмм канал — Blockchain Witnesses! Делитесь опытом или задавайте вопросы, если что-то непонятно.

Добавить комментарий