Понимание спецификаций EVM в желтой книге
Желтая книга (открывается в новой вкладке) — это формальная спецификация Эфириума. За исключением поправок, внесенных в ходе процесса EIP, она содержит точное описание того, как все работает. Она написана как математическая статья, включающая терминологию, которая может быть незнакома программистам. В этой статье вы узнаете, как ее читать, а следовательно, и другие связанные с ней математические статьи.
Какая желтая книга?
Как и почти все остальное в Эфириуме, желтая книга со временем развивается. Чтобы иметь возможность ссылаться на конкретную версию, я загрузил текущую версию на момент написания (открывается в новой вкладке). Номера разделов, страниц и уравнений, которые я использую, будут относиться к этой версии. Рекомендуется держать ее открытой в другом окне во время чтения этого документа.
Почему EVM?
Оригинальная желтая книга была написана в самом начале разработки Эфириума. Она описывает оригинальный механизм консенсуса на основе доказательства выполнения работы (PoW), который изначально использовался для защиты сети. Однако в сентябре 2022 года Эфириум отказался от доказательства выполнения работы (PoW) и начал использовать консенсус на основе доказательства доли владения. В этом руководстве основное внимание будет уделено частям желтой книги, определяющим виртуальную машину Эфириума (EVM). EVM осталась неизменной после перехода на доказательство доли владения (за исключением возвращаемого значения кода операции DIFFICULTY).
9 Модель исполнения
Этот раздел (стр. 14-16) включает большую часть определения EVM.
Термин состояние системы (system state) включает в себя все, что нужно знать о системе для ее запуска. В типичном компьютере это означает память, содержимое регистров и т. д.
Машина Тьюринга (открывается в новой вкладке) — это вычислительная модель. По сути, это упрощенная версия компьютера, которая, как доказано, обладает той же способностью выполнять вычисления, что и обычный компьютер (все, что может вычислить компьютер, может вычислить и машина Тьюринга, и наоборот). Эта модель упрощает доказательство различных теорем о том, что является вычислимым, а что нет.
Термин полный по Тьюрингу (открывается в новой вкладке) (Turing-complete) означает компьютер, который может выполнять те же вычисления, что и машина Тьюринга. Машины Тьюринга могут попадать в бесконечные циклы, а EVM — нет, потому что у нее закончится газ, поэтому она является лишь квазиполной по Тьюрингу.
9.1 Основы
В этом разделе изложены основы EVM и ее сравнение с другими вычислительными моделями.
Стековая машина (открывается в новой вкладке) — это компьютер, который хранит промежуточные данные не в регистрах, а в стеке (открывается в новой вкладке). Это предпочтительная архитектура для виртуальных машин, поскольку ее легко реализовать, а значит, ошибки и уязвимости в системе безопасности возникают гораздо реже. Память в стеке разделена на 256-битные слова. Это было выбрано потому, что это удобно для основных криптографических операций Эфириума, таких как хеширование Keccak-256 и вычисления на эллиптической кривой. Максимальный размер стека составляет 1024 элемента (1024 x 256 бит). Когда выполняются коды операций, они обычно получают свои параметры из стека. Существуют коды операций специально для реорганизации элементов в стеке, такие как POP (удаляет элемент с вершины стека), DUP_N (дублирует N-й элемент в стеке) и т. д.
EVM также имеет энергозависимое пространство, называемое памятью (memory), которое используется для хранения данных во время выполнения. Эта память организована в виде 32-байтовых слов. Все ячейки памяти инициализируются нулем. Если вы выполните этот код на Yul (открывается в новой вкладке) для добавления слова в память, он заполнит 32 байта памяти, дополнив пустое пространство в слове нулями, т. е. он создаст одно слово — с нулями в позициях 0-29, 0x60 в 30 и 0xA7 в 31.
mstore(0, 0x60A7)
mstore — это один из трех кодов операций, которые EVM предоставляет для взаимодействия с памятью: он загружает слово в память. Два других — это mstore8, который загружает один байт в память, и mload, который перемещает слово из памяти в стек.
EVM также имеет отдельную энергонезависимую модель хранилища (storage), которая поддерживается как часть состояния системы — эта память организована в виде массивов слов (в отличие от массивов байтов с адресацией по словам в стеке). В этом хранилище контракты хранят постоянные данные — контракт может взаимодействовать только со своим собственным хранилищем. Хранилище организовано в виде сопоставлений «ключ-значение».
Хотя это не упоминается в данном разделе желтой книги, также полезно знать, что существует четвертый тип памяти. Данные вызова (calldata) — это память только для чтения с байтовой адресацией, используемая для хранения значения, переданного с параметром data транзакции. EVM имеет специальные коды операций для управления calldata. calldatasize возвращает размер данных. calldataload загружает данные в стек. calldatacopy копирует данные в память.
Стандартная архитектура фон Неймана (открывается в новой вкладке) хранит код и данные в одной и той же памяти. EVM не следует этому стандарту из соображений безопасности — совместное использование энергозависимой памяти делает возможным изменение программного кода. Вместо этого код сохраняется в хранилище.
Существует только два случая, когда код выполняется из памяти:
- Когда контракт создает другой контракт (используя
CREATE(открывается в новой вкладке) илиCREATE2(открывается в новой вкладке)), код для конструктора контракта берется из памяти. - Во время создания любого контракта выполняется код конструктора, а затем возвращается код самого контракта, также из памяти.
Термин «исключительное исполнение» означает исключение, которое приводит к остановке выполнения текущего контракта.
9.2 Обзор комиссий
В этом разделе объясняется, как рассчитываются комиссии за газ. Существует три вида затрат:
Стоимость кода операции
Базовая стоимость конкретного кода операции. Чтобы получить это значение, найдите группу стоимости кода операции в Приложении H (стр. 29, под уравнением (329)) и найдите группу стоимости в уравнении (326). Это даст вам функцию стоимости, которая в большинстве случаев использует параметры из Приложения G (стр. 28).
Например, код операции CALLDATACOPY (открывается в новой вкладке) является членом группы Wcopy. Стоимость кода операции для этой группы составляет Gverylow+Gcopy×⌈μs[2]÷32⌉. Посмотрев на Приложение G, мы видим, что обе константы равны 3, что дает нам 3+3×⌈μs[2]÷32⌉.
Нам все еще нужно расшифровать выражение ⌈μs[2]÷32⌉. Внешняя часть, ⌈ <value> ⌉ — это функция округления в большую сторону (ceiling), функция, которая для заданного значения возвращает наименьшее целое число, которое не меньше этого значения. Например, ⌈2.5⌉ = ⌈3⌉ = 3. Внутренняя часть — это μs[2]÷32. Посмотрев на раздел 3 (Условные обозначения) на стр. 3, μ — это состояние машины. Состояние машины определено в разделе 9.4.1 на стр. 15. Согласно этому разделу, одним из параметров состояния машины является s для стека. Собирая все вместе, кажется, что μs[2] — это позиция №2 в стеке. Посмотрев на код операции (открывается в новой вкладке), позиция №2 в стеке — это размер данных в байтах. Посмотрев на другие коды операций в группе Wcopy, CODECOPY (открывается в новой вкладке) и RETURNDATACOPY (открывается в новой вкладке), они также имеют размер данных в той же позиции. Таким образом, ⌈μs[2]÷32⌉ — это количество 32-байтовых слов, необходимых для хранения копируемых данных. Собирая все вместе, базовая стоимость CALLDATACOPY (открывается в новой вкладке) составляет 3 газа плюс 3 за каждое слово копируемых данных.
Стоимость выполнения
Стоимость выполнения вызываемого нами кода.
- В случае
CREATE(открывается в новой вкладке) иCREATE2(открывается в новой вкладке) — конструктор для нового контракта. - В случае
CALL(открывается в новой вкладке),CALLCODE(открывается в новой вкладке),STATICCALL(открывается в новой вкладке) илиDELEGATECALL(открывается в новой вкладке) — вызываемый нами контракт.
Стоимость расширения памяти
Стоимость расширения памяти (при необходимости).
В уравнении 326 это значение записывается как Cmem(μi')-Cmem(μi). Снова посмотрев на раздел 9.4.1, мы видим, что μi — это количество слов в памяти. Таким образом, μi — это количество слов в памяти до выполнения кода операции, а μi' — количество слов в памяти после выполнения кода операции.
Функция Cmem определена в уравнении 328: Cmem(a) = Gmemory × a + ⌊a2 ÷ 512⌋. ⌊x⌋ — это функция округления в меньшую сторону (floor), функция, которая для заданного значения возвращает наибольшее целое число, которое не больше этого значения. Например, ⌊2.5⌋ = ⌊2⌋ = 2. Когда a < √512, a2 < 512, и результат функции округления в меньшую сторону равен нулю. Таким образом, для первых 22 слов (704 байта) стоимость растет линейно с количеством требуемых слов памяти. После этой точки ⌊a2 ÷ 512⌋ становится положительным. Когда требуемая память достаточно велика, стоимость газа пропорциональна квадрату объема памяти.
Примечание: эти факторы влияют только на базовую стоимость газа — они не учитывают рынок комиссий или чаевые валидаторам, которые определяют, сколько должен заплатить конечный пользователь — это просто чистая стоимость выполнения определенной операции в EVM.
9.3 Среда исполнения
Среда исполнения — это кортеж I, который включает информацию, не являющуюся частью состояния блокчейна или EVM.
| Параметр | Код операции для доступа к данным | Код Solidity для доступа к данным |
|---|---|---|
| Ia | ADDRESS (открывается в новой вкладке) | address(this) |
| Io | ORIGIN (открывается в новой вкладке) | tx.origin |
| Ip | GASPRICE (открывается в новой вкладке) | tx.gasprice |
| Id | CALLDATALOAD (открывается в новой вкладке) и т. д. | msg.data |
| Is | CALLER (открывается в новой вкладке) | msg.sender |
| Iv | CALLVALUE (открывается в новой вкладке) | msg.value |
| Ib | CODECOPY (открывается в новой вкладке) | address(this).code |
| IH | Поля заголовка блока, такие как NUMBER (открывается в новой вкладке) и DIFFICULTY (открывается в новой вкладке) | block.number, block.difficulty и т. д. |
| Ie | Глубина стека вызовов для вызовов между контрактами (включая создание контракта) | |
| Iw | Разрешено ли EVM изменять состояние, или она работает статически |
Для понимания остальной части раздела 9 необходимы еще несколько параметров:
| Параметр | Определен в разделе | Значение |
|---|---|---|
| σ | 2 (стр. 2, уравнение 1) | Состояние блокчейна |
| g | 9.3 (стр. 14) | Оставшийся газ |
| A | 6.1 (стр. 9) | Накопленное подсостояние (изменения, запланированные на момент завершения транзакции) |
| o | 9.3 (стр. 14) | Вывод — возвращаемый результат в случае внутренней транзакции (когда один контракт вызывает другой) и вызовов функций просмотра (когда вы просто запрашиваете информацию, поэтому нет необходимости ждать транзакции) |
9.4 Обзор исполнения
Теперь, когда у нас есть все предварительные данные, мы можем наконец начать разбираться в том, как работает EVM.
Уравнения 146-151 дают нам начальные условия для запуска EVM:
| Символ | Начальное значение | Значение |
|---|---|---|
| μg | g | Оставшийся газ |
| μpc | 0 | Счетчик команд, адрес следующей инструкции для выполнения |
| μm | (0, 0, ...) | Память, инициализированная нулями |
| μi | 0 | Самая высокая используемая ячейка памяти |
| μs | () | Стек, изначально пустой |
| μo | ∅ | Вывод, пустое множество до тех пор, пока мы не остановимся либо с возвращаемыми данными (RETURN (открывается в новой вкладке) или REVERT (открывается в новой вкладке)), либо без них (STOP (открывается в новой вкладке) или SELFDESTRUCT (открывается в новой вкладке)). |
Уравнение 152 говорит нам, что в каждый момент времени во время выполнения возможны четыре условия, и что с ними делать:
Z(σ,μ,A,I). Z представляет собой функцию, которая проверяет, создает ли операция недопустимый переход состояния (см. исключительную остановку). Если она возвращает значение True (истина), новое состояние идентично старому (за исключением того, что сжигается газ), поскольку изменения не были реализованы.- Если выполняемый код операции —
REVERT(открывается в новой вкладке), новое состояние совпадает со старым состоянием, часть газа теряется. - Если последовательность операций завершена, о чем свидетельствует
RETURN(открывается в новой вкладке), состояние обновляется до нового состояния. - Если мы не находимся ни в одном из конечных условий 1-3, продолжаем выполнение.
9.4.1 Состояние машины
В этом разделе более подробно объясняется состояние машины. В нем указывается, что w — это текущий код операции. Если μpc меньше ||Ib||, длины кода, то этот байт (Ib[μpc]) является кодом операции. В противном случае код операции определяется как STOP (открывается в новой вкладке).
Поскольку это стековая машина (открывается в новой вкладке), нам нужно отслеживать количество элементов, извлеченных (δ) и помещенных (α) каждым кодом операции.
9.4.2 Исключительная остановка
В этом разделе определяется функция Z, которая указывает, когда происходит аварийное завершение. Это логическая (открывается в новой вкладке) функция, поэтому она использует ∨ для логического ИЛИ (открывается в новой вкладке) и ∧ для логического И (открывается в новой вкладке).
У нас происходит исключительная остановка, если выполняется любое из этих условий:
-
μg < C(σ,μ,A,I) Как мы видели в разделе 9.2, C — это функция, определяющая стоимость газа. Оставшегося газа недостаточно для покрытия следующего кода операции.
-
δw=∅ Если количество элементов, извлекаемых для кода операции, не определено, то и сам код операции не определен.
-
|| μs || < δw Опустошение стека (stack underflow), недостаточно элементов в стеке для текущего кода операции.
-
w = JUMP ∧ μs[0]∉D(Ib) Код операции —
JUMP(открывается в новой вкладке), а адрес не являетсяJUMPDEST(открывается в новой вкладке). Переходы действительны только тогда, когда местом назначения являетсяJUMPDEST(открывается в новой вкладке). -
w = JUMPI ∧ μs[1]≠0 ∧ μs[0] ∉ D(Ib) Код операции —
JUMPI(открывается в новой вкладке), условие истинно (не равно нулю), поэтому переход должен произойти, а адрес не являетсяJUMPDEST(открывается в новой вкладке). Переходы действительны только тогда, когда местом назначения являетсяJUMPDEST(открывается в новой вкладке). -
w = RETURNDATACOPY ∧ μs[1]+μs[2]>|| μo || Код операции —
RETURNDATACOPY(открывается в новой вкладке). В этом коде операции элемент стека μs[1] — это смещение для чтения в буфере возвращаемых данных, а элемент стека μs[2] — это длина данных. Это условие возникает, когда вы пытаетесь прочитать данные за пределами буфера возвращаемых данных. Обратите внимание, что аналогичного условия для данных вызова (calldata) или для самого кода нет. Когда вы пытаетесь прочитать данные за пределами этих буферов, вы просто получаете нули. -
|| μs || - δw + αw > 1024
Переполнение стека. Если выполнение кода операции приведет к тому, что в стеке окажется более 1024 элементов, происходит прерывание.
-
¬Iw ∧ W(w,μ) Работаем ли мы статически (¬ — это отрицание (открывается в новой вкладке), а Iw истинно, когда нам разрешено изменять состояние блокчейна)? Если да, и мы пытаемся выполнить операцию, изменяющую состояние, она не может быть выполнена.
Функция W(w,μ) определяется позже в уравнении 159. W(w,μ) истинно, если выполняется одно из этих условий:
-
w ∈ {CREATE, CREATE2, SSTORE, SELFDESTRUCT} Эти коды операций изменяют состояние путем создания нового контракта, сохранения значения или уничтожения текущего контракта.
-
LOG0≤w ∧ w≤LOG4 Если мы вызваны статически, мы не можем создавать записи логов. Все коды операций логов находятся в диапазоне от
LOG0(A0) (открывается в новой вкладке) доLOG4(A4) (открывается в новой вкладке). Число после кода операции лога указывает, сколько тем содержит запись лога. -
w=CALL ∧ μs[2]≠0 Вы можете вызвать другой контракт, когда находитесь в статическом режиме, но если вы это сделаете, вы не сможете перевести ему ETH.
-
-
w = SSTORE ∧ μg ≤ Gcallstipend Вы не можете выполнить
SSTORE(открывается в новой вкладке), если у вас нет газа больше, чем Gcallstipend (определено как 2300 в Приложении G).
9.4.3 Действительность места назначения перехода
Здесь мы формально определяем, что такое коды операций JUMPDEST (открывается в новой вкладке). Мы не можем просто искать значение байта 0x5B, потому что оно может находиться внутри PUSH (и, следовательно, быть данными, а не кодом операции).
В уравнении (162) мы определяем функцию N(i,w). Первый параметр, i, — это позиция кода операции. Второй, w, — это сам код операции. Если w∈[PUSH1, PUSH32], это означает, что код операции — это PUSH (квадратные скобки определяют диапазон, включающий конечные точки). В этом случае следующий код операции находится по адресу i+2+(w−PUSH1). Для PUSH1 (открывается в новой вкладке) нам нужно продвинуться на два байта (сам PUSH и однобайтовое значение), для PUSH2 (открывается в новой вкладке) нам нужно продвинуться на три байта, потому что это двухбайтовое значение, и т. д. Все остальные коды операций EVM имеют длину всего один байт, поэтому во всех остальных случаях N(i,w)=i+1.
Эта функция используется в уравнении (161) для определения DJ(c,i), которое является множеством (открывается в новой вкладке) всех действительных мест назначения перехода в коде c, начиная с позиции кода операции i. Эта функция определяется рекурсивно. Если i≥||c||, это означает, что мы находимся в конце кода или после него. Мы больше не найдем никаких мест назначения перехода, поэтому просто возвращаем пустое множество.
Во всех остальных случаях мы смотрим на остальную часть кода, переходя к следующему коду операции и получая множество, начиная с него. c[i] — это текущий код операции, поэтому N(i,c[i]) — это позиция следующего кода операции. Следовательно, DJ(c,N(i,c[i])) — это множество действительных мест назначения перехода, которое начинается со следующего кода операции. Если текущий код операции не является JUMPDEST, просто верните это множество. Если это JUMPDEST, включите его в результирующее множество и верните его.
9.4.4 Нормальная остановка
Функция остановки H может возвращать три типа значений.
- Если мы не находимся в коде операции остановки, возвращается ∅, пустое множество. По соглашению это значение интерпретируется как логическое ложь (false).
- Если у нас есть код операции остановки, который не производит вывод (либо
STOP(открывается в новой вкладке), либоSELFDESTRUCT(открывается в новой вкладке)), в качестве возвращаемого значения возвращается последовательность байтов нулевого размера. Обратите внимание, что это сильно отличается от пустого множества. Это значение означает, что EVM действительно остановилась, просто нет возвращаемых данных для чтения. - Если у нас есть код операции остановки, который производит вывод (либо
RETURN(открывается в новой вкладке), либоREVERT(открывается в новой вкладке)), возвращается последовательность байтов, указанная этим кодом операции. Эта последовательность берется из памяти, значение на вершине стека (μs[0]) — это первый байт, а значение после него (μs[1]) — это длина.
H.2 Набор инструкций
Перед тем как перейти к последнему подразделу EVM, 9.5, давайте рассмотрим сами инструкции. Они определены в Приложении H.2, которое начинается на стр. 30. Ожидается, что все, что не указано как изменяющееся при использовании этого конкретного кода операции, останется неизменным. Переменные, которые изменяются, обозначаются как <что-то>′.
Например, давайте рассмотрим код операции ADD (открывается в новой вкладке).
| Значение | Мнемоника | δ | α | Описание |
|---|---|---|---|---|
| 0x01 | ADD | 2 | 1 | Операция сложения. |
| μ′s[0] ≡ μs[0] + μs[1] |
δ — это количество значений, которые мы извлекаем из стека. В данном случае два, потому что мы складываем два верхних значения.
α — это количество значений, которые мы помещаем обратно. В данном случае одно — сумма.
Таким образом, новая вершина стека (μ′s[0]) — это сумма старой вершины стека (μs[0]) и старого значения под ней (μs[1]).
Вместо того чтобы перечислять все коды операций в виде утомительного списка, в этой статье рассматриваются только те коды операций, которые привносят что-то новое.
| Значение | Мнемоника | δ | α | Описание |
|---|---|---|---|---|
| 0x20 | KECCAK256 | 2 | 1 | Вычисление хеша Keccak-256. |
| μ′s[0] ≡ KEC(μm[μs[0] . . . (μs[0] + μs[1] − 1)]) | ||||
| μ′i ≡ M(μi,μs[0],μs[1]) |
Это первый код операции, который обращается к памяти (в данном случае только для чтения). Однако он может выйти за текущие пределы памяти, поэтому нам нужно обновить μi. Мы делаем это с помощью функции M, определенной в уравнении 330 на стр. 30.
| Значение | Мнемоника | δ | α | Описание |
|---|---|---|---|---|
| 0x31 | BALANCE | 1 | 1 | Получение баланса заданного аккаунта. |
| ... |
Адрес, баланс которого нам нужно узнать, — это μs[0] mod 2160. На вершине стека находится адрес, но поскольку адреса состоят только из 160 бит, мы вычисляем значение по модулю (открывается в новой вкладке) 2160.
Если σ[μs[0] mod 2160] ≠ ∅, это означает, что об этом адресе есть информация. В этом случае σ[μs[0] mod 2160]b — это баланс для этого адреса. Если σ[μs[0] mod 2160] = ∅, это означает, что этот адрес не инициализирован и баланс равен нулю. Вы можете увидеть список полей информации об аккаунте в разделе 4.1 на стр. 4.
Второе уравнение, A'a ≡ Aa ∪ {μs[0] mod 2160}, связано с разницей в стоимости между доступом к «теплому» хранилищу (хранилищу, к которому недавно обращались и которое, вероятно, кэшировано) и «холодному» хранилищу (хранилищу, к которому не обращались и которое, вероятно, находится в более медленной памяти, извлечение из которой обходится дороже). Aa — это список адресов, к которым транзакция обращалась ранее, и доступ к которым, следовательно, должен быть дешевле, как определено в разделе 6.1 на стр. 9. Вы можете прочитать больше на эту тему в EIP-2929 (открывается в новой вкладке).
| Значение | Мнемоника | δ | α | Описание |
|---|---|---|---|---|
| 0x8F | DUP16 | 16 | 17 | Дублирование 16-го элемента стека. |
| μ′s[0] ≡ μs[15] |
Обратите внимание, что для использования любого элемента стека нам нужно извлечь его, что означает, что нам также нужно извлечь все элементы стека, находящиеся над ним. В случае с DUP<n> (открывается в новой вкладке) и SWAP<n> (открывается в новой вкладке) это означает необходимость извлечь, а затем поместить обратно до шестнадцати значений.
9.5 Цикл выполнения
Теперь, когда у нас есть все части, мы наконец можем понять, как задокументирован цикл выполнения EVM.
Уравнение (164) гласит, что при заданном состоянии:
- σ (глобальное состояние блокчейна)
- μ (состояние EVM)
- A (подсостояние, изменения, которые должны произойти по завершении транзакции)
- I (среда выполнения)
Новое состояние — (σ', μ', A', I').
Уравнения (165)-(167) определяют стек и его изменение в результате выполнения кода операции (μs). Уравнение (168) — это изменение количества газа (μg). Уравнение (169) — это изменение счетчика команд (μpc). Наконец, уравнения (170)-(173) указывают, что остальные параметры остаются неизменными, если они явно не изменены кодом операции.
На этом EVM полностью определена.
Заключение
Математическая нотация точна и позволила желтой книге специфицировать каждую деталь Эфириума. Однако у нее есть некоторые недостатки:
- Ее могут понять только люди, а это означает, что тесты на соответствие (открывается в новой вкладке) должны быть написаны вручную.
- Программисты понимают компьютерный код. Они могут понимать или не понимать математическую нотацию.
Возможно, по этим причинам более новые спецификации уровня консенсуса (открывается в новой вкладке) написаны на Python. Существуют спецификации уровня исполнения на Python (открывается в новой вкладке), но они не полные. До тех пор, пока вся желтая книга также не будет переведена на Python или аналогичный язык, желтая книга будет продолжать использоваться, и умение ее читать будет полезным.