Перейти к основному содержимому
БлокчейнMar 28, 2026

Deep EVM #2: Модель памяти EVM — стек, память, хранилище и calldata

OS
Open Soft Team

Engineering Team

Четыре места для ваших данных

EVM предоставляет четыре различных области данных, каждая с радикально отличающейся стоимостью, временем жизни и паттернами доступа. Выбор неправильной — самый распространённый источник чрезмерного потребления газа в смарт-контрактах.

ОбластьВремя жизниСтоимость чтенияСтоимость записиРазмер
СтекТекущий опкод0 (DUP: 3)0 (PUSH: 3)1024 слова
ПамятьТекущий контекст вызоваMLOAD: 3*MSTORE: 3*Расширяемая
ХранилищеПостоянное (блокчейн)SLOAD: 2100/100SSTORE: 20000/2900/1002^256 слотов
CalldataТекущий контекст вызоваCALLDATALOAD: 3Только чтениеВходные данные транзакции

*Операции с памятью стоят 3 газа плюс квадратичная стоимость расширения.

Стек: быстрый, но маленький

Стек — это рабочая память EVM. Каждая арифметическая, сравнительная и логическая операция читает из стека и пишет в стек. Он вмещает до 1024 элементов, каждый шириной 32 байта.

На практике вы редко используете более 16 слотов стека, потому что опкоды DUP и SWAP достигают только 16 элементов вглубь. Компилятор Solidity управляет расположением стека автоматически, но в Yul и Huff вы управляете им вручную — и ошибки «stack too deep» становятся вашим постоянным спутником.

Доступ к стеку по сути бесплатен (3 газа за PUSH/DUP/SWAP операции). Это делает стек идеальным местом для промежуточных вычислений, счётчиков циклов и временных значений.

// Вычисление только на стеке: сумма 1 + 2 + 3
// Общий газ: 3 + 3 + 3 + 3 + 3 = 15 газа
let a := add(1, 2)    // PUSH1 1, PUSH1 2, ADD
let b := add(a, 3)    // DUP1, PUSH1 3, ADD

Память: дешёвая, но эфемерная

Память — это побайтно-адресуемый массив, который начинается пустым и расширяется по мере необходимости. Она существует только на протяжении текущего контекста вызова — когда CALL, STATICCALL или DELEGATECALL возвращается, память вызываемого уничтожается.

Доступ к памяти происходит 32-байтными блоками:

  • MSTORE(offset, value) — записать 32 байта по указанному смещению. Стоит 3 газа.
  • MLOAD(offset) — прочитать 32 байта по указанному смещению. Стоит 3 газа.
  • MSTORE8(offset, value) — записать один байт. Стоит 3 газа.

Свободный указатель памяти

Solidity резервирует первые 128 байт памяти для специальных целей:

СмещениеНазначение
0x00-0x3fРабочее пространство (используется для хеширования)
0x40-0x5fСвободный указатель памяти
0x60-0x7fНулевой слот (начальное значение для динамических массивов)
0x80+Свободная память (аллокация начинается здесь)

Свободный указатель памяти по адресу 0x40 отслеживает следующий доступный адрес памяти. Когда Solidity выделяет память (например, new bytes(100), abi.encode(...), создание структуры в памяти), он читает указатель, использует этот адрес и продвигает указатель.

// Что Solidity делает внутренне, когда вы пишете:
// bytes memory data = new bytes(64);

// 1. Чтение свободного указателя: MLOAD(0x40) -> 0x80
// 2. Сохранение длины массива по 0x80: MSTORE(0x80, 64)
// 3. Обновление свободного указателя: MSTORE(0x40, 0x80 + 32 + 64 = 0xE0)
// 4. Данные находятся по адресам 0xA0 — 0xDF

Стоимость расширения памяти

Именно здесь память становится дорогой. Стоимость газа для памяти — это не только 3 газа за MLOAD/MSTORE — существует квадратичная стоимость расширения, взимаемая при обращении к памяти за пределами текущей верхней границы.

Формула (из Yellow Paper):

memory_cost = (memory_size_words^2 / 512) + (3 * memory_size_words)

где memory_size_words = ceil(наибольший_доступный_байт / 32).

Для малого использования памяти (менее ~700 байт) стоимость приблизительно линейна — 3 газа за слово. Но она растёт квадратично:

Использовано памятиСтоимость
32 байта (1 слово)3 газа
1 КБ (32 слова)98 газа
10 КБ (320 слов)1160 газа
100 КБ (3200 слов)29600 газа
1 МБ (32000 слов)2 001 000 газа

Этот квадратичный рост означает, что контракты, обрабатывающие большие массивы в памяти, могут быстро исчерпать лимит газа. Именно поэтому Solidity использует память экономно и предпочитает вычисления на стеке.

Хранилище: постоянное, но дорогое

Хранилище — это постоянное хранилище ключ-значение EVM. Каждый контракт имеет собственное изолированное пространство хранения с 2^256 32-байтными слотами. Хранилище сохраняется между транзакциями — это состояние блокчейна.

Раскладка хранилища в Solidity

Solidity последовательно назначает слоты хранилища переменным состояния:

contract StorageLayout {
    uint256 public x;       // слот 0
    uint256 public y;       // слот 1
    address public owner;   // слот 2 (упаковка: 20 байт)
    bool public paused;     // слот 2 (упаковка: 1 байт, тот же слот!)
    mapping(address => uint256) public balances;  // слот 3 (базовый)
    // balances[addr] находится по: keccak256(addr . slot_3)
}

Маппинги и динамические массивы используют keccak256 для вычисления слота хранилища:

  • mapping[key] -> keccak256(key || slot_number)
  • array[index] -> keccak256(slot_number) + index

Стоимость хранилища (после EIP-2929 + EIP-3529)

Хранилище — безусловно самая дорогая область данных:

ОперацияХолодныйТёплый
SLOAD2100100
SSTORE (ноль -> ненулевое)2210020000
SSTORE (ненулевое -> ненулевое)50002900
SSTORE (ненулевое -> ноль)5000 + 4800 возврат2900 + 4800 возврат

Установка слота хранилища из нуля в ненулевое значение стоит 20000 газа, потому что дерево состояний Ethereum должно вставить новый узел-лист. Вот почему инициализация новой записи маппинга (например, баланса токена для нового держателя) так дорога.

Упаковка хранилища

Solidity упаковывает несколько маленьких переменных в один 32-байтный слот, когда это возможно. Это критическая оптимизация:

// ПЛОХО: 3 слота хранилища = 3 x SLOAD = 6300 газа (холодный)
contract Unpacked {
    uint256 a;  // слот 0 (32 байта)
    uint256 b;  // слот 1 (32 байта)
    uint256 c;  // слот 2 (32 байта)
}

// ХОРОШО: 1 слот хранилища = 1 x SLOAD = 2100 газа (холодный)
contract Packed {
    uint64 a;   // слот 0, байты 0-7
    uint64 b;   // слот 0, байты 8-15
    uint64 c;   // слот 0, байты 16-23
}

Calldata: только чтение и дёшево

Calldata — это входные данные, отправленные с транзакцией или вызовом. Они доступны только для чтения, и обращение к ним дёшево (3 газа за CALLDATALOAD). Для внешних вызовов функций Solidity ABI-кодирует аргументы в calldata.

Использование calldata вместо memory для параметров функций позволяет избежать копирования данных и значительно экономит газ:

// ПЛОХО: копирует весь массив в память
function sum(uint256[] memory arr) external returns (uint256) { ... }

// ХОРОШО: читает напрямую из calldata
function sum(uint256[] calldata arr) external returns (uint256) { ... }

Транзиентное хранилище (EIP-1153)

EIP-1153 (обновление Cancun, март 2024) ввёл два новых опкода: TSTORE и TLOAD. Транзиентное хранилище подобно обычному хранилищу, но автоматически очищается в конце транзакции.

Ключевые свойства:

  • Та же модель ключ-значение, что и у хранилища (2^256 слотов по 32 байта)
  • Стоимость 100 газа для TLOAD и TSTORE (аналогично тёплому SLOAD/SSTORE)
  • Очищается после завершения транзакции — нет постоянного изменения состояния
  • Доступно между фреймами вызовов в рамках одной транзакции

Идеально подходит для:

  • Блокировки реентрабельности — установить флаг через TSTORE, проверить через TLOAD. Без стоимости 20000 газа SSTORE.
  • Межконтрактной коммуникации в рамках транзакции — паттерны обратных вызовов, флеш-займы.
  • Временных разрешений — временные ERC-20 allowance в рамках одной транзакции.
// До EIP-1153: блокировка реентрабельности стоит ~5000-20000 газа
mapping(address => bool) private _locked;

// После EIP-1153: блокировка реентрабельности стоит ~200 газа
assembly {
    if tload(0) { revert(0, 0) }  // проверка блокировки
    tstore(0, 1)                   // установка блокировки
}
// ... тело функции ...
assembly {
    tstore(0, 0)                   // снятие блокировки
}

Практическая оптимизация: выбор правильной области

Вот система принятия решений для разработчиков MEV-ботов:

  1. Может ли значение жить в стеке? Используйте стек. Нулевая маржинальная стоимость.
  2. Нужно больше 16 значений? Используйте память. Аллоцируйте от свободного указателя.
  3. Это входные данные только для чтения? Используйте calldata. 3 газа за 32-байтное чтение.
  4. Должно сохраняться между транзакциями? Используйте хранилище. Бюджетируйте 20000+ газа для новых записей.
  5. Должно сохраняться между фреймами вызовов, но не между транзакциями? Используйте транзиентное хранилище. 100 газа.

Заключение

Модель памяти EVM обманчиво проста — четыре области с ясными компромиссами. Но разница в стоимости огромна: ADD на стеке стоит 3 газа, а SSTORE — 20000. Понимание этих затрат на интуитивном уровне — вот что отличает газоэффективные контракты от газорасточительных. В следующей статье мы точно рассчитаем стоимость газа и изучим паттерны оптимизации, способные сэкономить тысячи газа за вызов.