April 28, 2022

EVM для задротов 3

Всем привет! С вами Тёма!

Сегодня мы продолжим углубляться в EVM и пытаться в нем разобраться!

По своей сути данная статья является переводом и выжимкой вот этого парня ТЫК (но только заключительной части его трилогии)

Так же хочу предупредить, новичкам в программировании скорее всего будет сложно понять происходящее, поэтому если вы действительно хотите все впитать, то читайте неспешно и вместе с гуглом

А перед прочтением обязательно ознакомьтесь с первой частью тут - ТЫК, и со второй частью тут - ТЫК

Начнем!

Основы памяти

Data Structure

Начнем мы со структур данных, потому что это фундаментал и дальше без него будет проблематично

Хранилище контрактов это просто mapping (словарь; ключ -> значение). Он сопоставляет 32-байтовый ключ с 32-байтовым значением. Учитывая, что размер нашего ключа составляет 32 байта, у нас может быть максимум (2^256)-1 ключей.

32 байта равны 256 битам, что дает нам (2^256)-1 двоичное число для выбора ключа

Все значения инициализируются как 0, а нули явно не сохраняются. Это имеет смысл, поскольку 2^256 — это приблизительное количество атомов в известной наблюдаемой Вселенной

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

Концептуально хранилище можно рассматривать как астрономически большой массив. Наш первый ключ с двоичным значением 0 представляет элемент 0 в массиве, ключ с двоичным значением 1 представляет элемент 1 в массиве и т. д.

Переменные фиксированного размера

Переменные контракта, которые объявлены как переменные хранения, можно разделить на 2 лагеря фиксированного размера и динамического размера. Сейчас мы сосредоточимся на переменных фиксированного размера и на том, как EVM может упаковать несколько переменных в один 32-байтовый слот для хранения

Теперь, когда мы знаем, что хранилище — это сопоставление ключ-значение, следующий вопрос — как ключи назначаются переменным. Скажем, у нас есть следующий код Solidity

Учитывая, что все эти переменные имеют фиксированный размер, EVM может использовать зарезервированные места хранения (ключи), начиная со слота 0 (ключ двоичного значения 0) и линейно продвигаясь вперед к слоту 1, 2 и т. д

Это будет сделано в зависимости от порядка объявления переменных в контракте. Первая объявленная переменная хранения будет храниться в слоте 0

В этом примере слот 0 будет содержать переменную «value1», переменная «value2» представляет собой массив фиксированного размера из 2, поэтому займет слоты 1 и 2, и, наконец, слот 3 будет содержать переменную «value3». Диаграмма ниже показывает это.

Теперь давайте взглянем на аналогичный контракт и проверим, как в этом сценарии хранятся переменные

Вы можете думать, что это займет слоты с 0 по 3, как в предыдущем примере. У нас было 4 значения для хранения в нашем предыдущем примере (с учетом массива размера 2), и у нас есть 4 значения для хранения в этом примере

Но это не так!

Вы можете быть удивлены, узнав, что в этом примере используется только слот хранения 0. Ключевое отличие заключается в типах единиц измерения, которые используются для переменных

Раньше все переменные имели тип uint256, который представляет 32 байта данных. Здесь мы используем uint32, uint64 и uint128, которые представляют 4, 8 и 16 байт данных соответственно, которые в сумме умещаются в 32 байтовый слот

Динамические переменные

Простыми словами - переменные, которые могут изменять свой размер

Использование зарезервированных слотов (о которых говорили только что) хорошо работает для переменных фиксированного размера, но не работает для массивов динамического размера и mapping, потому что нет способа узнать, сколько слотов нужно зарезервировать

Как нам уже известно, в Solidity имеется невероятно большое количество памяти, которое мы можем использовать, и поэтому просто раскидывать наугад по разным участкам памяти динамические переменные - намного удобнее, чем классическая работа с динамическими переменными

Но конечно, выбор местоположений наугад не был бы очень полезен, потому что у вас не было бы возможности снова найти данные. Вместо этого Solidity использует хеш-функцию для единообразного и многократного вычисления местоположений для значений с динамическим размером

Массивы с динамическим размером

Массиву с динамическим размером требуется место для хранения его размера, а также его элементов

contract StorageTest {
   uint256 a; // slot 0
   uint256[2] b; // slots 1-2
   
   struct Entry {
      uint256 id;
      uint256 value;
   }
   
   Entry c; // slots 3-4
   Entry[] d;
}

В приведенном выше коде массив d с динамическим размером находится в слоте 5, но единственное, что там хранится, — это размер d. Значения в массиве сохраняются последовательно, начиная с хеша слота

Следующая функция Solidity вычисляет местоположение элемента массива с динамическим размером:

function arrLocation(uint256 slot, uint256 index, uint256 elementSize)
public
pure
returns (uint256) {
   return uint256(keccak256(slot)) + (index * elementSize);
}

Mappings

Mapping требует эффективного способа найти местоположение, соответствующее данному ключу. Хэширование ключа — хорошее начало, но необходимо позаботиться о том, чтобы разные mapping генерировали разные местоположения

contract StorageTest {
   uint256 a; // slot 0
   uint256[2] b; // slots 1-2
   
   struct Entry {
      uint256 id;
      uint256 value;
   }
   Entry c; // slots 3-4
   Entry[] d; // slot 5 for length, keccak256(5)+ for data
   
   mapping(uint256 => uint256) e;
   mapping(uint256 => uint256) f;
}

В приведенном выше коде «местоположением» для e является слот 6, а для f — слот 7, но на самом деле в этих местах ничего не сохраняется. (Длина не сохраняется, и отдельные значения должны быть расположены в другом месте)

Чтобы найти местоположение определенного значения в mapping, ключ и слот mapping хэшируются вместе

Следующая функция Solidity вычисляет местоположение значения:

function mapLocation(uint256 slot, uint256 key)
public
pure
returns (uint256) {
   return uint256(keccak256(key, slot));
}

Обратите внимание, что когда keccak256 вызывается с несколькими параметрами, параметры объединяются перед хешированием. Поскольку слот и ключ являются входными данными для хеш-функции, между различными mapping не возникает пересечений

Комбинации сложных типов

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

contract StorageTest {
   uint256 a; // slot 0
   uint256[2] b; // slots 1-2 struct

   Entry {
      uint256 id;
      uint256 value;
   }
   Entry c; // slots 3-4
   Entry[] d; // slot 5 for length, keccak256(5)+ for data

   mapping(uint256 => uint256) e; // slot 6, data at h(k . 6)
   mapping(uint256 => uint256) f; // slot 7, data at h(k . 7)

   mapping(uint256 => uint256[]) g; // slot 8
   mapping(uint256 => uint256)[] h; // slot 9
}

Чтобы найти элементы внутри этих сложных типов, мы можем использовать функции, который описывались выше

Чтобы найти g[123][0]:

// first find arr = g[123]
arrLoc = mapLocation(8, 123); // g is at slot 8
// then find arr[0]
itemLoc = arrLocation(arrLoc, 0, 1);

Чтобы найти h[2][456]:

// first find map = h[2]
mapLoc = arrLocation(9, 2, 1); // h is at slot 9
// then find map[456]
itemLoc = mapLocation(mapLoc, 456);

Упаковка слотов

Компилятор Solidity знает, что он может хранить 32 байта данных в одном слоте хранения. В результате, когда «uint32 value1», занимающее всего 4 байта, хранится в слоте 0, и следующая переменная читается в компиляторе, то он посмотрит, можно ли ее упаковать в текущий слот памяти

В данном слоте 0 было 32 байта пространства, а value1 занимало только 4 из них, если следующая переменная имеет размер менее 28 байт, она также будет упакована в слот 0

В приведенном выше примере мы начинаем с 32 байтов в слоте 0:

value1 хранится в слоте 0, который занимает 4 байта
в слоте 0 осталось 28 байт
переменная 2 хранит 4 байта, что составляет <= 28, поэтому его можно сохранить в слоте 0
в слоте 0 осталось 24 байта значение
переменная 3 хранит 8 байтов, что составляет <= 24, поэтому его можно сохранить в слоте 0
в слоте 0 осталось 16 байтов значение
переменная 4 хранит 16 байтов, что <= 16, поэтому его можно сохранить в слоте 0
в слоте 0 осталось 0 байтов
Обратите внимание, что uint8 — это наименьший тип в Solidity, поэтому упаковка не может быть меньше 1 байта (8 бит).

Опкоды хранилища EVM

Теперь, когда мы понимаем структуру данных хранилища и концепцию упаковки слотов, давайте кратко рассмотрим 2 кода операций хранилища SSTORE и SLOAD

SSTORE

Мы начнем с SSTORE, который принимает 32-байтовый ключ и 32-байтовое значение из стека вызовов и сохраняет это 32-байтовое значение в месте, указанном 32-байтовым ключом (вот EVM Playground с демонстрацией)

SLOAD

Затем у нас есть SLOAD, который принимает 32-байтовый ключ из стека вызовов и помещает в этот же самый стек вызовов 32-байтовое значение, хранящееся в месте, указанном по 32-байтовому ключу (вот EVM Playground с демонстрацией)

На этом этапе вы должны задать себе вопрос: если SSTORE и SLOAD имеют дело только с 32-байтовыми значениями, как тогда можно извлечь переменную, которая была упакована в 32-байтовый слот?

Если вы возьмете наш пример выше, когда мы запустим SLOAD в слоте 0, то получим полное 32-байтовое значение, хранящееся в этом месте

Это значение будет включать данные для переменной 1, 2, 3 и 4. Но как EVM извлекает определенные байты из этого 32-байтового слота, чтобы вернуть нужное нам значение?

Ведь то же самое происходит, когда мы запускаем SSTORE, если мы сохраняем 32 байта каждый раз, как EVM гарантирует, что когда мы сохраняем переменную 2, оно не перезаписывает переменную 1. Когда мы сохраняем переменную 3, оно не перезаписывает переменной 2 и т. д

Вот вопросы, на которые мы постараемся ответить дальше

Хранение и извлечение упакованных переменных

Ниже приведен простой контракт, имитирующий пример, который мы рассмотрели выше. Единственным дополнением является функция сохранения, которая устанавливает значения переменных и должна считывать одну переменную для выполнения некоторых арифметических действий

Функция store() в приведенном выше Solidity будет выполнять именно те операции, о которых у нас были вопросы

Хранение нескольких переменных в одном слоте без перезаписи существующих данных и получение определенных байтов переменной из 32-байтового слота

Давайте начнем с просмотра конечного состояния слота 0 и будем работать в обратном направлении. Ниже представлено как двоичное, так и шестнадцатеричное представление слота 0

Помните, что шестнадцатеричные числа в конечном итоге воспринимаются машиной как двоичные числа. Это важно, поскольку при упаковке слотов используется ряд побитовых операций

Обратите внимание на значения, которые вы видите в шестнадцатеричном формате: 0x115c = 4444 в десятичном виде, 0x14d = 333, 0x16 = 22 и 0x01 = 1. Они соответствуют тому, что мы видим в нашем коде Solidity. Один слот содержит 32 байта данных, что составляет 64 шестнадцатеричных символа или 256 бит

Побитовые операции

Упаковка слотов использует 3 побитовых операции AND, OR и NOT. Они соответствуют трем опкодам EVM с идентичными именами. Давайте кратко их рассмотрим

Пример AND (конъюнкция):

Пример OR (дизъюнкция):

Пример NOT (отрицание):

Давайте теперь перейдем к тому, как они используются в приведенном выше примере Solidity!

Управление слотами — упаковка слотов SSTORE

Сейчас мы с фокусируемся на 18 строке нашего контракта

value2 = 22;

На этом этапе некоторые данные, а именно переменная 1, 2, 3, 4 были сохранены в слоте 0, теперь нам нужно упаковать некоторые дополнительные данные в тот же слот

Вся логика, которую мы видим в этом примере, такая же, как и при сохранении переменных 3 и 4. Мы рассмотрим, как это делается концептуально, и вам будет предоставлена ​​EVM Playground для дальнейшего изучения.

Начнем со следующих значений:

Обратите внимание, что «0xffffffff» равно «1111111111111111111111111111111111» в двоичном формате (переводы между система счисления ТЫК)

Первое, что делает EVM, это использует код операции EXP (возведение в степень), который принимает на вход базовое целое число и показатель степени соотвественно

Здесь мы используем 0x0100 в качестве базового целого числа, которое представляет 1 байт, и возводим его в степень 0x04, которая является начальной позицией для «value2». На изображении ниже показано, почему возвращаемое значение полезно

Мы видим, что результат функции EXP позволяет нам вставить наши данные в правильную позицию

Однако мы не можем использовать это, так как это приведет к перезаписи переменной 1, которая уже была сохранена. Поэтому здесь используются битовые маски (чуть-чуть о битовых масках тут - ТЫК)

На приведенном выше изображении показано, как можно использовать битовую маску для получения всех данных из слота, кроме байтов, которые вы хотите перезаписать. В этом случае байты переменной 2 уже были установлены на 0, однако, если бы они не были, мы бы увидели, что эти данные были стерты

Вот еще один пример, чтобы понять, что происходит. Это тот же процесс, но посмотрите, что произойдет, если все 4 значения уже будут сохранены, и мы хотим обновить переменную 2 с 22 до 99. Обратите внимание на обнуление существующего значения 0x016

Возможно, вы уже думаете о том, как логическое ИЛИ может помочь нам объединить имеющиеся у нас значения. На изображении ниже показаны следующие шаги

Теперь мы можем использовать SSTORE для этого 32-байтового значения в слоте 0, которое содержит данные как для переменной 1, так и для переменной 2 в правильных байтовых позициях

Управление слотами — получение упакованной переменной SLOAD

На этот раз мы сфокусируемся на 22 строке нашего контракта

uint96 value5 = value3 + uint32(666)

Нас не интересует арифметика, нас интересует получение переменной 3 для выполнения вычислений

Наш набор начальных значений:

Многое из того, что мы уже видели, будет повторно использовано для поиска с некоторыми модификациями

Мы получили значение переменной 3 из нашего упакованного слота 0. Шестнадцатеричное 0x14d равно 333, что мы и установили в приведенном выше коде Solidity

Опять же, битовые маски и побитовые операции используются для извлечения определенных байтов из 32-байтового слота. Это значение теперь находится в стеке и затем может использоваться EVM для вычисления «value3 + uint32(666)»

EVM Playground

Я взяли все покоды, выполняемые в функции store(), которую мы только что исследовали, и поместили их на EVM Playground. Здесь вы сможете в интерактивном режиме поиграть с используемыми опкодами и посмотреть, как стек вызовов и хранилище контрактов изменяются по мере того, как вы их просматриваете

Я оставил комментарии рядом с опкодами в двух изученных нами разделах (строки контракта 18 и 22). Я настоятельно рекомендую вам проверить это и просмотреть опкоды самостоятельно, это значительно улучшит ваше понимание.

Заключение

Теперь у вас должно быть глубокое понимание того, как работает упаковка слотов хранения и как EVM может извлекать и сохранять байты в определенных местах в 32-байтовом слоте

Надеюсь статья была интересной и понятной!

Мой телеграмм канал - https://t.me/ortomich_crypto