April 26, 2022

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

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

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

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

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

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

Начнем!

Погружаемся в память с головой!

Давайте снова взглянем на контракт из первой части нашей трилогии

А так же на байткод этого контракта:

608060405234801561001057600080fd5b50600436106100365760003560e01c80632e64cec11461003b5780636057361d14610059575b600080fd5b610043610075565b60405161005091906100d9565b60405180910390f35b610073600480360381019061006e919061009d565b61007e565b005b60008054905090565b8060008190555050565b60008135905061009781610103565b92915050565b6000602082840312156100b3576100b26100fe565b5b60006100c184828501610088565b91505092915050565b6100d3816100f4565b82525050565b60006020820190506100ee60008301846100ca565b92915050565b6000819050919050565b600080fd5b61010c816100f4565b811461011757600080fd5b5056fea2646970667358221220404e37f487a89a932dca5e77faaf6ca2de3b991f93d230604b1b8daaef64766264736f6c63430008070033

Сейчас речь пойдет про первые 5 байтов байтов контракта

6080604052
60 80                       =   PUSH1 0x80
60 40                       =   PUSH1 0x40
52                          =   MSTORE 

Эти 5 байтов представляют собой инициализацию «указателя свободной памяти». Чтобы полностью понять, что это значит и что делают эти байты, мы должны сначала сформировать ваше понимание структур данных, которые управляют контрактной памятью. Об этом сейчас и поговорим!

Memory Data Structure

Что на русском значит "структура данных в памяти"

Контрактная память — простой массив байтов, в котором данные могут храниться (store) блоками по 32 байта (256 бит) или по 1 байту (8 бит) и считываться (load) блоками по 32 байта (256 бит). На изображении ниже показана эта структура вместе с функциями чтения/записи контрактной памяти.

Все это дело определяется тремя опкодами, которые работают с памятью:

  • MSTORE (x, y) — сохраняет 32-байтовое (256-битное) значение «y», начиная с ячейки памяти «x»
  • MLOAD (x) — загружает 32 байта (256 бит), начиная с ячейки памяти «x», в стек вызовов
  • MSTORE8 (x, y) — сохраняет 1-байтовое (8-битное) значение «y» в ячейке памяти «x» (младший значащий байт 32-байтового значения стека).

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

Небольшая тренировка

EVM Playground поможет укрепить ваше понимание того, что делают эти 3 кода операции и как работают ячейки памяти. Нажмите «Run» и после стрелку в правом верхнем углу (выполняется пошагово), чтобы увидеть опокды и посмотреть, как изменяются стек и память. (Над кодами операций есть комментарии, описывающие, что делает каждая из них)

Обязательно перейдите по ссылке, иначе далее вы ничего не поймете! Держите оба окна открытыми и переключайтесь между ними, далее повествование будет вестись в связке с EVM playground

Рассматривая игровую площадку EVM выше, вы скорее всего заметили несколько странных явлений. Во-первых, когда мы записали один байт 0x22 с помощью MSTORE8 в ячейку памяти 32 (0x20), память изменилась с

на

Но как такое получилось? Мы же только 1 байт вроде добавили, а на деле у нас вылезла кучка нулей. Откуда?

Расширение памяти

Когда ваш контракт записывает что-то в память, вы должны платить за количество записанных байтов (газ). Если вы выполняете запись в область памяти, в которую раньше не производилась запись, то при первом ее использовании потребуется дополнительная плата за расширение памяти

Память расширяется исключительно с шагом 32 байта (256 бит) при записи в ранее нетронутое пространство памяти

Затраты на расширение памяти масштабируются линейно для первых 724 байт и квадратично после этого

В нашей памяти было 32 байта до того, как мы записали 1 байт в ячейку 32 ("22" дописали). В этот момент мы начали запись в нетронутую память, в результате память была расширена еще одним 32-байтовым приращением до 64 байт

Обратите внимание, что все места в памяти изначально определяются как нулевые, поэтому мы видим 2200000000000000000000000000000000000000000000000000000000000000, добавленную в нашу память

Память - массив байтов

Следующие, что вы могли заметить, так это то, что когда мы запускали MLOAD из ячейки памяти 33 (0x21), то мы вернули следующее значение в стек вызовов:

3300000000000000000000000000000000000000000000000000000000000000

Мы смогли начать чтение с не кратной числу 32 ячейке (с не кратной по индексу)!

Помните, что память — это массив байтов, что означает, что мы можем начать чтение (и запись) из любого места в памяти. Мы не ограничены числом, кратным 32. Память линейна и может быть адресована на уровне байтов

Память может быть создана заново только в функции. Это могут быть либо вновь созданные сложные типы, такие как массивы или структуры (например, через new int[...]), либо скопированные из переменной, на которую ссылается хранилище

Теперь у нас есть понимание структур данных, вернемся к указателю свободной памяти!

Указатель свободной памяти

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

Это защищает от перезаписи контрактом части памяти, которая была выделена для другой переменной

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

Указатель Свободной Памяти + Размер памяти = Новый Указатель Свободной Памяти

Байткод

Как упоминалось ранее, указатель свободной памяти определяется в начале байткода через выполнение этих 5 опкодов:

60 80                       =   PUSH1 0x80
60 40                       =   PUSH1 0x40
52                          =   MSTORE  

Они фактически объявляют, что указатель свободной памяти находится в памяти в байте 0x40 (64 в десятичном формате) и имеет значение 0x80 (128 в десятичном формате)

Вы скорее всего задаетесь вопросом, а почему именно значения 0x40 и 0x80 используются выше. Ответ на это можно найти в следующем утверждении

Схема памяти Solidity резервирует четыре 32-байтных слота:
0x00 - 0x3f (64 байта): чистое пространство
0x40 - 0x5f (32 байта): указатель свободной памяти
0x60 - 0x7f (32 байта): нулевой слот

Мы можем видеть, что 0x40 является предопределенным местоположением у Solidity для указателя свободной памяти. Значение 0x80 — это просто первый байт памяти, доступный для записи после 4 зарезервированных 32-байтовых слотов

Сейчас вы быстро пробежимся по тому, что делает каждый зарезервированный раздел:

  • Чистое пространство, может использоваться между операторами, т. е. во встроенной сборке и для методов хеширования (временная память)
  • Указатель свободной памяти - текущий размер выделенной памяти, начальная позиция свободной памяти, изначально 0x80
  • Нулевой слот используется в качестве начального значения для массивов динамической памяти и никогда не должен записываться

Память на примере реального контракта

Чтобы закрепить то, что мы узнали - нам необходимо рассмотреть то, как память и указатель свободной памяти обновляются в реальном коде Solidity

Я создал контракт MemoryLane и намеренно сделал его предельно простым. У него есть единственная функция, которая просто определяет два массива длиной 5 и 2, а затем присваивает b[0] значение 1. Несмотря на простоту, за эти три строки много чего успевает поменяться в памяти

Чтобы просмотреть подробности того, как этот код Solidity выполняется в EVM, его нужно скопировать в Remix IDE. После это все надо скомпилировать, развернуть, запустить функцию memoryLane(), а затем войти в режим дебага, чтобы просмотреть опкоды (см. здесь инструкции о том, как это сделать). Я извлек упрощенную версию на EVM Playground и пройдусь по ней ниже.

Упрощенная версия последовательно организует опкоды, удаляя любые JUMP и любой другой код, который не имеет отношения к манипуляциям с памятью. Комментарии были добавлены в код, чтобы обеспечить контекст того, что делается. Код разделен на 6 отдельных разделов, в которые мы по ходу углубимся

Повторюсь, EVM Playground необходимо просматривать параллельно с данной статьей, эта связка значительно упростит усвоение информации!

Инициализация указателя свободной памяти

(строки 1-15 EVM Playground)

Во-первых, у нас есть «инициализация указателя свободной памяти», которую мы обсуждали выше. Значение 0x80 (128 в десятичном формате) помещается в стек. Это значение указателя свободной памяти, определяемое структурой памяти Solidity. На данном этапе у нас ничего нет в памяти

Затем мы перемещаем указатель свободной памяти на 0x40 (64 в десятичном формате), снова определяемый структурой памяти Solidity

Наконец, мы вызываем MSTORE, который берет первое значение (0x40) как адрес, в который буду записываться данные, а второе (0x80) как непосредственно сами данные, который и будут записаны

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

У нас в памяти 192 шестнадцатеричных символа, что означает, что у нас есть 96 байта (1 байт = 8 бит = 2 шестнадцатеричных символа)

Если мы вернемся к схеме памяти Solidity, то нам сказали что первые 64 байта будут выделены как чистое пространство, а следующие 32 байта будут для указателя свободной памяти

Это именно то, что мы имеем ниже

Выделение памяти для переменной «a» и обновление указателя свободной памяти

(строки 16-34 EVM Playground)

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

Следующая память выделяется для переменной «a» (bytes32[5]), а так же обновляется указатель свободной памяти

Компилятор определит, сколько места требуется, через размер массива и размер элемента массива по умолчанию

Помните, что элементы в массивах памяти в Solidity всегда занимают кратные 32 байта (это верно даже для bytes1[], но не для байтов и строк)

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

В этом случае вычисление 5 * 32 = 160 или 0xa0 в шестнадцатеричном виде. Мы можем видеть, как это помещается в стек и добавляется к текущему указателю свободной памяти 0x80 (128 в десятичном формате), чтобы получить новое значение указателя свободной памяти

Это возвращает 0x120 (288 в десятичном формате), которое, как мы видим, было записано в место указателя свободной памяти

Стек вызовов сохраняет местоположение переменной «a» в стеке 0x80, поэтому при необходимости он может ссылаться на нее позже. 0xffff представляет местоположение JUMP и может быть проигнорировано, поскольку оно не имеет отношения к манипуляциям с памятью

Инициализации переменной «a»

(строки EVM Playground 35–95)

Теперь, когда память выделена и указатель свободной памяти обновлен, нам нужно инициализировать пространство памяти для переменной «a». Поскольку переменная только что объявлена ​​и не назначена, она будет инициализирована нулевым значением

Для этого запись EVM использует CALLDATACOPY, которая принимает 3 переменные:

  • memoryOffset (в какую ячейку памяти копировать данные)
  • calldataOffset (смещение байтов в calldata для копирования)
  • размер (размер байта для копирования)

В нашем случае memoryOffset — это место в памяти для переменной «a» (0x80). CalldataOffset — это фактический размер наших данных вызова, поскольку мы не хотим копировать какие-либо данные вызова, мы просто хотим инициализировать память нулевым значением. По итогу размер равен 0xa0 или 160 байт, поскольку это размер переменной

Мы можем видеть, что наша память расширилась до 288 байт (включая нулевой слот), и стек снова содержит местоположение переменной в памяти и местоположение JUMP в стеке вызовов

Выделение памяти для переменной «b» и обновление указателя свободной памяти

(строки EVM Playground 96–112)

Это то же самое, что выделение памяти и обновление указателя свободной памяти для переменной «a», за исключением того, что на этот раз это для «bytes32[2] memory b»

Указатель памяти обновляется до 0x160 (352 в десятичном формате), что равно предыдущему указателю свободной памяти 288 байт плюс размер новой переменной 64 байта

Обратите внимание, что указатель свободной памяти обновился до 0x160 и теперь у нас есть ячейка памяти для переменной «b» (0x120) в стеке

Инициализации переменной «b»

(строки EVM Playground 113–162)

То же, что и инициализация переменной «a»

Обратите внимание, что объем памяти увеличился до 352 байт. Стек по-прежнему содержит ячейки памяти для двух переменных

Присваиваем значение b[0]

(строки EVM Playground 163-207)

Наконец, мы добираемся до присвоения значения массиву «b» с индексом 0. В коде указано, что b[0] должно будет иметь значение равное 1

Это значение помещается в стек 0x01. Далее происходит битовый сдвиг влево, однако вход для битового сдвига равен 0, что означает, что наше значение не меняется

Затем позиция индекса массива, которая должна быть записана в 0x00, помещается в стек, и выполняется проверка, чтобы убедиться, что это значение меньше, чем длина массива 0x02. Если это не так, то выполнение переходит к другой части байткода, которая обрабатывает это состояние ошибки

Коды операций MUL (умножение) и ADD (сложение) используются для определения того, где в памяти нужно записать значение, чтобы оно соответствовало правильному индексу массива

0x20 (32 в десятичном виде) * 0x00 (0 в десятичном виде) = 0x00

Помните, что массивы памяти представляют собой 32-байтовые элементы, поэтому это значение представляет собой начальное положение индекса массива. Учитывая, что мы пишем по индексу 0, у нас нет смещения

0x00 + 0x120 = 0x120 (288 в десятичном виде)

ADD используется для добавления этого значения смещения к ячейке памяти для переменной «b». Учитывая, что наше смещение равно 0, мы будем записывать наши данные прямо в назначенную ячейку памяти

Наконец, MSTORE сохраняет значение 0x01 в эту ячейку памяти 0x120

На изображении ниже показано состояние системы в конце выполнения функции. Все элементы стека были удалены

Обратите внимание, что на самом деле в ремиксе в стеке осталось несколько элементов, расположение JUMP и сигнатура функции, однако они не имеют отношения к манипулированию памятью и поэтому были опущены на игровой площадке EVM.

Наша память была обновлена, чтобы включить присваивание b[0] = 1, в третьей снизу строке нашей памяти значение 0 превратилось в 1

Вы можете убедиться, что значение находится в правильном месте памяти, b[0] должно занимать ячейки 0x120–0x13f (байты 289–320)

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

Когда вы теперь будете просматриваете некоторые коды операций контракта и видеть определенные ячейки памяти, которые продолжают всплывать (0x40), то будете точно знать, что они означают

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

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