Устройство EVM: стек, память и хранилище
EVM хранит данные в трёх местах, и каждое стоит по-разному. Перепутать их — главный источник «дорогих» и багующих контрактов.
Стек дешёвый и временный, память дешёвая и временная, хранилище дорогое и вечное. Вся оптимизация газа крутится вокруг этих трёх слов.
EVM — это стековая виртуальная машина. У неё нет регистров, как у обычного процессора. Все вычисления идут через стек: опкод снимает аргументы с вершины стека и кладёт результат обратно. Глубина стека ограничена 1024 элементами, каждый элемент — 32 байта (256 бит). Отсюда и базовый тип uint256.
Три области данных
Stack — рабочая площадка для арифметики. Живёт ровно одну инструкцию, почти бесплатен. Memory — линейный байтовый массив, обнуляется в начале каждого вызова. Туда кладут временные структуры, аргументы функций, возвращаемые значения. Storage — постоянное хранилище контракта, словарь из слотов по 32 байта, который переживает транзакции и хранится в блокчейне навсегда.
ВЫЗОВ ФУНКЦИИ
=============
STACK MEMORY STORAGE
(256-бит (временный (постоянные слоты,
слова) байт-массив) живут вечно)
+-----+ +-----------+ +------------------+
| top | <---- | 0x00 .. | | slot 0: owner |
| ... | | 0x20 .. | | slot 1: balance |
+-----+ +-----------+ | slot 2: paused |
очищается очищается +------------------+
сразу в конце вызова переживает всё
~3 газа ~3 газа/слово ~20000 газа на запись
Почему это так важно
Запись нового значения в storage стоит около 20000 газа, изменение существующего — около 5000, а чтение — около 2100 (после берлинского хардфорка). Для сравнения, арифметика на стеке стоит 3 газа. Разница в тысячи раз! Поэтому грамотный контракт минимизирует количество записей в storage и старается работать с данными в памяти.
Как работает под капотом (EVM/газ)
Каждая переменная состояния занимает слот storage. Компилятор укладывает их по порядку объявления. Маленькие типы (uint8, bool, address) он пытается упаковать в один слот, если они объявлены подряд и помещаются в 32 байта. Это называется storage packing. Чтение/запись упакованных переменных дешевле, потому что задействован один слот вместо нескольких.
# Та же логика на Python: storage как словарь слотов по 32 байта
SLOT_COST_NEW = 20000
SLOT_COST_UPDATE = 5000
storage = {} # slot_index -> value
def sstore(slot, value):
cost = SLOT_COST_UPDATE if slot in storage else SLOT_COST_NEW
storage[slot] = value
return cost
total = 0
total += sstore(0, "owner_addr") # первая запись — дорого
total += sstore(1, 0) # ещё одна новая запись
total += sstore(1, 100) # обновление слота 1 — дешевле
print("storage:", storage)
print("итого газа на записи:", total)
«Та же логика на Python ▶». Видно, как первая запись в слот стоит в разы дороже последующих обновлений — это поведение реальной EVM.
Частые ошибки
- Объявлять переменную как
storage, когда нужна была временная копия вmemory— и наоборот. В Solidity это явные ключевые слова, и путаница ведёт к неожиданной записи в блокчейн. - Делать запись в storage внутри цикла — газ растёт линейно и легко упирается в лимит блока.
- Игнорировать упаковку: объявить
uint128, потомuint256, потомuint128— упаковка ломается, тратится лишний слот.
Best practices
- Кэшируйте значение из storage в локальную переменную (memory/stack), если читаете его несколько раз в одной функции.
- Группируйте мелкие переменные состояния подряд, чтобы компилятор упаковал их в один слот.
- Избегайте записи в storage в циклах; накапливайте результат локально и пишите один раз.
Итоги
EVM работает со стеком из 256-битных слов и тремя областями данных: дешёвыми временными stack и memory и дорогим вечным storage. Понимание этих различий — фундамент и для оптимизации газа, и для корректности. Теперь развернём первый контракт.