Storage layout в Solidity: как компилятор раскладывает переменные по слотам

Storage layout в Solidity — это то, как компилятор раскладывает переменные состояния контракта по 256-битным слотам хранилища EVM. От этого зависят:

  • предсказуемость работы контракта;
  • совместимость при апгрейдах через прокси;
  • корректность прямого чтения/записи storage через низкоуровневые вызовы.

Правильное понимание storage layout особенно важно, если вы используете прокси-паттерны, пишете код на Yul или анализируете контракты на низком уровне.

Storage layout в Solidity: как компилятор раскладывает переменные по слотам

Storage в EVM: базовая модель

В EVM хранилище контракта — это ассоциативный массив:

  • ключ — 256-битный индекс слота;
  • значение — 256-битное слово (32 байта).

Solidity проецирует переменные состояния на эти слоты по довольно жёстким правилам. Важно:

  • слоты нумеруются с 0;
  • порядок переменных в коде Solidity влияет на их размещение;
  • множество типов можно «упаковать» в один слот, если они занимают меньше 32 байт.

Базовые правила раскладки переменных

Упрощённо:

  • каждая переменная состояния получает основной слот (base slot);
  • для простых фиксированного размера типов (например, uint256) значение хранится прямо в этом слоте;
  • для динамических типов (bytes, string, динамические массивы, mapping) в основном слоте хранится «указатель» (смещение), а фактические данные лежат в других слотах, вычисляемых через keccak256.

Пример:

contract Example {
    uint256 public a;   // slot 0
    address public b;   // slot 1 (но может быть упакован с другими мелкими типами)
    bool public c;      // может упаковаться в slot 1
}

Упаковка (packing) простых типов в один слот

Solidity пытается компактно упаковать переменные, которые занимают меньше 32 байт:

  • в один слот можно поместить несколько переменных «меньше 256 бит»;
  • упаковка идёт в порядке объявления;
  • если следующая переменная не помещается в текущий слот, она переезжает в следующий.

Пример:

contract Packed {
    uint128 public a; // 16 байт
    uint128 public b; // 16 байт, итого a+b = 32 байта → оба в slot 0
    uint64  public c; // 8 байт → новый слот, slot 1
    bool    public d; // 1 байт → тоже slot 1
}

Размещение:

  • a и b — в slot 0 (каждый занимает половину слота);
  • c и d — в slot 1 (сначала c, затем d и возможные «заполнители»).

Если изменить порядок переменных, раскладка изменится — это критично при апгрейдах.

Динамические массивы и строки

Для динамических массивов и типов bytes/string используется другая схема:

contract Dyn {
    uint256[] public arr;  // base slot = p (допустим, p = 0)
    string   public name;  // base slot = p+1
}

Правила:

  • в слоте p хранится длина массива (количество элементов);
  • фактические элементы лежат начиная со слота keccak256(p):
    • элемент arr[i] хранится в слоте keccak256(p) + i.

Аналогично для bytes и string, но детали оптимизации зависят от размера строки:

  • короткие значения (bytes/string до 31 байта) могут храниться прямо в слоте вместе с длиной;
  • более длинные — по схеме, похожей на динамический массив, с указателем и данными в слотах keccak256(p) и далее.

Mapping и формула keccak256(key, slot)

Mapping не хранит данные «по порядку» — только через хеш:

contract Maps {
    mapping(address => uint256) public balance; // base slot = q
}

Схема:

  • в слоте q сам mapping пустой — там ничего полезного;
  • значение для balance[key] лежит в слоте:
  `keccak256(encode(key) . encode(q))`
где `.` — конкатенация, `encode` — 32-байтовое представление.

Последствия:

  • порядок вставки элементов не влияет на раскладку;
  • зная key и q, можно вычислить точный слот и читать значение напрямую из storage (полезно для дебага/аналитики).

Struct и вложенные структуры

Struct’ы используют те же правила, что и «простые» переменные, но внутри своей области:

struct Position {
    uint128 margin;  // упакуется с
    uint128 size;    // → один слот
    bool   isActive; // новый слот
}

contract Positions {
    Position public position; // base slot = r (например, r = 0)
}

Размещение:

  • position.margin и position.size — в slot r;
  • position.isActive — в slot r+1.

Если в контракте несколько переменных до position, их слоты учитываются; struct «занимает» последовательный диапазон. Вложенные динамические типы (например, uint256[] внутри struct) получат свои base slots и далее будут жить по правилам динамических массивов и mapping’ов.

Почему storage layout критичен для прокси и апгрейдов

В апгрейдируемых контрактах через прокси (DELEGATECALL и прокси):

  • прокси хранит всё состояние (storage);
  • реализация (implementation) содержит только логику;
  • при апгрейде меняется код реализаций, но storage-прокси должен остаться совместимым.

Ошибки storage layout:

  • изменение порядка переменных;
  • вставка новых переменных «в середину» списка;
  • изменение типов (например, uint256uint128)

приводят к тому, что новые версии контракта читают «не те» слоты и ломают состояние (балансы, роли, конфигурации).

Практика:

  • новые переменные добавлять только в конец списка;
  • резервировать «gap»-поля (массивы фиксированной длины) под будущие переменные;
  • строго документировать layout и не менять его без крайней необходимости.

Практические рекомендации

  • Планируйте переменные заранее: групируйте мелкие типы так, чтобы они хорошо упаковывались в слоты.
  • Не меняйте порядок и типы существующих переменных в апгрейдируемых контрактах.
  • Используйте gap’ы (uint256[50] private __gap;) в базовых контрактах для будущих расширений.
  • При низкоуровневой работе с storage (Yul, off-chain-анализ) всегда сверяйтесь с фактическим storage layout сгенерированным компилятором.

См. также

Task Runner