👾 How Solidity compiles to EVM bytecode — Эльшан Джафаров
На нашем EVM-Compatible Meetup в Тбилиси Эльшан Джафаров, Blockchain Engineer из Fluence Lab прочитал довольно сложный доклад про то, как хранится информация в слотах, как экономить GAS при вычислениях EVM, как Solidity компилируется в EVM и как работает с абстракциями, которые там содержатся. А также про подводные камни и баги. Сделали краткий конспект доклада!
Из чего состоит EVM
EVM — виртуальная машина Ethereum, распределенный компьютер, который существует как единый объект, поддерживаемый тысячами подключенных компьютеров, на которых работает клиент Ethereum.
В EVM есть три сущности: стек (stack), память (memory) и хранилище (storage). EVM ничего не знает о типах данных и о структуре данных в Solidity, для EVM это все байты и команды ассемблера, которые он может обрабатывать.
EVM — это стек-машина, где все вычисления происходят в стеке, в отличие от машин, которые построены по модели регистров. В одной ячейке стека — 32 байта, глубина стека 1024 элемента, но читать можно только первые 16 элементов.
Представим код, который пушит два числа в стек и суммирует их. Вот, как это будет экзектьютиться:
- PUSH 1 0х01 — положить в стек 1 байт
- PUSH 1 0х02 — положить второй байт поверх предыдущего
- ADD — суммирование этих значений
Calldata, memory, storage
Если вы разрабатывали на Solidity, вы знаете, что есть:
- Calldata;
- memory;
- storage.
Про Calldata
У транзакции есть поле «data», которое показывает, какие данные мы отправляем в смарт-контракт. Смарт-контракт, после анализа этих данных, может вызвать какой-то определенный метод и сделать экзекьюшн. Calldata не может быть модифицирована, т.к. она — иммутабельна. Ее можно только запрашивать и считывать. Ее можно отправить либо в стек, либо в memory. Это важно, потому что сама по себе Calldata достаточно дешевая. Когда мы отправляем транзакцию, мы платим за размер транзакции, за каждый байт в data. 1 байт стоит от 8 или 16 GAS. 8 — за нулевой байт, не нулевой — 16. Когда, мы отправляем транзакцию, мы знаем, сколько она будет стоить.
Например, у нас есть функция, в которую мы получили Calldata. Мы записали от 1 до 9. Далее мы возвращаем data и Calldata в memory. В таком случае нам придется заплатить за расширение memory и за то, что мы туда отправим данные. Если мы работаем с Calldata, лучше не доставать из нее значения до тех пор, пока нам не нужно обратиться к ней — чтобы не платить дополнительный GAS. В других системах мы не будем об этом задумываться, но в Ethereum есть проблема с конечной стоимостью транзакций для пользователя.
Про Memory
Memory ****линейна и расширяется по 32 байта. Структуру memory можно представить как одномерный массив. Если мы читаем или пишем в нулевую ячейку memory, то сразу же расширяем ее на 32 байта этими действиями, и платим за это. Если мы читаем 33-ю ячейку, то добавляется еще 32 байта — до 64. Когда Solodity компилируется в EVM-код, при запуске контракта происходит аллокация нескольких ячеек, 128 байт.
Первые 64 байта зарезервированы Solidity для функции хеширования. В следующих 32 байтах хранится пойнтер, где заканчивается аллоцированная memory. Там хранится информация, о том на какой позиции находится значение в memory, с какого адреса можно начинать писать memory. Есть еще zero-слот, который используется для инициализации некоторых данных.
Когда мы что-то хотим отправить в memory, приходится платить комиссию, которая квадратично увеличивается. Одна ячейка — 32 байта. Это формирует следующую формулу:
memory_size_word = (memory_size_word + 31) / 32
memory cost = (memory_size_word ** 2) / 512 + (3 * memory_size_word )
Base_cost = (4 ** 2) / 512 + (3 * 4) = 12 GAS
Чем больше мы будем использовать memory, тем дороже будет это обходиться. Например, гигабайт данных может обойтись слишком дорого из-за квадратичного увеличения.
Как Solidity хранит данные в memory: представим некоторую структуру, у которой первые два поля занимают по 32 байта, следующее — 64. На всю структуру нужно 128 байт — 4 слота. Это справедливо и для массивов с фиксированной длиной. Каждое значение будем хранить в 32-байтном слоте. На слайде справа одна линия — 16 байт. Свободная memory начинается со слота 0x80. a, b, c, d — тоже занимают 32 байта, хотя называются unit8.
Динамические массивы. Чтобы поместить динамический массив в первую 32-байтную ячейку, нужно указать длину и элементы массива. На слайде у нас приведены две функции, которые на первый взгляд ничем не отличаются, но в Solidity их хранение будет отличаться.
В первом случае мы займем два слота, первые 32 байта — длина, вторые 32 байта — значения. Во втором случае — каждый байт будет расширен до 32 байт (каждое значение будет занимать 32 байта).
Про Return
Кажется, что в этих двух примерах нет разницы, но на самом деле она есть. Мы вызываем функцию, инициализируем структуру и возвращаем ее. Во втором примере мы также возвращаем структуру, но мы ее инициализировали при создании метода, при входе в функцию. Solidity это воспринимает как две разные структуру и в первом случае аллоцирует под структуру 128 байт — под каждое значение 4 поля. Во втором случае аллоцируется гораздо больше памяти, и вначале все занимают нули. Можно назвать это багом Solidity.
Про Storage
Стек и memory — временные элементы. Memory работает как оперативная память в рамках одного контекста, когда мы делаем вызов смарт-контракта, memory очищается. Storage хранит данные и после завершения вычислений до следующего момента перезаписи хранилища. Storage имеет key и value представления по 32 байта.
Solidity конвертирует Stprage-структуры иначе. Если мы аллоцировали структуру под 128 байт, то в данном контексте мы на 32 байта меньше аллоцируем. Если у вас есть два вида данных рядом и они помещаются в один слот, значит их можно упаковать вместе. Мы инициализировали 4 переменные storage, для структуры выделится значение ключа от 0 до 2 — получается три слота (0, 1, 2) куда записываются переменные (а → 1, b →2, c+d →3). Если инициализировать переменную а, она будет иметь некоторое смещение в памяти, которое говорит, под каким ключом будет лежать значение (ключ а = 3).
Стоимость газа, если мы инициализируем что-то с 0, будет равна 20К GAS, если значение не нулевое — 5К GAS.
Массивы с фиксированной длиной хранятся в сторадже примерно так же. Мы берем offset — позицию структуры данных в коде.
key = offset + index. Мы берем значение оффсета и добавляем индекс-значение и получаем key внутри этого стораджа.
На это примере оффсет = 3, ячейка а[0] будет храниться под key 0х03 и так далее. Мы не можем расположить данные динамического массива линейно, так как длина может меняться. Тут можно использовать лайфхак: берем оффсет, индекс элемента и хешируем. Получаем в результате уникальный ключ, под который мы записываем каждый элемент массива. Длину массива мы записываем под сам оффсет.
Про байты и стринг
Байты и стринг (bytes, string) хранятся иначе. Если их длина — меньше 32 байт, то они хранятся в одном слоте и занимают 31 байт, а 1 байт занимает информация о длине. Если массив байтов и стринга — больше 32 байт, то они хранятся как динамический массив. Один символ базово занимает один байт. Если стринга 33 байта — выделится 64 байта (+32). Схожесть с динамическим массивом в том, что в первой ячейке хранится длина.
Полезные ссылки
- Документация Solidity
- Telegram и Twitter Эльшана
Cyber Academy — образовательная платформа для блокчейн-разработчиков. Присоединяйтесь к нам ✨
Поддержите нас на Gitcoin
Анонсы | Website | Twitter | Телеграм-чат | GitHub | Facebook | Linkedin