Понимаем байт-код EVM: Часть 3
Оригинал — https://blog.trustlook.com/understand-evm-bytecode-part-3/
Канал — https://t.me/jetix37eth
В предыдущих частях мы говорили о байт-коде виртуальной машины для создания и выполнения. Мы видели, что переменные стека обычно используются для предоставления операндов кодам операций или передачи целочисленных аргументов между вызовами внутренних функций. Все переменные, связанные с состоянием, должны быть сохранены в хранилище.
Однако хранилище спроектировано в виде словаря или хэш-таблицы. Оно будет содержать все данные по парам ключ-значение. Для каждого адресного ключа он может хранить 32-байтовое целое число. Итак, как он может реализовать сложные структуры данных, struct, mapping, массивы переменной длины и т.д.? Давайте подробнее разберемся в их реализации.
Давайте начнем с относительно простого: типы данных фиксированного размера. Мы уже знаем, что EVM поддерживает целые числа разной длины, от 1 байта до 32 байт. Если вы определили только несколько 32-байтовых целочисленных переменных в своих смарт-контрактах, компилятор просто назначит переменным последовательность адресов, начинающихся с 0x0. Например:
Если вы скомпилировали приведенный выше код и проверили байт-код EVM, вы обнаружите, что весь код, считывающий или записывающий 3 переменные balance1, balance2 и balance3, будет сопоставляться операциям SLOAD или SSTORE по адресам 0x0, 0x1 и 0x2 соответственно.
Однако все мы знаем, что использование хранилища обходится дорого. Если вы понятия не имеете, что такое газ в EVM, я рекомендую вам провести небольшое исследование по этому вопросу. Есть тонны статей, в которых говорится об этом. По сути, это стоимость выполнения вашего кода.
Таким образом, чтобы оптимизировать затраты на газ при использовании хранилища, когда у вас есть несколько целых чисел небольшой длины, они будут оптимизированы для использования 1 слота хранения. Например:
Когда у вас есть две переменные, определенные как uint128, они могут быть помещены в один слот памяти по адресу 0x0, а третьей переменной balance3 будет присвоен собственный адрес 0x1. Когда вы ссылаетесь на переменную balance1, первое 16-байтовое значение будет извлечено после SLOAD или SSTORE. Давайте посмотрим на какой-нибудь реальный пример этого:
6080604052600436106100615763ffffffff7c010000000000000000000000000000000000000000000000000000000060003504166340441eec81146100665780634f2be91f146100a0578063c45c4f58146100c7578063f24a0faa146100dc575b600080fd5b34801561007257600080fd5b5061007b6100f1565b604080516fffffffffffffffffffffffffffffffff9092168252519081900360200190f35b3480156100ac57600080fd5b506100b561011d565b60408051918252519081900360200190f35b3480156100d357600080fd5b5061007b610158565b3480156100e857600080fd5b506100b5610170565b60005470010000000000000000000000000000000090046fffffffffffffffffffffffffffffffff1681565b6000546fffffffffffffffffffffffffffffffff80821670010000000000000000000000000000000090920481169190910116600181905590565b6000546fffffffffffffffffffffffffffffffff1681565b600154815600a165627a7a72305820fa0e623e455a9cc0439ea393dff5b50cc571150034eb57658dd9718e3982b1590029
По соображениям экономии времени мы не будем рассматривать их все, давайте просто посмотрим на строку вычисления add внутри функции add():
sstore(0x1,uint128((uint128((sload(0x0) / 0x100000000000000000000000000000000)) + uint128(sload(0x0)))));
Логика использования слотов хранения 0x0 и 0x1 четко показана в приведенной выше строке, которая является строкой balance3 = balance1 + balance2;
Мы можем видеть, что одна строка кода Solidity может привести к множеству инструкций В будущем мы обсудим еще более сложные структуры данных, это будет ошеломляюще, если мы посмотрим на все коды операций.
Поэтому, чтобы сделать наш контент более читабельным, я просто покажу эквивалентный код на ассемблере, чтобы доказать логику. Тем не менее, вы всегда можете потратить больше времени на изучение байт-кода один за другим, чтобы попрактиковаться.
Мы говорили о самом базовом типе данных, Integer, в EVM. Теперь давайте посмотрим на struct в Solidity:
Эквивалентный код ассемблера для функции deposit() выглядит так:
Мы можем видеть, что даже если мы определяем struct Funder в нашем контракте, компилятор все равно генерирует код как просто 2 обычные переменные хранилища. Кроме того, если несколько элементов-членов внутри структуры могут поместиться в один слот для хранения, оптимизация также произойдет.
Теперь давайте поговорим об очень популярном типе данных — mapping.
Если вы знакомы с разработкой смарт-контрактов, вы, вероятно, увидите, что многие приложения аналогично используют отображение для записи баланса учетной записи. Давайте скомпилируем код и проверим ассемблерный код переменной balanceOf:
mstore(0x0,msg.data(0x04) & 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF);
Мы можем увидеть msg.data(0х04) & 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFffffffffff получит параметр из данных полезной нагрузки в качестве значения адреса. Затем этот аргумент будет помещен в адрес памяти 0x0. Кроме того, для адреса 0x20 в памяти задано значение 0x0. Этот 0x0 на самом деле является индексом объявления переменных хранилища. Это только потому, что balanceOf является первой объявленной переменной в смарт-контакте.
После настройки памяти вызывается код операции SHA3 для вычисления хэш-значения входных данных, и результаты будут использоваться в качестве ключа хранения для mapping-переменной. Из приведенного выше кода мы можем видеть, что когда вы объявляете mapping-переменную одного уровня, компилятор фактически принимает ее как функцию с одним аргументом и возвращает одно значение. По соображениям удобочитаемости давайте использовать arg0 для значения, установленного в адресе 0x0 в памяти вычисления сопоставления.
Давайте взглянем на двухуровневый mapping:
mapping (address => mapping (address => uint256)) public tokens;
Возможно, вы видели некоторые похожие mapping-переменные, подобные приведенным выше. Когда вы скомпилируете код и сгенерируете байт-код, его ассемблерный код будет выглядеть следующим образом:
По-видимому, для двухуровневой mapping-переменной это фактически функция с двумя аргументами и возвращающая одно значение. Для этой tokens он сначала настроил память с 0x0 по адресу 0x20 (мы знаем, что 0x0 - это индекс объявления переменной), arg0 по адресу 0x0. Затем первое HASH-значение было вычислено с использованием кода операции SHA3.
Однако это вычисленное значение снова будет помещено в адрес памяти 0x20, а второй аргумент arg1 на этот раз будет помещен по адресу 0x0 для другого вычисления SHA3. Затем результат будет использоваться в качестве ключа хранения для переменной. В той же логике вы можете продолжать добавлять уровни для ваших mapping-переменных, и ключом для хранилища всегда будет последний вычисленный результат SHA3.
До сих пор вы, возможно, лучше понимали, почему указатель на свободную память всегда сохраняется в 0x40, а не в 0x0 или 0x20. Это происходит только потому, что эти адреса зарезервированы для вычисления хэша.
Ранее мы изучили несколько структур данных в Solidity. Что, если мы соберем некоторые из них вместе? Например, что делать, если у нас есть mapping-переменная, которая возвращает значение struct:
Давайте взглянем на декомпилированный код для balanceOf:
function balanceOf( address arg0) public return (var0,var1) {
return(sload(temp0),sload((temp0 + 0x1)));
Мы можем видеть, что вычислительная часть SHA3 такая же, как и обычная одноуровневая mapping-переменная. Единственное отличие заключается в том, что обычный тип данных, такой как integer, будет просто использовать результат хэша в качестве ключа хранения. Но элемент-член внутри структуры применит индекс объявления элемента к этому хэш-значению для ключа хранения.
Таким образом, в этом случае два элемента-члена в struct Funder добавят 0x0 и 0x01 к хэш-значению temp0 для ключа хранения.
Далее мы рассмотрим структуры данных с переменной длиной. Например, у нас есть вот такой смарт-контракт:
Мы определили массив переменной длины senders. Давайте посмотрим, как EVM реализует эту переменную в хранилище:
function senders( uint256 arg0) public return (var0) {
return(uint160(sload((temp0 + arg0))));
Из приведенного выше ассемблерного кода мы можем заметить, что переменная реализована как функция с одним аргументом index, возвращающая элемент из массива. В Solidity мы определили только одну переменную массива senders.
Как мы обсуждали ранее, хранилище назначит индекс адреса для каждой переменной, а поскольку существует только одна, ей присваивается 0x0. Однако адреса 0x0 недостаточно для хранения всех данных массива. Вместо этого он будет содержать длину массива.
Для данных массива хранилище использует значение SHA3 своего адресного индекса (в данном случае 0x0) в качестве начала данных массива. Индекс массива будет добавлен к этому хэш-значению в качестве ключа хранения для соответствующего элемента.
Итак, давайте вернемся к приведенному выше фрагменту кода. Когда внешние вызыватели хотят получить доступ к элементу в массиве по индексу, сначала проверяется, превышает ли индекс длину массива. Если нет, то будет вычислено значение хэша, и индекс будет добавлен к этому хэшу, чтобы получить элемент массива.
До сих пор мы обсуждали struct, mapping и array. Есть еще один тип данных, который может вас заинтересовать — строки. String и bytes - это один и тот же тип данных для хранения байтов переменной длины. Давайте посмотрим, как это будет выглядеть, когда мы объявим строковую переменную в хранилище:
Этот смарт-контракт ничего не делает, кроме как просто возвращает строку вызывающему. Давайте посмотрим, как строка сохраняется в хранилище. Даже при наличии одной строки скомпилированный код на ассемблере немного сложен:
Код на ассемблере немного сложен, давайте рассмотрим его логику.
Сначала давайте разберемся с логикой “((((0x100 * ((0x1 & sload(0x0)) == 0)) – 0x1) & sload(0x0)) / 0x2)”. По-видимому, поскольку в контракте определена только одна переменная hello, для этой переменной зарезервирован адрес хранилища 0x0. Но, похоже, дело не только в длине, поданной как массивы.
Из этого сравнения “((0x1 & sload(0x0)) == 0))” мы знаем, что последний бит этого поля является флагом. Если бит установлен в 1, то это сравнение равно False (0x0), поэтому вся строка будет “(uint256(sload(0x0)) / 0x2)”. Если бит установлен в 0, то вся строка будет “(uint8(sload(0x0)) / 0x2)”. Это значение присвоено var7 в приведенном выше коде.
Затем это значение сравнивается с 0x1F. Если оно меньше 0x1F, эта строка “mstore(var5,((sload(0x0) / 0x100) * 0x100));” скопирует первые байты 0x1F из хранилища 0x0 в память. Если оно больше 0x1F, то результатом будет некоторая аналогичная операция, которую мы видели в массиве переменной длины.
Основываясь на том, что мы видели из кода на ассемблере, мы можем понять основную логику строк. Максимально использовать хранилище, если строка меньше 0x1F — значит она может быть сохранена в одном слоте хранилища.
Таким образом, последний бит поля установлен в 0, а последний байт имеет длину строки, умноженную на 2. И строка сохраняется в первых 31 байте в слоте. Однако, если длина строки больше 31, то один слот для хранения не может вместить все данные. Таким образом, слот поля будет содержать только поле длины (последний бит установлен равным 1), а данные сохраняются в виде массива переменной длины.
До сих пор мы обсуждали реализации хранения большинства типов данных в Solidity. В следующем разделе мы поговорим о памяти и ее взаимодействии с полезной нагрузкой данных.