К основному содержимому

Введите слово из вопроса или названия статьи.

3 · Под капотом

EVM изнутри: стек, память, storage, calldata и стоимость газа

Как виртуальная машина Ethereum исполняет смарт-контракт: стековая архитектура, области данных, опкоды, цена операций и почему упаковка storage экономит деньги.

4 мин чтенияОбновлено:
В этой статье

Когда вы вызываете функцию контракта, никакой «программы на сервере» не запускается. Каждый узел сети независимо исполняет байткод контракта на одной и той же виртуальной машине - EVM (Ethereum Virtual Machine) - и получает побитово одинаковый результат. Это и делает блокчейн «компьютером, которому все верят». Разберём, как этот компьютер устроен.

Контракт - это байткод

Solidity-код компилируется в байткод: последовательность байтов, каждый из которых - инструкция (опкод) или данные к ней. Например, 0x01 - это ADD, 0x60 - PUSH1, 0xFD - REVERT. Развёрнутый в сеть контракт - это просто байты, лежащие по его адресу. Адрес с байткодом вы можете увидеть в обозревателе блоков во вкладке Contract (см. как читать Etherscan).

Стековая машина

EVM - стековая виртуальная машина: нет регистров, как у обычного процессора. Инструкции берут операнды с вершины стека и кладут туда результат. Элементы стека - 256-битные слова (32 байта), максимальная глубина - 1024.

Выражение a + b исполняется так:

text
PUSH1 0x02    // стек: [2]
PUSH1 0x03    // стек: [3, 2]
ADD           // стек: [5]

256 бит не случайность: это размер хеша keccak256 и удобная единица для криптографии, поэтому Solidity по умолчанию работает с uint256.

Четыре области данных

У EVM четыре места, где хранятся данные. Различия между ними - основа понимания газа.

ОбластьВремя жизниДоступСтоимость
Stackвнутри одной функциичтение и записьпочти бесплатно
Memoryна время вызовачтение и записьдёшево, но растёт квадратично с размером
Calldataна время вызоватолько чтениедёшево: входные данные транзакции
Storageнавсегда, пока жив контрактчтение и записьочень дорого

Storage - постоянное хранилище контракта, таблица «слот (256 бит) → значение (256 бит)». Всё, что контракт «помнит» между вызовами - балансы, сделки, статусы, - лежит здесь. Запись нового ненулевого значения стоит порядка 20 000 газа, чтение «холодного» слота - около 2 100, «тёплого» - около 100.

Memory - временный буфер, живущий до конца вызова. Первые байты стоят дёшево, но стоимость расширения растёт: каждое слово - 3 газа плюс квадратичная добавка.

Calldata - входные данные транзакции, к которым можно только обращаться. Для внешних функций аргументы calldata дешевле, чем memory: нет копирования.

Газ: у каждой инструкции своя цена

Каждый опкод тратит газ. Примерный порядок цен:

  • арифметика (ADD, MUL) - единицы газа;
  • чтение storage (SLOAD) - 100–2 100;
  • запись storage (SSTORE) - от 2 900 до 20 000 и выше;
  • вызов другого контракта (CALL) - сотни газа плюс работа вызываемого;
  • создание события (LOG) - зависит от числа топиков и размера данных; дешевле storage;
  • создание контракта - десятки тысяч.

Отсюда практическое правило оптимизации: главные деньги уходят на storage. Сэкономить на сложении трудно, а сократить число записей в storage - легко.

Упаковка storage

Слот - 32 байта. Маленькие типы можно упаковывать в один слот, если они идут подряд. Так устроена структура сделки в нашем контракте:

solidity
struct Deal {
    // slot 0
    address buyer;           // 20 байт
    uint96 amount;           // 12 байт - вместе ровно 32
    // slot 1
    address seller;          // 20 байт
    uint16 arbiterBaseFeeBps;    // 2
    uint16 arbiterDisputeFeeBps; // 2
    uint64 deadline;             // 8 - вместе ровно 32
    // ... всего 5 слотов
}

Адрес занимает 20 байт, uint96 - 12. Вместе они ровно заполняют слот. Если бы сумма хранилась как uint256, потребовался бы лишний слот, а значит, лишние 20 000+ газа при создании сделки и лишние 2 100 при каждом чтении.

Цена упаковки - ограничение: uint96 вмещает числа до ~7,9 × 10²⁸, что для суммы токена с 18 знаками - около 79 миллиардов единиц. Контракт проверяет переполнение и отвергает слишком большие суммы (ошибка AmountTooLarge), поэтому «тихой» обрезки нет. Порядок полей в структуре выбран под упаковку, и менять его «для красоты» нельзя.

Вызовы между контрактами

Контракты вызывают друг друга тремя основными способами:

  • CALL - обычный вызов: у вызываемого контракта своё состояние, свой storage. Так эскроу вызывает transfer токена.
  • STATICCALL - вызов только для чтения: любая попытка записи откатывает транзакцию. Так вызываются view-функции.
  • DELEGATECALL - исполнение чужого кода в контексте своего storage. На нём построены прокси-контракты (обновляемые контракты). Мощный и опасный инструмент: ошибка здесь позволяет подменить логику.

Каждый внешний вызов - это точка, где управление передаётся другому коду. О последствиях - в статье про реентрантность.

Revert и откат состояния

Если в ходе исполнения случается ошибка - сработал revert, не хватило газа - все изменения состояния откатываются, как будто транзакции не было. Остаётся только потраченный газ. Именно поэтому в контракте проверки идут в начале функции: ошибка быстро и дёшево отменяет всё.

Пользовательские ошибки (error NotAuthorized();) компактнее строк и стоят дешевле. Как они кодируются - в статье про ABI.

Версии EVM и хард-форки

Набор опкодов расширяется по мере хард-форков: в Shanghai появился PUSH0, в Cancun - TSTORE и MCOPY. Контракт, скомпилированный под новую версию, может не запуститься в сети, где форк ещё не активирован. Наш контракт собран под версию paris и намеренно не использует эти опкоды: байткод исполним в любой EVM-сети и одинаков побайтно - это условие того, чтобы адрес был один и тот же везде (см. CREATE2).

Что почитать дальше

Когда вы вызываете функцию, аргументы превращаются в байты по строгим правилам. Как именно - ABI: кодирование вызовов и селекторы. А чтобы научиться читать код самих контрактов, загляните в обзор Solidity.

Упоминается в статьях

Готовы попробовать?

Создайте первую сделку за пару минут. Деньги блокируются в смарт-контракте и выплачиваются по правилам, которые видны заранее.