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

Deep EVM #6: Управление памятью в Yul — mstore, mload и свободный указатель

OS
Open Soft Team

Engineering Team

Память EVM через призму Yul

В Solidity управление памятью полностью автоматизировано. Компилятор сам отслеживает свободный указатель, аллоцирует массивы и структуры, копирует данные. Но когда вы переходите на Yul, вы берёте всё это на себя. Каждый байт, каждое смещение, каждая аллокация — ваша ответственность.

Это даёт огромную мощь и гибкость. Вы можете избежать ненужных аллокаций, переиспользовать память, оптимизировать расположение данных. Но одна ошибка — и вы перезапишете критические данные, испортите свободный указатель или получите некорректные результаты.

mstore и mload: основы

Две главные операции с памятью в EVM:

assembly {
    // mstore(offset, value) — записать 32 байта
    mstore(0x00, 0xDEADBEEF)
    // Память: [00..1f] = 0x00000000000000000000000000000000000000000000000000000000DEADBEEF

    // mload(offset) — прочитать 32 байта
    let val := mload(0x00)
    // val = 0x00000000000000000000000000000000000000000000000000000000DEADBEEF

    // mstore8(offset, value) — записать 1 байт
    mstore8(0x00, 0xFF)
    // Первый байт теперь 0xFF, остальные 31 байт не изменились
}

Важные детали:

  • mstore всегда пишет 32 байта, даже если значение маленькое. Оно дополняется нулями слева.
  • mload всегда читает 32 байта.
  • Операции могут перекрываться: mstore(0x10, value) перекроет часть данных по смещениям 0x00 и 0x20.
  • Стоимость: 3 газа за операцию + квадратичная стоимость расширения памяти.

Раскладка памяти Solidity

Когда вы пишете Yul внутри Solidity, вы работаете в контексте раскладки памяти Solidity:

// Раскладка памяти Solidity:
// 0x00 - 0x3f: Scratch space (рабочее пространство)
// 0x40 - 0x5f: Свободный указатель памяти
// 0x60 - 0x7f: Нулевой слот
// 0x80+:       Свободная память

Scratch Space (0x00 - 0x3f)

Первые 64 байта — это рабочая область для временных вычислений. Solidity использует её для хеширования и промежуточных операций. В Yul вы можете свободно использовать эту область, не опасаясь конфликтов — при условии, что вы не вызываете Solidity-функции между записью и чтением.

assembly {
    // Безопасно: используем scratch space для keccak256
    mstore(0x00, address())
    mstore(0x20, 1) // слот маппинга
    let hash := keccak256(0x00, 0x40)
}

Свободный указатель (0x40)

Самый критический элемент раскладки памяти. Свободный указатель хранит адрес начала неиспользованной памяти:

assembly {
    // Чтение свободного указателя
    let freePtr := mload(0x40)
    // Начальное значение: 0x80 (после зарезервированных 128 байт)

    // Аллокация 64 байт
    let myData := freePtr
    mstore(0x40, add(freePtr, 64)) // Обновление указателя

    // Теперь можно писать в myData
    mstore(myData, 42)
    mstore(add(myData, 32), 100)
}

Критическое правило: если вы аллоцируете память через свободный указатель в Yul, вы ДОЛЖНЫ его обновить. Иначе следующая аллокация Solidity перезапишет ваши данные.

Паттерны работы с памятью

Паттерн 1: Аллокация массива в памяти

assembly {
    function allocateArray(length) -> ptr {
        ptr := mload(0x40)
        // Первые 32 байта — длина массива
        mstore(ptr, length)
        // Обновляем свободный указатель: 32 (длина) + 32 * length (элементы)
        mstore(0x40, add(ptr, add(32, mul(length, 32))))
    }

    function setElement(arrayPtr, index, value) {
        // Смещение: arrayPtr + 32 (длина) + index * 32
        let offset := add(add(arrayPtr, 32), mul(index, 32))
        mstore(offset, value)
    }

    function getElement(arrayPtr, index) -> value {
        let offset := add(add(arrayPtr, 32), mul(index, 32))
        value := mload(offset)
    }

    // Использование
    let arr := allocateArray(5)
    setElement(arr, 0, 100)
    setElement(arr, 1, 200)
    let first := getElement(arr, 0) // 100
}

Паттерн 2: Работа без аллокации

Самый газоэффективный подход — вообще не аллоцировать память, используя scratch space:

assembly {
    // Вместо аллокации массива — используем scratch space
    // для вычисления хеша маппинга
    function getBalanceSlot(account) -> slot {
        mstore(0x00, account)
        mstore(0x20, 3) // слот маппинга balances
        slot := keccak256(0x00, 0x40)
    }

    // Чтение баланса одной операцией
    let balance := sload(getBalanceSlot(caller()))
}

Этот паттерн не обновляет свободный указатель и не расширяет память — 0 дополнительных затрат.

Паттерн 3: Копирование calldata в память

assembly {
    // Копируем calldata в память для обработки
    let size := calldatasize()
    let ptr := mload(0x40)
    calldatacopy(ptr, 0, size)
    mstore(0x40, add(ptr, size))

    // Теперь данные доступны в памяти по ptr
    let firstWord := mload(ptr)
}

Паттерн 4: Формирование calldata для внешнего вызова

Один из самых частых случаев использования Yul — формирование calldata для внешних вызовов без накладных расходов abi.encodeWithSelector:

assembly {
    // Вызов balanceOf(address) на ERC-20 токене
    // Используем scratch space — не нужна аллокация
    mstore(0x00, 0x70a0823100000000000000000000000000000000000000000000000000000000)
    mstore(0x04, account)

    // call(gas, addr, value, argsOffset, argsSize, retOffset, retSize)
    let success := staticcall(gas(), token, 0x00, 0x24, 0x00, 0x20)

    if iszero(success) {
        revert(0, 0)
    }

    let balance := mload(0x00)
}

Обратите внимание: мы перезаписываем scratch space после вызова, что допустимо.

Паттерн 5: Эффективная конкатенация байт

assembly {
    function concatBytes32(a, b) -> result {
        // Аллоцируем 64 байта
        result := mload(0x40)
        mstore(result, a)
        mstore(add(result, 32), b)
        mstore(0x40, add(result, 64))
    }

    function hashPair(a, b) -> hash {
        // Без аллокации — используем scratch space
        mstore(0x00, a)
        mstore(0x20, b)
        hash := keccak256(0x00, 0x40)
    }
}

Квадратичная стоимость расширения памяти

Когда вы обращаетесь к памяти за пределами текущей верхней границы, EVM взимает дополнительную плату. Формула:

cost = (words^2 / 512) + (3 * words)

где words = ceil(highest_byte / 32).

Для практического понимания:

assembly {
    // Первые 724 байта (~22 слова): ~линейная стоимость, ~3 газа/слово
    mstore(0x80, 1)    // Первое расширение: ~3 газа

    // 10 КБ памяти: 1160 газа — приемлемо
    mstore(0x2800, 1)  // 10240 байт

    // 100 КБ памяти: 29600 газа — дорого
    mstore(0x19000, 1) // 102400 байт

    // 1 МБ памяти: 2 001 000 газа — запредельно
    mstore(0x100000, 1) // 1048576 байт
}

Важный вывод: никогда не аллоцируйте больше памяти, чем нужно. Если вам нужно обработать большой массив, обрабатывайте его по частям.

Безопасность памяти

Начиная с Solidity 0.8.13, компилятор поддерживает флаг «memory-safe assembly»:

assembly ("memory-safe") {
    // Компилятор доверяет, что этот блок:
    // 1. Работает только с scratch space (0x00-0x3f) для временных данных
    // 2. Аллоцирует через свободный указатель и обновляет его
    // 3. Не перезаписывает Solidity-данные в памяти

    let ptr := mload(0x40)
    mstore(ptr, 42)
    mstore(0x40, add(ptr, 32))
}

Если блок помечен как memory-safe, компилятор может применить дополнительные оптимизации (stack-to-memory, размещение переменных в памяти вместо стека). Без этого флага компилятор вынужден быть консервативным.

Распространённые ошибки

1. Забыли обновить свободный указатель

// ОШИБКА: свободный указатель не обновлён
assembly {
    let ptr := mload(0x40)
    mstore(ptr, data)
    // Следующая аллокация Solidity перезапишет data!
}

// ПРАВИЛЬНО:
assembly {
    let ptr := mload(0x40)
    mstore(ptr, data)
    mstore(0x40, add(ptr, 32)) // Обязательно обновить!
}

2. Перезапись свободного указателя

// ОШИБКА: случайная запись по адресу 0x40
assembly {
    mstore(0x40, someValue) // Это перезапишет свободный указатель!
}

3. Невыровненные чтения

assembly {
    // mload всегда читает 32 байта
    // Если вам нужен только 1 байт:
    let byte_val := byte(0, mload(offset))

    // Или для N байт:
    let masked := and(mload(offset), 0xFFFF) // последние 2 байта
}

Возврат данных из Yul

Для возврата данных из функции через Yul используйте опкод return:

assembly {
    // Возврат одного значения uint256
    mstore(0x00, 42)
    return(0x00, 0x20) // return(offset, size)

    // Возврат нескольких значений
    mstore(0x00, value1)
    mstore(0x20, value2)
    return(0x00, 0x40)

    // Возврат динамических данных (bytes)
    let ptr := mload(0x40)
    mstore(ptr, 0x20) // смещение до данных
    mstore(add(ptr, 0x20), length) // длина
    // ... копируем данные ...
    return(ptr, add(0x40, length))
}

Заключение

Управление памятью в Yul — это балансирование между производительностью и безопасностью. Scratch space (0x00-0x3f) — ваш лучший друг для временных вычислений. Свободный указатель (0x40) — критический элемент, который нельзя повредить. Квадратичная стоимость расширения — ограничение, которое нужно всегда учитывать.

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