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

Deep EVM #10: Управление стеком в Huff — takes(), returns() и искусство dup/swap

OS
Open Soft Team

Engineering Team

Ментальная модель стековой машины

EVM — стековая машина. Нет регистров, нет именованных переменных — только стек LIFO из 32-байтных слов, глубиной 1024 слота. Каждый опкод либо кладёт, либо снимает, либо переупорядочивает элементы на этом стеке. Если вы не можете удержать текущее состояние стека в голове — вы напишете ошибочный байткод. Эта статья посвящена построению такой ментальной модели.

Нотация

В этой статье (и в комментариях Huff) мы представляем состояние стека в квадратных скобках, где левый элемент — вершина:

// [top, second, third, ..., bottom]
0x01  // [1]
0x02  // [2, 1]
add   // [3]

Каждый макрос Huff должен иметь стековый комментарий после каждого опкода. Это не опционально — это единственный способ аудита корректности.

DUP: дублирование элементов стека

EVM предоставляет DUP1DUP16. 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 предоставляет SWAP1SWAP16. 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.

Стратегии для глубоких стеков:

  1. Реструктуризация логики — держите нужные значения ближе к вершине.
  2. Использование памяти как scratch space. Сохраните значение через MSTORE, получите позже через MLOAD. Стоит 3+3=6 газа вместо 3 для DUP, зато снимает ограничение глубины.
  3. Разбиение макроса на меньшие макросы, каждый из которых работает с меньшим числом стековых элементов.
#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 придерживайтесь дисциплины:

  1. Комментируйте каждую строку состоянием стека после исполнения.
  2. Проверяйте takes/returns — считайте элементы стека на входе и выходе.
  3. Трассируйте каждую ветку — при каждом JUMPI обе ветви (переход и проход) должны оставлять стек в валидном состоянии.
  4. Следите за дрейфом стека — если тело цикла не балансирует push/pop, стек будет расти или уменьшаться на каждой итерации.

Отладка стековых ошибок

Самые частые баги в Huff:

  1. Stack underflow — pop из пустого стека. EVM ревертит в рантайме.
  2. Дисбаланс стека на JUMP — JUMPDEST, достигаемый из двух путей, ожидает разные состояния стека.
  3. Off-by-one в DUP/SWAPdup3 вместо 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-таблицами.