Deep EVM #1: Как EVM выполняет ваш код — опкоды, стек и газ
Engineering Team
EVM — это стековая машина
Ethereum Virtual Machine — это не процессор x86 в вашем ноутбуке. У неё нет регистров. Вместо этого EVM является стековой машиной — каждое вычисление помещает данные в стек или извлекает из стека глубиной 1024 элемента, где каждый элемент — 256-битное (32-байтное) слово.
Когда вы вызываете смарт-контракт, EVM получает байткод контракта — плоскую последовательность однобайтных опкодов — и начинает выполнение с байта 0. Здесь нет таблицы функций, нет ELF-заголовка, нет этапа линковки. Байткод и есть программа.
// Solidity:
// uint256 result = 2 + 3;
// Компилируется в байткод:
// PUSH1 0x02 PUSH1 0x03 ADD
// Трассировка стека:
// [] -> PUSH1 0x02 -> [2]
// [2] -> PUSH1 0x03 -> [2, 3]
// [2, 3] -> ADD -> [5]
Каждый опкод извлекает свои операнды с вершины стека и помещает результат обратно. Опкод ADD извлекает два значения, складывает их и помещает сумму. Это принципиально отличается от регистровых архитектур, где вы указываете регистры-источники и регистр-приёмник.
Категории опкодов
EVM определяет примерно 140 опкодов, сгруппированных по функциональным категориям:
Арифметика и сравнение
- ADD, SUB, MUL, DIV, MOD — базовая 256-битная целочисленная арифметика. Все стоят 3 газа (уровень G_verylow).
- SDIV, SMOD — деление и модуль со знаком в дополнительном коде.
- ADDMOD, MULMOD — модулярная арифметика:
(a + b) % Nи(a * b) % Nодним опкодом. Критически важны для операций на эллиптических кривых, стоят 8 газа. - EXP — возведение в степень. Стоит 10 газа + 50 за каждый байт экспоненты, что делает его одним из самых дорогих арифметических опкодов.
- LT, GT, SLT, SGT, EQ, ISZERO — опкоды сравнения, помещающие 1 (истина) или 0 (ложь) в стек.
Побитовые операции
- AND, OR, XOR, NOT — побитовая логика, 3 газа каждая.
- SHL, SHR, SAR — сдвиг влево, логический сдвиг вправо, арифметический сдвиг вправо (добавлены в Constantinople, EIP-145). До них сдвиги требовали MUL/DIV на степени двойки.
- BYTE — извлечение одного байта из 32-байтного слова.
BYTE(0, x)возвращает старший байт.
Манипуляции со стеком
- POP — удалить верхний элемент.
- PUSH1 — PUSH32 — поместить от 1 до 32 байт непосредственных данных в стек. PUSH1 — самый частый опкод в развёрнутом байткоде.
- DUP1 — DUP16 — дублировать N-й элемент стека на вершину.
- SWAP1 — SWAP16 — поменять местами верхний элемент с N-м элементом ниже.
Информация об окружении и блоке
- CALLER (msg.sender), CALLVALUE (msg.value), CALLDATALOAD, CALLDATASIZE, CALLDATACOPY — доступ к контексту транзакции.
- NUMBER, TIMESTAMP, BASEFEE, CHAINID — информация уровня блока.
- BALANCE, EXTCODESIZE, EXTCODECOPY — запрос данных других аккаунтов.
Расписание газа
Каждый опкод имеет стоимость в газе. Газ выполняет две функции: предотвращает бесконечные циклы (проблема останова) и справедливо оценивает вычислительные ресурсы.
Стоимость газа распределяется по уровням:
| Уровень | Газ | Примеры |
|---|---|---|
| Нулевой | 0 | STOP, RETURN, REVERT |
| Базовый | 2 | ADDRESS, ORIGIN, CALLER |
| Очень низкий | 3 | ADD, SUB, LT, GT, AND, OR, POP |
| Низкий | 5 | MUL, DIV, MOD |
| Средний | 8 | ADDMOD, MULMOD, JUMP |
| Высокий | 10 | JUMPI |
| Специальный | разный | SLOAD, SSTORE, CALL, CREATE |
Самые дорогие опкоды — те, что взаимодействуют с состоянием:
// Стоимость газа для доступа к состоянию (после EIP-2929):
// SLOAD (холодный): 2100 газа
// SLOAD (тёплый): 100 газа
// SSTORE (холодный, 0->ненулевое): 22100 газа
// SSTORE (тёплый): 100 газа (+ 20000 если 0->ненулевое)
// CALL (холодный): 2600 газа
// CALL (тёплый): 100 газа
// BALANCE (холодный): 2600 газа
// BALANCE (тёплый): 100 газа
Холодный и тёплый доступ (EIP-2929)
EIP-2929 (обновление Berlin, апрель 2021) ввёл концепцию списка доступа — набора адресов и слотов хранилища для каждой транзакции, к которым уже обращались.
При первом обращении к слоту хранилища или внешнему адресу в транзакции он считается «холодным» и стоит дополнительного газа. Повторные обращения «тёплые» и дешёвые. Вот почему порядок чтения слотов хранилища важен для оптимизации газа.
// В Solidity этот паттерн дорогой:
function bad() external view returns (uint256) {
// Первое чтение слота: 2100 газа (холодный)
uint256 a = myStorage;
// ... логика ...
// Второе чтение: 100 газа (тёплый)
uint256 b = myStorage;
return a + b;
}
// Кэширование в памяти:
function good() external view returns (uint256) {
uint256 cached = myStorage; // 2100 газа (холодный), только один раз
return cached + cached; // 6 газа (ADD + DUP)
}
Поток выполнения: что происходит в транзакции
Когда вы отправляете транзакцию, вызывающую контракт, вот полная последовательность выполнения:
- Валидация транзакции — проверка nonce, баланс >= value + gas * gasPrice, верификация подписи.
- Вычет внутреннего газа — 21000 газа за саму транзакцию, плюс 16 газа за каждый ненулевой байт calldata и 4 за нулевой.
- Настройка контекста — EVM создаёт контекст выполнения: код, calldata, вызывающий, значение, оставшийся газ.
- Счётчик программы начинается с 0 — EVM читает опкод на позиции 0 и выполняет его.
- Последовательное выполнение — каждый опкод выполняется, газ вычитается. JUMP и JUMPI позволяют нелинейный поток управления, но только к позициям, отмеченным JUMPDEST.
- Завершение — выполнение завершается через STOP (успех, нет данных возврата), RETURN (успех, с данными возврата), REVERT (ошибка, состояние откатывается) или исчерпание газа.
- Фиксация или откат состояния — при успехе все изменения состояния фиксируются. При откате все изменения в данном контексте вызова отменяются.
Счётчик программы и JUMP
Счётчик программы (PC) — это неявный регистр, отслеживающий текущую позицию в байткоде. Большинство опкодов продвигают PC на 1 (или на 1 + N для PUSH-опкодов). Два опкода изменяют PC напрямую:
- JUMP — извлекает адрес назначения из стека, устанавливает PC на это значение. Адрес назначения должен содержать опкод JUMPDEST, иначе транзакция откатится.
- JUMPI — условный переход. Извлекает адрес назначения и условие. Если условие ненулевое — переход; иначе продолжение последовательного выполнения.
Именно так EVM реализует if/else, циклы и диспетчеризацию функций. Компилятор Solidity генерирует селектор функций, который загружает первые 4 байта calldata, сравнивает с известными сигнатурами и выполняет JUMPI к соответствующему блоку кода.
// Диспетчеризация функций (упрощённый байткод):
// CALLDATALOAD(0) -> SHR(224) -> function_selector
// DUP1 PUSH4 0xa9059cbb EQ PUSH2 0x00a4 JUMPI // transfer(address,uint256)
// DUP1 PUSH4 0x70a08231 EQ PUSH2 0x00d2 JUMPI // balanceOf(address)
// PUSH1 0x00 DUP1 REVERT // fallback: revert
Подвызовы: CALL, STATICCALL, DELEGATECALL
Контракты могут вызывать другие контракты с помощью трёх опкодов вызова:
- CALL — стандартный вызов. Создаёт новый контекст выполнения со своим стеком и памятью. Вызываемый работает независимо; если он откатится, откатываются только его изменения.
- STATICCALL — вызов только для чтения (EIP-214). Любой опкод, изменяющий состояние (SSTORE, CREATE, LOG, SELFDESTRUCT) внутри вызываемого, вызывает немедленный откат.
- DELEGATECALL — выполняет код вызываемого, но в контексте хранилища вызывающего. msg.sender и msg.value сохраняются из исходного вызова. Именно так работают прокси-паттерны и библиотеки.
Каждый опкод вызова принимает 7 аргументов стека: gas, address, value (кроме STATICCALL/DELEGATECALL), argsOffset, argsLength, retOffset, retLength. Стоимость газа: 100 (тёплый) или 2600 (холодный) плюс надбавка за перевод значения.
Возвраты газа и их ограничения
Исторически очистка слота хранилища с ненулевого на нулевое давала возврат газа — стимулируя очистку состояния. После EIP-3529 (обновление London) максимальный возврат ограничен 20% от общего использованного газа (ранее 50%). Это уничтожило арбитраж газовых токенов (CHI, GST2), которые эксплуатировали возвраты для прибыли.
Оставшиеся источники возврата:
- SSTORE: установка ненулевого слота обратно в ноль возвращает 4800 газа.
- SELFDESTRUCT: удалён как источник возврата в EIP-3529.
Практические следствия для MEV
Если вы строите MEV-ботов, понимание EVM на уровне опкодов — не опция, а конкурентное требование. Каждая единица газа, сэкономленная при выполнении вашего бота — это маржа прибыли. Ключевые выводы:
- Симулируйте перед отправкой — используйте
eth_callили локальный EVM (revm, EVMONE) для трассировки выполнения и точного расчёта стоимости газа до отправки бандла билдеру. - Минимизируйте холодный доступ — предварительно прогревайте слоты хранилища через списки доступа (EIP-2930).
- Используйте STATICCALL для чтения — немного дешевле и гарантирует отсутствие мутации состояния.
- Знайте стоимость опкодов — один неудачно расположенный SLOAD может стоить 2100 газа; в конкурентной среде MEV это разница между прибылью и убытком.
Заключение
EVM элегантна в своей простоте: стековая машина с 256-битными словами, плоский формат байткода и система учёта газа, оценивающая каждую операцию. Понимание этого фундамента — опкоды, стек и газ — является предпосылкой для всего остального в этой серии: разметки памяти, оптимизации хранилища, примитивов безопасности и, наконец, написания чистого Yul.
В следующей статье мы рассмотрим четыре области данных EVM: стек, память, хранилище и calldata — и почему выбор правильной определяет, стоит ли ваш контракт $0.50 или $50 за выполнение.