Deep EVM #10: Управление стеком в Huff — takes(), returns() и искусство dup/swap
Engineering Team
Ментальная модель стековой машины
EVM — стековая машина. Нет регистров, нет именованных переменных — только стек LIFO из 32-байтных слов, глубиной 1024 слота. Каждый опкод либо кладёт, либо снимает, либо переупорядочивает элементы на этом стеке. Если вы не можете удержать текущее состояние стека в голове — вы напишете ошибочный байткод. Эта статья посвящена построению такой ментальной модели.
Нотация
В этой статье (и в комментариях Huff) мы представляем состояние стека в квадратных скобках, где левый элемент — вершина:
// [top, second, third, ..., bottom]
0x01 // [1]
0x02 // [2, 1]
add // [3]
Каждый макрос Huff должен иметь стековый комментарий после каждого опкода. Это не опционально — это единственный способ аудита корректности.
DUP: дублирование элементов стека
EVM предоставляет DUP1 — DUP16. DUPn копирует n-й элемент от вершины и кладёт его на стек. Стек растёт на 1.
// Stack: [a, b, c, d]
dup1 // [a, a, b, c, d] — копия вершины
dup3 // [c, a, a, b, c, d] — копия 3-го от вершины
Стоимость газа: 3 газа для любого DUPn. Одна из самых дешёвых операций в EVM.
Когда использовать DUP
DUP — инструмент для неразрушающего чтения. Многие опкоды потребляют аргументы (ADD снимает два, кладёт один), поэтому если значение понадобится позже — дублируйте его перед подачей в потребляющий опкод.
#define macro SAFE_SUB() = takes(2) returns(1) {
// takes: [a, b] — вычислить a - b, revert если b > a
dup2 dup2 // [a, b, a, b]
lt // [a < b?, a, b]
revert_underflow jumpi // [a, b]
sub // [a - b]
done jump
revert_underflow:
0x00 0x00 revert
done:
}
Обратите внимание на dup2 dup2 — мы дублируем и a, и b, потому что lt их потребит, а оригиналы нужны для sub.
SWAP: перестановка элементов стека
EVM предоставляет SWAP1 — SWAP16. SWAPn меняет местами вершину с (n+1)-м элементом. Размер стека не меняется.
// Stack: [a, b, c, d]
swap1 // [b, a, c, d] — обмен вершины и 2-го
swap3 // [d, a, c, b] — обмен вершины и 4-го
Стоимость: 3 газа для любого SWAPn.
Когда использовать SWAP
SWAP переупорядочивает аргументы для опкодов, ожидающих определённый порядок. Например, SUB вычисляет stack[0] - stack[1]. Если значения в неправильном порядке:
// Stack: [b, a] — но нам нужно a - b
swap1 // [a, b]
sub // [a - b]
Ограничение глубины 16
DUP и SWAP достигают только 16 уровней вглубь. Если значение на позиции 17 и глубже, его невозможно получить одним опкодом. Это жёсткое ограничение EVM.
Стратегии для глубоких стеков:
- Реструктуризация логики — держите нужные значения ближе к вершине.
- Использование памяти как scratch space. Сохраните значение через
MSTORE, получите позже черезMLOAD. Стоит 3+3=6 газа вместо 3 для DUP, зато снимает ограничение глубины. - Разбиение макроса на меньшие макросы, каждый из которых работает с меньшим числом стековых элементов.
#define macro STASH_TO_MEMORY() = takes(1) returns(0) {
// takes: [value]
0x80 mstore // [] — сохраняем в 0x80 (scratch space)
}
#define macro RECALL_FROM_MEMORY() = takes(0) returns(1) {
0x80 mload // [value]
}
В MEV-контрактах мы часто резервируем 0x80..0xc0 как scratch-область для значений, которые иначе вытолкнули бы стек за предел 16.
Общие паттерны
Паттерн 1: сохранение значения через потребляющую операцию
У вас [x], и нужно вызвать опкод, потребляющий x, но x ещё понадобится.
// Хотим: вычислить хеш x, но сохранить x
// Stack: [x]
dup1 // [x, x]
0x00 mstore // [x] — memory[0] = x
0x20 0x00 // [0, 32, x]
keccak256 // [hash, x]
Паттерн 2: ротация трёх элементов
Имеем [a, b, c], нужно [c, a, b]:
swap2 // [c, b, a]
swap1 // [c, a, b]
2 опкода, 6 газа. В EVM нет ротации одним опкодом.
Имеем [a, b, c], нужно [b, c, a]:
swap1 // [b, a, c]
swap2 // [b, c, a]
Паттерн 3: очистка ненужных элементов стека
После вычисления могут остаться лишние элементы. Используйте pop (2 газа):
// Stack: [result, garbage1, garbage2]
swap1 pop // [result, garbage2]
swap1 pop // [result]
Паттерн 4: дублирование пары
Нужно скопировать два верхних элемента:
// Stack: [a, b]
dup2 // [b, a, b]
dup2 // [a, b, a, b]
Обратите внимание — DUP в обратном порядке. dup2 сначала копирует b (позиция 2), затем dup2 копирует a (теперь на позиции 2, так как стек вырос). Этот паттерн постоянно встречается в коде сравнения-перед-арифметикой.
Дисциплина визуализации стека
При написании Huff придерживайтесь дисциплины:
- Комментируйте каждую строку состоянием стека после исполнения.
- Проверяйте takes/returns — считайте элементы стека на входе и выходе.
- Трассируйте каждую ветку — при каждом JUMPI обе ветви (переход и проход) должны оставлять стек в валидном состоянии.
- Следите за дрейфом стека — если тело цикла не балансирует push/pop, стек будет расти или уменьшаться на каждой итерации.
Отладка стековых ошибок
Самые частые баги в Huff:
- Stack underflow — pop из пустого стека. EVM ревертит в рантайме.
- Дисбаланс стека на JUMP — JUMPDEST, достигаемый из двух путей, ожидает разные состояния стека.
- Off-by-one в DUP/SWAP —
dup3вместоdup4, когда вы добавили лишний push раньше.
huffc имеет флаг --stack-check для базового стекового анализа:
huffc src/Contract.huff -r --stack-check
Он ловит очевидные underflow, но не может трассировать все динамические пути. Для сложных контрактов трассируйте исполнение вручную с forge debug или evm-trace.
Продвинутый подход: стек как регистровый файл
Опытные Huff-разработчики воспринимают верхние ~8 позиций стека как регистровый файл:
Позиция 1 (вершина): Рабочий регистр — текущее вычисление
Позиции 2-3: Регистры аргументов — входы следующей операции
Позиции 4-6: Локальные переменные — значения, нужные скоро
Позиции 7-8: Контекстные регистры — счётчики циклов
Позиция 9+: Область вытеснения — рассмотрите память
Эта ментальная модель помогает решить, когда использовать SWAP для перемещения значения на вершину, а когда — DUP, и когда выгружать в память.
Итоги
Управление стеком — ключевой навык для разработки на Huff. DUP для неразрушающего чтения, SWAP для перестановки, память для значений за пределами глубины 16. Комментируйте каждую строку состоянием стека. Проверяйте каждую ветку. В следующей статье мы используем эти навыки для построения O(1) диспатчера функций с упакованными jump-таблицами.