Intro даст базовое понимание EVM. Если ты и так знаешь достаточно об EVM, то чтение intro можно пропустить и переходить к главной части "Что такое opcodes?".
Intro
Ethereum - это сеть, где каждый участник сети хранит копию состояния единственного канонического компьютера (называемого виртуальной машиной Ethereum или EVM). Любой участник сети может транслировать запрос этому компьютеру на выполнение произвольных вычислений.
Запросы на вычисление называются транзакциями. Запись всех транзакций и текущего состояния сохраняются и в свою очередь согласовывается всеми участниками.
Подробнее про ethereum можно прочитать в официальной документации, про анатомию транзакций в статье "The Anatomy of a Transaction Receipt". Ниже мы продолжим говорить про самые важные моменты сети Ethereum.
Account based model
В отличие от сети Bitcoin, Ethereum поддерживает стратегию аккаунтов.
Аккаунты нужны для хранения информации о балансах пользователей. Аккаунты и балансы аккаунтов хранятся в большой таблице внутри EVM. Они являются частью общего состояния EVM.
Сущность аккаунт содержит следующие поля:
Баланс. Количество эфира в Wei.
Nonce. Счетчик транзакций этого аккаунта.
Storage root. Также известен как хэш хранилища.
Code hash. Этот хэш ссылается на code аккаунта на виртуальной машине. По сути, это 256-битный хэш корня «дерева Меркла», кодирующего содержимое хранилища аккаунта.
В природе EVM аккаунты делятся на два типа:
Внешний EOA (Externally Owned Account)
Смарт-контракт
Оба типа аккаунтов могут не только получать, хранить и отправлять эфиры, но и взаимодействовать с другими смарт-контрактами.
Ключевое отличие аккаунтов типа "EOA" и "смарт-контракт" заключается в следующем:
Адрес EOA создается на основе приватного ключа пользователя. Адрес контракта создается на основе адреса деплоера, байт-кода и nonce.
Поля code hash и storage root заполняются только для аккаунта типа "смарт-контракт". Для EOA аккаунтов эти поля пустые.
EVM
Ethereum Virtual Machine — это глобальный виртуальный компьютер, состояние которого хранит (и с которым согласовывается) каждый участник сети Ethereum. Любой участник может запросить выполнение произвольного кода в EVM.
С точки зрения opcodes, EVM - это полная по Тьюрингу стековая машина, отвечающая за выполнение кода смарт-контрактов.
Смарт-контракт — это набор инструкций. Каждая инструкция представляет собой код операций (со своей удобной мнемоникой, своего рода текстовым представлением присвоенных им значений от 0 до 255). Когда EVM выполняет смарт-контракт, она последовательно считывает и выполняет каждую инструкцию.
Упрощенная архитектура EVM
Упрощенно можно представить внутреннее устройство EVM схемой ниже. Забегая вперед скажем, что объекты на схеме EVM code и Storage это не что иное, как то на что ссылаются code hash и storage аккаунта. Я говорил про них в разделе выше, где рассказывал про аккаунты.
На схеме выше мы видим две крупных области:
Machine state (volatile). Эта область содержит непостоянные объекты, которые будут созданы в рамках контекста вызова. Под контекстом вызова можно понимать исполнение любого набора инструкций, полученных из байт-кода смарт-контракта.
World state (persistent). Эта область содержит объекты, которые не зависят от контекста вызова.
Machine state включает:
PC (Program counter). Решает, какую инструкцию из области code EVM должна прочитать следующей. PC обычно увеличивается на один байт, чтобы указать на следующую инструкцию. За исключением всего нескольких команд. Я имею ввиду команды
JUMP
,JUMPI
.Stack. Список инструкций смарт-контракта. Максимально может включать 1024 инструкции. Размер инструкции равен 32-м байтам. Для каждого вызова в рамках контекста вызова создается один Stack. Он уничтожается, когда контекст вызова завершается.
Memory. Также как и Stack создается в начале вызова в рамках контекста и очищается по окончанию.
World state включает:
Code. Это область где хранятся инструкции. Код - это байты данных, прочитанные, интерпретированные и выполненные EVM во время исполнения смарт-контракта. Код в этой области неизменяем. Это показано аббревиатурой ROM(Read-only memory)
Storage. Хранилище отвечает за хранение состояния блокчейна. По сути, это мапа (сопоставление) 32-байтовых слотов с 32-байтовыми значениями. Хранилище постоянно для смарт-контракта. Любое значение записанное кодом смарт-контракта сохраняется после окончания вызова. Каждый контракт имеет собственное хранилище и не может читать или изменять хранилище из другого контракта
Для более простого понимания можно провести следующие аналогии. Stack имеет ограничение на хранение 1024-х инструкций. Используется stack для передачи значений функциям function(arg,arg2)
и выполнения функций.
Из-за ограничений stack, сложные комбинации opcodes используют память контракта (memory) для извлечения или передачи данных. Однако память непостоянна (когда выполнение вызова функции закончится, память очистится) и поэтому подходит для хранения объявленных переменных.
Чтобы хранить данные неограниченное время и сделать их доступными для выполнения в следующих вызовах нужно использовать storage. По сути это общедоступная база данных. Можно даже считывать значения извне без необходимости отправлять транзакции. Однако запись в хранилище - это одна из самых дорогостоящих операций.
Итог:
Stack. Хранение аргументов функции. Плюс операции выполнения.
Memory. Краткосрочное хранение объявленных переменных в рамках контекста одного вызова функции.
Storage. Долгосрочное хранение данных в рамках жизни блокчейна. Данные доступны в рамках любых контекстов вызова функции и для считывания извне.
Можно на все это посмотреть со стороны реализации Ethereum протокола на go:
Выполнение инструкций
Помним, что EVM - стековая машина. Стек здесь играет ключевую роль. На схеме ниже показано выполнение некоторого набора инструкций.
Этот алгоритм можно описать следующим образом:
В рамках контекста вызова создаются следующие объекты machine state: memory, stack, program counter.
Program counter (PC) получает команду на инициализацию исполнения инструкций.
Из EVM code берутся инструкции и разбиваются на операции(opcodes).
Каждая операция попадает в stack.
Операции попавшие в stack начинают выполняться.
Выполнение каждой операции может задействовать storage или memory.
Важно! В этой схеме мы опускаем проверку gas. Каждый opcode стоит несколько единиц gas. Gas помогает поддерживать безопасность сети Ethereum. Расчет gas выходит за рамки этой схемы.
Ключевые моменты
Solidity код преобразуется в → байт-код → из байт-кода извлекаются opcodes
Аккаунты бывают двух типов: EOA и смарт-контракты. Только смарт-контракты имеют код.
Понимание, как работает EVM. Под каждый контекст вызова создается Memory и Stack. При этом EVM code неизменяем. Storage постоянен, но может быть изменен.
Что такое opcodes?
Opcodes - низкоуровневые машинные инструкции. Еще их называют кодами операций.
EVM не может интерпретировать код смарт-контракта, написанный на высокоуровневом языке программирования. Любой код должен быть скомпилирован в машиночитаемый код (байт-код), который содержит инструкции в двоичном формате.
Для чего необходимо понимать opcodes? Ответ прост - для минимизации потребления gas и снижения затрат конечного пользователя. Дополнительным бонусом будет умение правильно применять практики из других языков программирования. Например, яркими примерами особенности разработки под EVM являются работа с памятью и массивами данных.
Важно! На момент написания этой статьи существует чуть больше 140 уникальных opcodes. За счет этих opcodes EVM считается Тьюринг полной.
Категории opcodes
Для простоты восприятия можно разбить все opcodes на следующие группы:
Управление стеком.
POP, PUSH, DUP, SWAP
.Арифметика.
ADD, SUB, MUL, SMUL, DIV, SDIV, MOD, EXP, ADDMOD, MULMOD, SMOD
Сравнение и побитовые сдвиги.
GT, LT, EQ, SLT, SGT, ISZERO, AND, OR, XOR, NOT, BYTE, SHL, SHR, SAR
Операции среды.
CALLER, CALLVALUE, NUMBER, CODESIZE, CALLDATACOPY, CALLDATALOAD, CALLDATASIZE, EXTCODECOPY
Управление памятью memory.
MLOAD, MSTORE, MSTORE8, MSIZE
Управление памятью storage.
SLOAD, SSTORE
Управление program counter.
JUMP, JUMPI, PC, JUMPDEST
Остановка процесса.
STOP, RETURN, REVERT, INVALID, SELFDESTRUCT
Информация о блоке.
BLOCKHASH, TIMESTAMP, COINBASE, NUMBER, DIFFICULTY, GASLIMIT, CHAINID, SELFBALANCE, BASEFEE
Объяснять все значения opcodes нет смысла. Чтобы узнать, что каждый из них делает, необходимо прочитать документацию на сайте evm.codes. Дальше будем работать с этим сервисом. Каждому коду операции выделяется 1 байт памяти. Например:
0x00
- STOP 0x01
- ADD
Важно! 1 байт представлен 2-мя шестнадцатеричными символами.
Gas
Я уже сказал, что gas помогает поддерживать безопасность сети Ethereum. Для каждого вычисления требуется плата за выполнение операции в сети. Это не позволяет злоумышленнику делать злонамеренные действия в сети. Например создавать транзакции для спама.
Таким образом у каждого opcode есть своя базовая стоимость gas. Посмотреть ее можно в столбце MINIMUM GAS.
Opcodes, которым необходимо больше вычислительных ресурсов, требуется более высокая плата за gas. Например, простая инструкция POP
требует 5 газа, а чуть более сложная JUMP
требует 8 единиц газа.
Код |
Название |
Gas |
---|---|---|
04 |
DIV |
5 единиц газа |
50 |
POP |
2 единицы газа |
56 |
JUMP |
8 единиц газа |
38 |
CODESIZE |
2 единицы газа |
Однако существуют еще более сложные opcodes, которые взимают динамическую стоимость gas. Например, операция кодирования KECCAK256
требует 30 единиц газа плюс 6 единиц газа за каждое кодируемое слово.
Код |
Название |
Static gas |
Dynamic gas |
---|---|---|---|
20 |
KECCAK256 |
30 единиц газа |
6 единиц за каждое слово минимального размера плюс затраты на расширение памяти |
31 |
BALANCE |
0 единиц газа |
Если адрес доступа теплый, динамическая стоимость равна 100. Иначе стоимость равна 2600 |
51 |
MLOAD |
3 единицы газа |
Рассчитывается по принципу memory_expansion_cost |
Важно! Кроме таблицы газа для opcodes, каждая транзакция требует 21_000 gas. А самый дорогой opcodeCREATE
, отвечающий за создание контракта, требует 32_000 газа сверх стоимости транзакции.
При выполнение инструкций, которые уменьшают размер общего состояния блокчейн дополнительный gas может быть возвращен обратно в качестве награды. Например выполнение opcode SELFDESTRUCT
возвращает 24_000 gas. Возврат происходит только после завершения исполнения контракта, поэтому контракты не могут себя окупить. Кроме того, возмещение не может превышать половину стоимости gas, использованного для текущего вызова контракта.
Пример разбора байт-кода
Давай попробуем разобрать следующий пример байт-кода 0x6002600201600202
. Согласно таблице opcodes мы можем брать первый байт (60) и искать его в таблице opcodes. Это будет opcode PUSH1
. Таким образом вся строка байт-кода будет разобрана.
Первый байт равен
60
. Согласно таблице opcodes это кодPUSH1
. Этот код помещает значение следующего байта02
в stack.Следующий байт равен
60
. Что обозначает добавление следующего байта02
в stack. Сейчас в stack находится два значения [0x02, 0x02]. Это отображено на схеме.Следующий байт
01
обозначает кодADD
. Этот код берет два последних значения из stack, складывает и записывает результат сложения [0x04] в stack.Следующий байт обозначает повторение шага 1 или 2. В stack кладется значение
0x02
. Таким образом в stack [0x04, 0x02].Последний байт
02
обозначает кодMUL
. Он выполняет умножение двух значений из stack между собой. Результатом выполнения байт-кода будет значение [0x08] в stack.
Должно быть ты обратил внимание, что этот байт-код выполнил простейшие математические операции 4-го класса. (2 + 2) * 2
. Задачка на очередность выполнения операций. В результате мы получили значение равное 8. Попробуй самостоятельно предложить такой байт-код, при котором 2 + 2 * 2 = 6
. Помочь тебе проверить себя поможет playground. Ответ смотри в конце статьи.
Примеры не интуитивных шаблонов проектирования, влияющих на количество затраченного газа
Здесь мы разберем несколько примеров, которые наглядно покажут важность темы "Evm Opcodes".
MUL vs EXP Умножение против возведения в степень.
MUL
стоит 5 газа. EXP стоит 10 статических единиц газа и 50 * количество_байт_в_показатели_степени. Думаю тут и так ясно, что выгоднее. Конечно, если возможно, то выгоднее использовать умножение вместо возведения в степень.SLOAD vs MLOAD
MLOAD
всегда стоит 3 статического газа + динамический газ за расширение памяти.SLOAD
стоит 2100 газа для первоначального доступа и по 100 газа за повторные. Это говорит о том, что в большинстве случаев дешевле загружать данные из memory, нежели чем из storage. Отсюда и появляются оптимизации массивов, где сначала дешевле раз скопировать массив из storage в memory, а потом уже с ним работать.Приемы объектно-ориентированного подхода Выделение новых сущностей в виде контрактов, любых аккаунтов будет интерпретировано в opcode
CREATE
. Его стоимость не менее 32_000 газа. Это самый дорогой код операцию EVM. Таким образом, лучше свести к минимуму количество используемых смарт-контрактов. Это отличается от типичного объектно-ориентированного программирования, в котором разделение кода на классы поощряется для повторного использования кода.SSTORE Здесь все просто. Запись в storage - это одна из самых дорогих операций. Поэтому в реализации NFT, метаданные не хранятся в storage контракта. Storage хранит всего лишь ссылку на эти метаданные.
Обратный инжиниринг
Зачастую контракты верифицированы и код контракта можно посмотреть на etherscan. Однако, если контракт не верифицирован, то можно попробовать разобрать байт-код контракта.
Прекрасную статью на эту тему предлагает Ори Померанц в официальной документации Ethereum. Хочу предупредить, что разбор не верифицированных контрактов - это нетривиальная задача. Поэтому смотри сам, на сколько тебе сейчас нужно погрузиться в эту тему.
Практика
Теория - это хорошо. Однако закрепление теории практикой всегда гораздо продуктивнее. Franco Victorio - один из разработчиков hardhat, создал коллекцию головоломок. Эта коллекция головоломок предлагает посмотреть на набор opcodes и ввести такое значение, которое позволит инструкциям успешно выполниться. Постарайся решить эти головоломки самостоятельно, прежде чем искать ответы. У меня получилось решить 7 головоломок.
Для удобство решать головоломки можно в playground.
Хорошие новости
В Foundry есть встроенный debugger, который умеет поддерживать отображение opcodes.
Под цифрой 1 выделена область исполняемого кода тестовой функции. Под цифрой 2 выделена область соответствующего исполнения инструкции. В правой части рамками без номеров выделены две области отвечающие за отображение stack и memory.
Ответ на задачку 2 + 2 * 2
6002600202600201
[01] -> 6002 -> PUSH1 02
[02] -> 6002 -> PUSH1 02
[04] -> 02 -> MUL
[05] -> 6002 -> PUSH1 02
[07] -> 01 -> ADD
Links
Для тех, у кого есть много времени
Есть блог пользователя под ником noxx, который подготовил серию статей на тему углубленного изучения EVM. Это немножко галопом по Европе, потому что материала много и он не простой. Советую читать разработчикам с опытом.
Нашелся добрый человек по имени Тёма. Он сделал перевод статей под лозунгом "EVM для задротов".