Перейти к основному содержимому
БлокчейнMar 28, 2026

Deep EVM #5: Введение в Yul — секретный ассемблер Solidity

OS
Open Soft Team

Engineering Team

Что такое Yul

Yul — это промежуточный язык, встроенный в Solidity. Он обеспечивает прямой доступ к опкодам EVM через чистый и читаемый синтаксис. В отличие от чистого ассемблера (где вы работаете со стеком напрямую), Yul предоставляет переменные, функции и управляющие конструкции — при этом компилируя в практически оптимальный байткод.

Yul используется в двух контекстах:

  1. Inline assembly — блоки assembly { ... } внутри Solidity-функций.
  2. 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 стоит использовать, когда:

  1. Горячие пути — функции, вызываемые миллионы раз (DEX swap, ордер-бук).
  2. Битовые манипуляции — упаковка/распаковка данных в 256-битные слова.
  3. Кастомное управление памятью — избежание накладных расходов свободного указателя Solidity.
  4. Низкоуровневые вызовы — оптимизированные CALL/DELEGATECALL без оверхеда Solidity.
  5. 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

  1. Нет проверки переполнения — все операции в Yul работают по модулю 2^256.
  2. Нет проверки типов — адрес, число, байты — всё u256.
  3. Повреждение памяти — если вы случайно перезапишете свободный указатель (0x40), Solidity будет аллоцировать память по неправильному адресу.
  4. Stack too deep — Yul ограничен глубиной стека в 16 элементов для DUP/SWAP.
  5. Нет returndata по умолчанию — нужно вручную вызывать return(offset, size).

Заключение

Yul — это мост между высокоуровневым Solidity и низкоуровневым байткодом EVM. Он предоставляет контроль над каждым аспектом выполнения — памятью, хранилищем, потоком управления — при этом сохраняя читаемый синтаксис. Освоение Yul — ключевой навык для любого серьёзного разработчика смарт-контрактов.

В следующей статье мы углубимся в управление памятью в Yul: mstore, mload, свободный указатель и паттерны эффективной аллокации.