Deep EVM #2: Модель памяти EVM — стек, память, хранилище и calldata
Engineering Team
Четыре места для ваших данных
EVM предоставляет четыре различных области данных, каждая с радикально отличающейся стоимостью, временем жизни и паттернами доступа. Выбор неправильной — самый распространённый источник чрезмерного потребления газа в смарт-контрактах.
| Область | Время жизни | Стоимость чтения | Стоимость записи | Размер |
|---|---|---|---|---|
| Стек | Текущий опкод | 0 (DUP: 3) | 0 (PUSH: 3) | 1024 слова |
| Память | Текущий контекст вызова | MLOAD: 3* | MSTORE: 3* | Расширяемая |
| Хранилище | Постоянное (блокчейн) | SLOAD: 2100/100 | SSTORE: 20000/2900/100 | 2^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)
Хранилище — безусловно самая дорогая область данных:
| Операция | Холодный | Тёплый |
|---|---|---|
| SLOAD | 2100 | 100 |
| SSTORE (ноль -> ненулевое) | 22100 | 20000 |
| SSTORE (ненулевое -> ненулевое) | 5000 | 2900 |
| 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-ботов:
- Может ли значение жить в стеке? Используйте стек. Нулевая маржинальная стоимость.
- Нужно больше 16 значений? Используйте память. Аллоцируйте от свободного указателя.
- Это входные данные только для чтения? Используйте calldata. 3 газа за 32-байтное чтение.
- Должно сохраняться между транзакциями? Используйте хранилище. Бюджетируйте 20000+ газа для новых записей.
- Должно сохраняться между фреймами вызовов, но не между транзакциями? Используйте транзиентное хранилище. 100 газа.
Заключение
Модель памяти EVM обманчиво проста — четыре области с ясными компромиссами. Но разница в стоимости огромна: ADD на стеке стоит 3 газа, а SSTORE — 20000. Понимание этих затрат на интуитивном уровне — вот что отличает газоэффективные контракты от газорасточительных. В следующей статье мы точно рассчитаем стоимость газа и изучим паттерны оптимизации, способные сэкономить тысячи газа за вызов.