Deep EVM #6: Управление памятью в Yul — mstore, mload и свободный указатель
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 — как итерировать по массивам, обрабатывать данные и реализовывать сложную логику с минимальными затратами газа.