Ethereum
March 2, 2023

Понимаем байт-код EVM: Часть 4

Оригинал — https://blog.trustlook.com/understand-evm-bytecode-part-4/

Канал — https://t.me/jetix37eth

В прошлых частях мы говорили о том, как различные типы данных Solidity реализуются в хранилище. Сегодня мы поговорим о памяти более подробно, о ее использовании во внешних вызовах.

Мы узнали некоторые основы о памяти из предыдущих разделов. Мы знаем, что память предназначена для вычисления хэша или взаимодействия с внешними вызовами или возвратами. Структура памяти зарезервировала 0x0 и 0x20 для вычисления хэша. По адресу 0x40 он сохранит указатель свободной памяти для использования в будущем.

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


Давайте посмотрим на все коды операций, которые зависят от памяти, кроме MLOAD и MSTORE:

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

Сначала давайте посмотрим на код операции CALLDATACOPY. В документации Solidity “calldatacopy(t, f, s)” определяется как “копировать s байт из calldata в позиции f в mem в позиции t”. Если у вас есть опыт анализа контрактов на уровне байт-кода, вы можете заметить, что другой аналогичный код операции CALLDATALOAD более популярен, чем этот.

Но разница между этими 2 кодами операций заключается в том, что CALLDATALOAD загружает только 32-байтовые данные в стек вместо памяти. Если публичная функция контракта использует только целые числа в своих аргументах, то CALLDATALOAD достаточно хорош для вызовов. Формат полезной нагрузки данных будет следующим:


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

Дешифрованный байт-код выглядит так:

temp0 = mload(0x40);

mstore(0x40,(0x40 + temp0));

calldatacopy(temp0,0x4,0x40);

По-видимому, на этот раз CALLDATACOPY используется для копирования всего аргумента в память для дальнейшего использования. Это не просто фиксированный размер массивов. Для любого параметра, который имеет фиксированный размер, например struct, CALLDATACOPY будет тем, кто выполнит эту работу.


Однако все может быть еще сложнее, когда есть динамические массивы. Например:

Мы можем видеть, что существует только одна функция test(), использующая массив адресов в качестве аргумента. Итак, как данные будут расположены внутри полезной нагрузки данных?

Давайте все-таки заглянем в байт-код в поисках истины. Вот фрагмент кода перед вызовом функции test():

temp0 = mload(0x40);

temp1 = msg.data(0x4);

mstore(0x40,(0x20 + (temp0 + (msg.data((0x4 + temp1)) * 0x20))));

mstore(temp0,msg.data((0x4 + temp1)));

calldatacopy((temp0 + 0x20),(0x24 + temp1),(msg.data((0x4 + temp1)) * 0x20));

var1 = test(temp0);

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

Первая строка “temp0 = mload(0x40);” очень популярна. Она получает указатель свободной памяти из адреса памяти 0x40 в переменную temp0. Затем temp1 будет присвоено значение, полученное из полезной нагрузки данных со смещением 0x4, которое регулярно содержит первый параметр, когда тип является integer. Однако, по-видимому, в данном случае это еще не закончено. Это значение в temp1 будет использоваться в качестве смещения для определения местоположения данных, начиная с 0x4.

Это значение может быть показано как “msg.data((0x4 + temp1))”. Из приведенного выше фрагмента кода это значение представляет собой длину массива. Размер каждого элемента в массиве равен 0x20. Таким образом, “mstore(0x40,(0x20 + (temp0 + (msg.data((0x4 + temp1)) * 0x20))));” изменит указатель свободной памяти, чтобы сохранить часть памяти для этого аргумента. Затем длина массива будет скопирована в старый указатель свободной памяти, и элементы массива также будут скопированы CALLDATACOPY. Наконец, указатель памяти будет передан функции test() для работы.

После того, как у есть некоторое базовое представление о том, как была организована полезная нагрузка данных EVM, мы сможем увидеть, как работают коды операций, связанные с CALL, и как задействована память. В документации Solidity указано: “call (g, a, v, in, insize, out, outsize) – вызов контракта по адресу a с входной памятью[in..(in+insize)), обеспечивающий подачу газа g и v wei, а также память выходной области[out..(out+outsize)), возвращающий 0 при ошибке (например. кончился газ) и 1 на успех”.


Поэтому, когда вы используете этот код операции для вызова другого смарт-контракта, вам необходимо предоставить всю информацию. Давайте посмотрим на реальный пример ссылки на внешний контракт:

Здесь мы определили 2 смарт-контракта. Функция getA() в контракте Exisitng вызовет внешнюю функцию a() в Deployed. Вот скомпилированный байт-код:

Как всегда, указатель свободной памяти был загружен в переменную temp0. Затем хэш функции 0xDBE671F сохраняется в свободной памяти. Тогда var11 будет содержать первую переменную хранилища, которая в данном случае является dc. Строка require проверит, содержит ли адрес в var11 адрес контракта.

Наконец, этот адрес будет использоваться для выполнения внешнего вызова “var11.gas(gasleft).value(0).call(temp0,4,temp0,0x20)”. Параметры (temp0 и 4) в этом вызове — это полезная нагрузка данных, которую он хочет отправить внешнему контракту. В этом случае он отправляет 4 байта с адреса temp0. Из раннего кода мы знаем, что 4 байта - это хэш-значение подписи функции для a(). Поскольку в функции нет других аргументов, для выполнения этого вызова требуется всего 4 байта хэша функции. Если функция, которую вы хотите вызвать, действительно имеет аргументы, то компилятор расположит память так, как мы обсуждали ранее для вызова.

Параметры (temp0 и 0x20) будут содержать возвращаемые данные из внешнего вызова. EVM получит данные, возвращенные из внешнего вызова, и поместит их в указанный вызов кода операции адреса памяти.


Есть одна вещь, которую стоит упомянуть о внешнем вызове — это то, что в Solidity есть некоторый жестко закодированный адрес для встроенной функции. Если вы посмотрите на какой-нибудь байт-код, вы всегда найдете некоторые внешние вызовы, использующие адреса 1,2,3 и 4. По-видимому, это не обычные адреса смарт-контрактов. Я поискал в Интернете, и не так много информации о них можно найти. Но я нахожу некоторую подсказку в документации Solidity:

Выражения, которые могут иметь побочный эффект на распределение памяти, разрешены, но те, которые могут иметь побочный эффект на другие объекты памяти, - нет. Разрешены встроенные функции keccak256, sha256, ripemd160, ecrecover, addmod и mulmode (даже если они вызывают внешние контракты).

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

1 - ecrecover

2 - sha256

3 - ripemd160

4 - sha3

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

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