Устройство 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. Понимание этих различий — фундамент и для оптимизации газа, и для корректности. Теперь развернём первый контракт.

Проверьте себя
1. Какая область данных EVM переживает завершение транзакции и стоит дороже всего?
AStack
BMemory
CStorage
DCalldata
2. Почему uint256 — базовый размер в EVM?
AТак короче писать
BКаждое слово стека и слот storage равны 32 байтам (256 бит)
C256 бит — максимум для адреса
DЭто требование OpenZeppelin