EVM для задротов
Сегодня речь пойдет про более углубленное познание EVM, что это вообще такое и как оно действительно работает
По своей сути данная статья является переводом и выжимкой вот этого парня ТЫК (но только первой части его трилогии, в будущем возможно и остальные переведу и разберу)
Так же хочу предупредить, новичкам в программировании скорее всего будет сложно понять происходящее, поэтому если вы действительно хотите все впитать, то читайте неспешно и вместе с гуглом
Начнем!
Solidity → Bytecode → Opcode
Именно такой заголовок будет у данной главы
Начнем с того, что Solidity КОД НЕ ЧИТАЕМ ДЛЯ EVM! Для простоты объяснения скажу так, solidity - высокоуровневый язык, а EVM такого рода штуки читать не умеет, ему как и любой вычислительной машине нужны на вход только циферки. Именно поэтому мы и переводим весь наш solidity код в байткод, чтобы EVM могла его понять. Байткод создается вместе с контрактом, то есть в момент деплоя, вместе с ABI, но об этом сегодня не будем (ABI - интерфейс при помощи которого две программы могут между собой контактировать, это если кратко)
Вот нашел интересную картинку про деплой контракта на EVM
Ну вот, получили мы с вами большой набор цифр под названием "байткод", а что с ним дальше делать? Тут и включается наше третье звено - Opcode
Что он из себя представляет? Опкод - это набор команд, условно если стоит набор цифр "111", то надо делать сложение, если стоит "000", то надо делать вычитание. Всего в solidity 140 опкодов, и если вы скажете что их мало, и команд и операция я могу в solidity написать больше, то знайте, многие операции - комбинации этих опкодов. Полный список всех опкодов с хорошим описанием можете смотреть тут - ТЫК
Опять же, вас может возникнуть вопрос, мол ну да, мы поняли как сообщить EVM о том где надо сложить, а где надо вычесть, но как мы сообщим ей входные данные для этих вычислений?
Так вот, у каждого опкода есть свои входные данные. Это значит что если EVM видит команду, в который по условию следующие 8 цифр - входные данные, то он и воспримет их исключительно как входные данные
Я уверен вам это тяжело понять, поэтому перейдем к примеру
У нас есть вот такой простенький контракт
608060405234801561001057600080fd5b50600436106100365760003560e01c80632e64cec11461003b5780636057361d14610059575b600080fd5b610043610075565b60405161005091906100d9565b60405180910390f35b610073600480360381019061006e919061009d565b61007e565b005b60008054905090565b8060008190555050565b60008135905061009781610103565b92915050565b6000602082840312156100b3576100b26100fe565b5b60006100c184828501610088565b91505092915050565b6100d3816100f4565b82525050565b60006020820190506100ee60008301846100ca565b92915050565b6000819050919050565b600080fd5b61010c816100f4565b811461011757600080fd5b5056fea2646970667358221220404e37f487a89a932dca5e77faaf6ca2de3b991f93d230604b1b8daaef64766264736f6c63430008070033
Это код выборщика выполняемой функции (у нас в контракте 2 функции используется и он как раз выбирает одну из них, которую мы его попросили)
А вот так выглядит расшифровка этого байткода в опокод:
60 00 = PUSH1 0x00 35 = CALLDATALOAD 60 e0 = PUSH1 0xe0 1c = SHR 80 = DUP1 63 2e64cec1 = PUSH4 0x2e64cec1 14 = EQ 61 003b = PUSH2 0x003b 57 = JUMPI 80 = DUP1 63 6057361d = PUSH4 0x6057361d 14 = EQ 61 0059 = PUSH2 0x0059 57 = JUMPI
Повторюсь, все опкоды можно найти и посмотреть тут ТЫК
Вызов функций контрактов и Calldata
Тут мы вызываем функцию и передаем в нее аргумент "10", abi.encodeWithSignature() используется чтобы получить данные в нужном формате, а Emit на всякий случай для тестировки
0x6057361d000000000000000000000000000000000000000000000000000000000000000a
Это полученные значения из abi.encodeWithSignature("store(uint256)", 10);
store(uint256) - функция из контракта который мы использовали в самом начале
Давайте расшифруем этот байткод!
keccak256(“store(uint256)”) → first 4 bytes = 6057361d
Как мы можем заметить, если шифрануть функицю через кечак, то мы получим первые 4 байта (6057361d), которые как раз таки и указывают на саму функцию, а после этого у нас идет 32 байта входных данных (000000000000000000000000000000000000000000000000000000000000000a)
Для тех кто не знает почему а = 10, то посмотрите перевод из десятичной системы счисления в шестнадцатеричную
Opcode и Calldata
Перед началом этой части статья я настоятельно рекомендую посмотреть любое видео о побитовом смещении (битовых сдвигах), иначе будет тяжело
А сейчас мы подробно разберем как происходит выбор функции на выше приведенных примерах!
Мы пробежимся по каждой из команд опкода (который мы получали выше) и постараемся разобраться как она работает и что делает
Мы начнем с PUSH1, которая сообщает EVM, что необходимо пропушить 1 байт, 0x00 (0 десятичная дробь), в колстак (вот этот квадратик справа). Почем мы это делаем - объясним дальше
Дальше у нас CALLDATALOAD который выталкивает первое значение в стеке (0) как input
Этот опкод загружает данные вызова в стек, используя «input» в качестве смещения. Элементы стека имеют размер 32 байта, но наши данные вызова имеют размер 36 байтов. Введенное значение — msg.data[i:i+32], где «i» — это input. Это гарантирует, что в стек помещаются только 32 байта, но позволяет нам получить доступ к любой части данных вызова
В этом случае у нас нет смещения (значение, извлеченное из стека, равно 0 из предыдущего PUSH1), поэтому мы помещаем первые 32 байта calldata в стек вызовов
Помните, ранее мы регистрировали данные о наших вызовах с помощью Emit, равного:
“0x6057361d000000000000000000000000000000000000000000000000000000000000000a”
Это означает, что последние 4 байта («0000000a») потеряны. Если бы мы хотели получить доступ к переменной uint256, мы бы использовали смещение 4, чтобы опустить сигнатуру функции, но включить всю переменную
Другой PUSH1 на этот раз с шестнадцатеричным значением 0xe0, которое имеет десятичное значение 224. Помните, что сигнатуры функций ("6057361d") имеют длину 4 байта или 32 бита. Наша загруженная calldata имеют длину 32 байта или 256 бит. 256 - 32 = 224 вы можете видеть, к чему это идет
Затем у нас есть SHR, который является правым битовым смещением. Он берет первый элемент из стека (224) в качестве входных данных о том, на сколько нужно сдвинуть, а второй элемент из стека (0x6057361d0…0a) представляет то, что нужно сдвинуть. Мы можем видеть, что после этой операции у нас остается наш 4-байтовый селектор функций в стеке вызовов
Далее идет DUP1, простой код операции, который берет значение на вершине стека и дублирует его
PUSH4 помещает 4-байтовую сигнатуру функции retrieve() (0x2e64cec1) в стек вызовов
keccak256(“retrieve()”) → first 4 bytes = 2e64cec1 Это вторая функция в нашем контракте
Если вам интересно, как он узнает это значение, помните, что оно находится в байт-коде, который был скомпилирован из кода Solidity. Таким образом, компилятор имел информацию обо всех именах функций и типах аргументов
EQ извлекает 2 значения из стека, в данном случае 0x2e64cec1 и 0x6057361d, и проверяет, равны ли они. Если да, то в стек помещает 1, если нет 0
PUSH2 помещает 2 байта данных в стек вызовов 0x003b в шестнадцатеричном формате, что равно 59 в десятичном формате
В стеке вызовов есть нечто, называемое программным счетчиком, который указывает, где в байт-коде находится следующая команда выполнения. Здесь мы устанавливаем 59, потому что это место для начала байт-кода retrieve()
Вы можете просмотреть расположение счетчика программ аналогично расположению номера строки в вашем коде Solidity. Если функция определена в строке 59, вы можете использовать номер строки, чтобы сообщить машине, где найти код для этой функции.
JUMPI означает «прыгать, если». Он извлекает 2 значения из стека в качестве входных данных, первое (59) — это место перехода (куда он перейдет), а второе (0) — логическое значение, указывающее, следует ли выполнять этот переход. Где 1 = истина и 0 = ложь
Если это правда, счетчик программы будет обновлен, и выполнение перейдет к этому месту. В нашем случае это ложь, счетчик программ не изменяется и выполнение продолжается в обычном режиме
PUSH4 помещает 4-байтовую сигнатуру функции store(uint256) (0x6057361d) в стек вызовов.
EQ вызывается снова, однако на этот раз результат верный, поскольку сигнатуры функций совпадают
PUSH2, передает программе-счетсчику местоположение байт-кода store(uint256), 0x0059 в шестнадцатеричном формате, что равно 89 в десятичном формате.
JUMPI, на этот раз логическая проверка верна, что означает выполнение прыжка. Это обновит счетчик программ до 89, что переместит выполнение в другую часть байткода
В этом месте будет код операции JUMPDEST, без этого кода операции в пункте назначения JUMPI завершится ошибкой
Вот и все, после выполнения этого кода операции вы попадете в расположение байткода хранилища (uint156), и выполнение функции продолжится в обычном режиме
Хотя у этого контракта было только 2 функции, те же принципы применяются к контракту с более чем 20 функциями
Теперь вы знаете, как EVM определяет расположение байткода функции, которую необходимо выполнить, на основе вызова функции контракта. На самом деле это просто набор «условных выражений» для каждой функции в вашем контракте вместе с местами их перехода