EVM изнутри: стек, память, storage, calldata и стоимость газа
Как виртуальная машина Ethereum исполняет смарт-контракт: стековая архитектура, области данных, опкоды, цена операций и почему упаковка storage экономит деньги.
В этой статье
Когда вы вызываете функцию контракта, никакой «программы на сервере» не запускается. Каждый узел сети независимо исполняет байткод контракта на одной и той же виртуальной машине - EVM (Ethereum Virtual Machine) - и получает побитово одинаковый результат. Это и делает блокчейн «компьютером, которому все верят». Разберём, как этот компьютер устроен.
Контракт - это байткод
Solidity-код компилируется в байткод: последовательность байтов, каждый из которых - инструкция (опкод) или данные к ней. Например, 0x01 - это ADD, 0x60 - PUSH1, 0xFD - REVERT. Развёрнутый в сеть контракт - это просто байты, лежащие по его адресу. Адрес с байткодом вы можете увидеть в обозревателе блоков во вкладке Contract (см. как читать Etherscan).
Стековая машина
EVM - стековая виртуальная машина: нет регистров, как у обычного процессора. Инструкции берут операнды с вершины стека и кладут туда результат. Элементы стека - 256-битные слова (32 байта), максимальная глубина - 1024.
Выражение a + b исполняется так:
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 байта. Маленькие типы можно упаковывать в один слот, если они идут подряд. Так устроена структура сделки в нашем контракте:
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.
Читайте также
- 3 · Под капотом
ABI: как вызов функции превращается в байты - селекторы, calldata и события
Как кодируются вызовы смарт-контрактов: селектор функции, выравнивание аргументов, динамические типы, пользовательские ошибки и топики событий на реальных примерах.
4 мин чтения - 2 · Практика
Анатомия транзакции: nonce, газ, mempool и подтверждения
Что происходит от нажатия «Подписать» до подтверждения в блоке: поля транзакции, nonce, mempool, включение в блок, финальность и причины застрявших транзакций.
3 мин чтения
Упоминается в статьях
- Bitcoin и Ethereum: в чём разница и почему сделки строят на Ethereum
- Словарь терминов: блокчейн, кошельки, смарт-контракты и эскроу от A до Я
- Как проверить смарт-контракт перед использованием: пошаговая инструкция
- Аккаунты и состояние Ethereum: EOA, контракты и мировое состояние
- Обзор языка Solidity: типы, функции, ошибки и события на примерах
- Раскладка хранилища и упаковка переменных в Solidity
- Прокси и обновляемые контракты: delegatecall, UUPS и цена гибкости
- Оптимизация газа в Solidity: 12 приёмов и когда они уместны
- Роллапы изнутри: батчи, доказательства мошенничества, ZK, блобы и вывод средств
Готовы попробовать?
Создайте первую сделку за пару минут. Деньги блокируются в смарт-контракте и выплачиваются по правилам, которые видны заранее.

