Deep EVM #5: Введение в Yul — секретный ассемблер Solidity
Engineering Team
Что такое Yul
Yul — это промежуточный язык, встроенный в Solidity. Он обеспечивает прямой доступ к опкодам EVM через чистый и читаемый синтаксис. В отличие от чистого ассемблера (где вы работаете со стеком напрямую), Yul предоставляет переменные, функции и управляющие конструкции — при этом компилируя в практически оптимальный байткод.
Yul используется в двух контекстах:
- Inline assembly — блоки
assembly { ... }внутри Solidity-функций. - Standalone Yul — полноценные контракты, написанные целиком на Yul.
Большинство высокооптимизированных DeFi-протоколов используют inline assembly для критических путей. Uniswap V4, Seaport, Solady — все они широко применяют Yul.
Синтаксис Yul
Переменные и присваивание
В Yul существует единственный тип данных — 256-битное беззнаковое целое (u256), эквивалентное одному слову стека EVM:
assembly {
// Объявление переменной (инициализируется нулём)
let x
// Объявление с присваиванием
let y := 42
// Присваивание существующей переменной
x := add(y, 1)
// Множественное присваивание (от функции)
let a, b := myFunction()
}
Все значения — 256-битные слова. Адреса, булевы значения, байты — всё хранится как u256. Yul не имеет системы типов и не проверяет переполнение.
Встроенные функции (опкоды)
Каждый опкод EVM доступен как встроенная функция Yul:
assembly {
// Арифметика
let sum := add(1, 2) // 1 + 2 = 3
let diff := sub(10, 3) // 10 - 3 = 7
let prod := mul(4, 5) // 4 * 5 = 20
let quot := div(20, 4) // 20 / 4 = 5
let rem := mod(17, 5) // 17 % 5 = 2
let power := exp(2, 10) // 2^10 = 1024
// Сравнение (возвращает 0 или 1)
let isLess := lt(3, 5) // 3 < 5 = 1
let isEqual := eq(42, 42) // 42 == 42 = 1
let isZero := iszero(0) // 0 == 0 = 1
// Побитовые операции
let masked := and(0xff, 0x1234) // 0x34
let shifted := shr(8, 0x1234) // 0x12
// Память
mstore(0x00, 42) // Записать 42 по адресу 0x00
let val := mload(0x00) // Прочитать из адреса 0x00
// Хранилище
sstore(0, 100) // Записать 100 в слот 0
let stored := sload(0) // Прочитать из слота 0
// Контекст
let sender := caller() // msg.sender
let value := callvalue() // msg.value
let size := calldatasize() // размер calldata
}
Управление потоком
Yul предоставляет три управляющие конструкции:
assembly {
// if (без else!)
if gt(x, 10) {
// выполняется если x > 10
y := 1
}
// switch (аналог switch/case)
switch x
case 0 {
y := 100
}
case 1 {
y := 200
}
default {
y := 300
}
// for (цикл)
for { let i := 0 } lt(i, 10) { i := add(i, 1) } {
// тело цикла
sum := add(sum, i)
}
}
Обратите внимание: в Yul нет else. Для условий с двумя ветвями используйте switch.
Функции
Yul поддерживает пользовательские функции:
assembly {
function safeAdd(a, b) -> result {
result := add(a, b)
if lt(result, a) {
revert(0, 0) // переполнение
}
}
function max(a, b) -> result {
result := a
if gt(b, a) {
result := b
}
}
function swap(a, b) -> x, y {
x := b
y := a
}
let sum := safeAdd(100, 200)
let maximum := max(42, 17)
let a, b := swap(1, 2)
}
Функции Yul компилируются в JUMP/JUMPDEST пары — никакого ABI-кодирования, никаких внешних вызовов. Это чисто внутренние функции.
Практические примеры
Эффективное ABI-декодирование
Solidity генерирует безопасное, но объёмное ABI-декодирование. В Yul вы можете декодировать calldata напрямую:
function transfer(address to, uint256 amount) external {
assembly {
// Первые 4 байта — селектор функции, пропускаем
// Следующие 32 байта — адрес (правые 20 байт)
let to := calldataload(4)
// Следующие 32 байта — сумма
let amount := calldataload(36)
// Маскируем адрес (оставляем только 20 байт)
to := and(to, 0xffffffffffffffffffffffffffffffffffffffff)
// ... логика трансфера ...
}
}
Эффективное хеширование
Keccak256 — часто используемая операция, особенно для маппингов:
function getSlot(address user, uint256 baseSlot) internal pure returns (bytes32 slot) {
assembly {
// Записываем адрес в рабочее пространство
mstore(0x00, user)
// Записываем базовый слот
mstore(0x20, baseSlot)
// Хешируем 64 байта
slot := keccak256(0x00, 0x40)
}
}
Это экономит газ по сравнению с abi.encodePacked(user, baseSlot) в Solidity, который аллоцирует память и обновляет свободный указатель.
Эффективная проверка возвращаемого значения
function safeTransfer(address token, address to, uint256 amount) internal {
assembly {
// Кодируем calldata для transfer(address,uint256)
let ptr := mload(0x40)
mstore(ptr, 0xa9059cbb00000000000000000000000000000000000000000000000000000000)
mstore(add(ptr, 4), to)
mstore(add(ptr, 36), amount)
// Вызываем
let success := call(gas(), token, 0, ptr, 68, 0x00, 32)
// Проверяем: вызов успешен И (нет returndata ИЛИ returndata == true)
if iszero(and(
success,
or(
iszero(returndatasize()),
and(gt(returndatasize(), 31), eq(mload(0x00), 1))
)
)) {
revert(0, 0)
}
}
}
Этот паттерн — основа библиотеки SafeTransferLib от Solady. Он обрабатывает токены, которые не возвращают значение (USDT), и токены, которые возвращают bool (стандартные ERC-20).
Когда использовать Yul
Yul стоит использовать, когда:
- Горячие пути — функции, вызываемые миллионы раз (DEX swap, ордер-бук).
- Битовые манипуляции — упаковка/распаковка данных в 256-битные слова.
- Кастомное управление памятью — избежание накладных расходов свободного указателя Solidity.
- Низкоуровневые вызовы — оптимизированные CALL/DELEGATECALL без оверхеда Solidity.
- MEV-боты — каждый газ на счету.
Yul НЕ стоит использовать, когда:
- Код читают другие разработчики, не знающие ассемблера.
- Безопасность важнее оптимизации (пользовательские контракты).
- Разница в газе незначительна (< 1000 газа).
Отладка Yul
Отладка Yul затруднена отсутствием привычных инструментов:
// Нет console.log, но можно эмитировать события
assembly {
// Эмитируем анонимное событие для отладки
mstore(0x00, value)
log0(0x00, 0x20)
}
// Или использовать revert с данными
assembly {
mstore(0x00, value)
revert(0x00, 0x20) // Отладка через revert reason
}
На практике используйте Foundry debugger (forge debug) для пошаговой отладки байткода.
Подводные камни Yul
- Нет проверки переполнения — все операции в Yul работают по модулю 2^256.
- Нет проверки типов — адрес, число, байты — всё u256.
- Повреждение памяти — если вы случайно перезапишете свободный указатель (0x40), Solidity будет аллоцировать память по неправильному адресу.
- Stack too deep — Yul ограничен глубиной стека в 16 элементов для DUP/SWAP.
- Нет returndata по умолчанию — нужно вручную вызывать
return(offset, size).
Заключение
Yul — это мост между высокоуровневым Solidity и низкоуровневым байткодом EVM. Он предоставляет контроль над каждым аспектом выполнения — памятью, хранилищем, потоком управления — при этом сохраняя читаемый синтаксис. Освоение Yul — ключевой навык для любого серьёзного разработчика смарт-контрактов.
В следующей статье мы углубимся в управление памятью в Yul: mstore, mload, свободный указатель и паттерны эффективной аллокации.