Prerequisites:
- 03-state-trie-mpt
EVM: стек, память и хранилище
Зачем это нужно
Каждый смарт-контракт Ethereum выполняется на EVM (Ethereum Virtual Machine) — стековой виртуальной машине, встроенной в каждую ноду сети. Если вы хотите понимать, почему одна операция стоит 3 gas, а другая — 22100 gas, почему порядок переменных в Solidity влияет на стоимость транзакции, и как работает байткод контракта — вам нужно разобраться в архитектуре EVM.
Цель урока: Вы сможете объяснить архитектуру EVM, пошагово выполнить последовательность опкодов, нарисовать storage layout контракта и рассчитать стоимость расширения памяти.
Архитектура EVM
EVM — это стековая машина (stack machine). В отличие от регистровых архитектур (x86, ARM), EVM не имеет именованных регистров. Все операции работают со стеком.
Сравнение с Bitcoin Script
В уроке Bitcoin Script (BTC-04) мы уже видели стековую виртуальную машину. EVM — это радикально расширенная версия той же идеи:
| Характеристика | Bitcoin Script | EVM |
|---|---|---|
| Тип | Стековая машина | Стековая машина |
| Тьюринг-полнота | Нет (нет циклов) | Да (JUMP/JUMPI) |
| Стек | 1000 элементов, произвольный размер | 1024 элемента, 256-bit words |
| Память | Нет | Byte-addressable, volatile |
| Хранилище | Нет | 256-bit key-value, persistent |
| Газ | Нет (размер скрипта) | Точный учет каждой операции |
| Опкоды | ~100 | ~140+ |
Шесть компонентов EVM
EVM состоит из шести основных компонентов. Нажмите на каждый для подробностей:
Интуитивная аналогия
Представьте EVM как рабочее место мастера:
- Стек — стопка карточек на столе. Можно положить карточку сверху (PUSH) или снять верхнюю (POP). Максимум 1024 карточки
- Память — блокнот для черновиков. Доступен во время работы, стирается когда мастер уходит
- Хранилище — сейф. Данные сохраняются навсегда, но открывать и закрывать сейф дорого
- Calldata — конверт с заданием от заказчика. Можно читать, нельзя менять
- Program Counter — закладка в инструкции. Показывает, какой шаг выполняется сейчас
- Gas Counter — бюджет на работу. Каждое действие стоит газ. Если бюджет закончился — всё откатывается
Опкоды EVM
Опкоды — это инструкции EVM. Каждый опкод занимает 1 байт (от 0x00 до 0xFF). Некоторые опкоды (PUSH1-PUSH32) имеют операнды — данные, следующие за опкодом в байткоде.
Группы опкодов
| Группа | Примеры | Описание |
|---|---|---|
| Stack | PUSH1-PUSH32, POP, DUP1-DUP16, SWAP1-SWAP16 | Управление стеком |
| Arithmetic | ADD, MUL, SUB, DIV, MOD, EXP | Арифметика (mod 2^256) |
| Comparison | LT, GT, EQ, ISZERO | Сравнение |
| Bitwise | AND, OR, XOR, NOT, SHL, SHR | Побитовые операции |
| Memory | MLOAD, MSTORE, MSTORE8, MSIZE | Работа с памятью |
| Storage | SLOAD, SSTORE | Работа с хранилищем |
| Flow | JUMP, JUMPI, JUMPDEST, STOP, RETURN, REVERT | Управление потоком |
| Context | CALLER, CALLVALUE, CALLDATALOAD | Контекст вызова |
| Hashing | KECCAK256 (SHA3) | Хеширование |
| Calls | CALL, DELEGATECALL, STATICCALL, CREATE | Межконтрактные вызовы |
| Logging | LOG0-LOG4 | Генерация событий (events) |
Пошаговое выполнение опкодов
Это ключевая интерактивная диаграмма урока. Выберите последовательность байткода и пройдите через каждый опкод, наблюдая изменения стека, хранилища и газа:
Формальная запись: PUSH1 + SSTORE
Рассмотрим байткод 60 2A 60 00 55:
Байт Опкод Описание
0x00 PUSH1 Поместить 1 байт на стек
0x01 0x2A Данные (42 в десятичной)
0x02 PUSH1 Поместить 1 байт на стек
0x03 0x00 Данные (слот 0)
0x04 SSTORE Записать value=stack[0] в slot=stack[1]
Стек (LIFO):
После PUSH1 0x2A: [0x2A] (top)
После PUSH1 0x00: [0x2A, 0x00] (top = 0x00)
SSTORE берет: slot=0x00, value=0x2A
После SSTORE: [] (стек пуст)
Storage: slot[0] = 0x2A
Storage Layout: как Solidity хранит переменные
Компилятор Solidity назначает каждой переменной слот в хранилище контракта. Каждый слот — 32 байта (256 бит). Правила:
- Простые типы (uint256, address, bool) — последовательные слоты, начиная с 0
- Packing — переменные меньше 32 байт, объявленные подряд, упаковываются в один слот
- Mapping — base slot содержит ничего; значение
mapping[key]хранится в слотеkeccak256(key . slot_number) - Dynamic arrays — длина в base slot; данные начинаются с
keccak256(slot_number)
Зачем это знать?
- Оптимизация газа: Чтение/запись одного слота дешевле двух. Правильный порядок переменных = меньше слотов
- Безопасность: Upgradeable прокси требуют сохранения storage layout между версиями
- Отладка:
forge inspect Contract storage-layoutпоказывает реальный layout - Низкоуровневый доступ: DELEGATECALL использует storage вызывающего контракта — несовпадение layout = катастрофа
Пример оптимизации
// Плохо: 3 слота
contract Bad {
uint128 a; // slot 0 (16 bytes, не заполнен)
uint256 b; // slot 1 (32 bytes, новый слот — не помещается в slot 0)
uint128 c; // slot 2 (16 bytes, не заполнен)
}
// Хорошо: 2 слота
contract Good {
uint128 a; // slot 0 (lower 16 bytes)
uint128 c; // slot 0 (upper 16 bytes) — packed!
uint256 b; // slot 1
}
Память EVM: расширение и стоимость
Память EVM — это линейный массив байтов, адресуемый побайтово. В начале выполнения память пуста. При обращении к ранее не использованному адресу память расширяется — и за это нужно платить газом.
Формула стоимости
Стоимость памяти зависит от количества 32-байтных слов:
memory_cost = words * 3 + words^2 / 512
Первый компонент (words * 3) — линейный. Второй (words^2 / 512) — квадратичный. Это означает, что небольшие объемы памяти дешевые, но стоимость быстро растет:
| Words | Bytes | Cost (gas) | Примечание |
|---|---|---|---|
| 1 | 32 | 3 | Один слот |
| 3 | 96 | 9 | Типичный вызов |
| 10 | 320 | 30 | |
| 100 | 3200 | 319 | Квадратичность заметна |
| 1000 | 32000 | 4953 | Дорого |
Free Memory Pointer
Solidity хранит указатель свободной памяти в ячейке 0x40. По умолчанию он равен 0x80 (первые 128 байт зарезервированы):
0x00-0x3f Scratch space (временные данные)
0x40-0x5f Free memory pointer (указывает на начало свободной памяти)
0x60-0x7f Zero slot (используется как default value)
0x80+ Свободная память (начало данных)
Математическое описание
Stack Machine формально
EVM — это кортеж (code, pc, stack, memory, storage, gas):
code : byte[] -- байткод контракта
pc : uint -- program counter (0..len(code)-1)
stack : uint256[] -- стек (max 1024)
memory : byte[] -- volatile memory
storage : Map<uint256, uint256> -- persistent storage
gas : uint -- remaining gas
Переход состояния для каждого опкода:
PUSH1 v: pc' = pc+2, stack' = stack ++ [v], gas' = gas - 3
ADD: pc' = pc+1, stack' = stack[:-2] ++ [stack[-1] + stack[-2]], gas' = gas - 3
SSTORE: pc' = pc+1, stack' = stack[:-2], storage'[stack[-1]] = stack[-2],
gas' = gas - (cold ? 22100 : 100)
MSTORE: pc' = pc+1, stack' = stack[:-2], memory'[stack[-1]..+32] = stack[-2],
gas' = gas - 3 - expansion_cost
Стоимость памяти формально
M(a) = a * G_memory + floor(a^2 / 512)
где:
a = ceil(max_accessed_byte / 32) -- количество слов
G_memory = 3 -- стоимость за слово
Стоимость расширения при доступе к новому максимальному адресу:
expansion_cost = M(a_new) - M(a_old)
Практика
- Откройте диаграмму Выполнение опкодов EVM и пройдите обе последовательности
- Для каждого шага запишите: opcode, состояние стека до/после, gas used
- В диаграмме Storage Layout исследуйте, как mapping вычисляет слот для разных ключей
- В диаграмме Расширение памяти проследите, как квадратично растет стоимость
Итоги
- EVM — стековая машина с 6 компонентами: стек, память, хранилище, calldata, PC, gas
- Стек: 256-bit words, max 1024 глубина. LIFO. Все операции работают через стек
- Память: byte-addressable, volatile, расширяется с квадратичной стоимостью
- Хранилище: 256-bit key-value pairs, persistent, самое дорогое (SSTORE cold = 22100 gas)
- Solidity назначает переменным последовательные слоты; малые типы пакуются
- Mapping хранит значения в слотах keccak256(key . base_slot)
- Правильный порядок переменных в контракте экономит газ за счет slot packing
Finished the lesson?
Mark it as complete to track your progress