Deep EVM #10: Huff 스택 관리 — takes(), returns(), dup/swap의 기술
Engineering Team
스택 머신 멘탈 모델
EVM은 스택 머신입니다. 레지스터도, 명명된 변수도 없습니다 — 각 요소가 32바이트 워드인 1024 슬롯 깊이의 후입선출(LIFO) 스택만 있습니다. 모든 옵코드는 이 스택에 항목을 푸시하거나, 팝하거나, 재배열합니다. 현재 스택 상태를 머릿속에 유지할 수 없다면, 버그가 있는 바이트코드를 생성하게 됩니다. 이 기사는 그 멘탈 모델을 구축하는 것에 관한 것입니다.
표기법 규칙
이 기사 전반(그리고 Huff 주석에서)에서 스택 상태를 대괄호로 표현하며, 가장 왼쪽 항목이 스택의 상단입니다:
// [top, second, third, ..., bottom]
0x01 // [1]
0x02 // [2, 1]
add // [3]
모든 Huff 매크로는 각 옵코드 뒤에 스택 주석이 있어야 합니다. 이것은 선택이 아닙니다 — 정확성을 감사하는 유일한 방법입니다.
DUP: 스택 항목 복제
EVM은 DUP1부터 DUP16까지 제공합니다. DUPn은 상단에서 n번째 항목을 복사하여 스택에 푸시합니다. 스택이 1만큼 증가합니다.
// 스택: [a, b, c, d]
dup1 // [a, a, b, c, d] — 상단 복사
dup3 // [c, a, a, b, c, d] — 상단에서 3번째 복사
가스 비용: 모든 DUPn에 대해 3 가스. EVM에서 가장 저렴한 연산 중 하나입니다.
언제 DUP를 사용해야 하는가
DUP는 비파괴적 읽기를 위한 도구입니다. 많은 옵코드가 인수를 소비하므로(ADD는 둘을 팝하고, 하나를 푸시), 나중에 값이 다시 필요하면 소비 옵코드에 넘기기 전에 DUP하세요.
#define macro SAFE_SUB() = takes(2) returns(1) {
// takes: [a, b] — a - b 계산, 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에 주목하세요 — lt가 소비할 a와 b 모두를 복제합니다. sub에 원본이 여전히 필요하기 때문입니다.
SWAP: 스택 재배열
EVM은 SWAP1부터 SWAP16까지 제공합니다. SWAPn은 상단 항목을 (n+1)번째 항목과 교환합니다. 스택 크기는 동일하게 유지됩니다.
// 스택: [a, b, c, d]
swap1 // [b, a, c, d] — 상단을 2번째와 교환
swap3 // [d, a, c, b] — 상단을 4번째와 교환
가스 비용: 모든 SWAPn에 대해 3 가스.
언제 SWAP를 사용해야 하는가
SWAP는 특정 순서를 기대하는 옵코드에 대한 인수를 재정렬합니다. 예를 들어, SUB는 stack[0] - stack[1]을 계산합니다. 값이 잘못된 순서라면:
// 스택: [b, a] — 하지만 a - b를 원함
swap1 // [a, b]
sub // [a - b]
깊이 16 제한
DUP와 SWAP는 16 깊이까지만 도달합니다. 값이 17번째 위치 또는 더 깊으면 단일 옵코드로 접근할 수 없습니다. 이것은 EVM의 하드 제약입니다.
깊은 스택을 위한 전략:
- 로직을 재구성하여 필요한 값을 상단 근처에 유지. 최선의 접근법입니다.
- 메모리를 스크래치 공간으로 사용. MSTORE로 값을 저장하고, 나중에 MLOAD로 검색. DUP의 3 가스 대비 3+3=6 가스이지만 깊이 장벽을 돌파합니다.
- 매크로를 더 작은 매크로로 분할하여 각각 더 적은 스택 항목에서 작동하도록 합니다.
#define macro STASH_TO_MEMORY() = takes(1) returns(0) {
// takes: [value]
0x80 mstore // [] — 0x80에 보관 (스크래치 공간)
}
#define macro RECALL_FROM_MEMORY() = takes(0) returns(1) {
0x80 mload // [value]
}
MEV 컨트랙트에서 우리는 종종 0x80..0xc0을 스택이 16을 넘어가는 값을 위한 스크래치 영역으로 예약합니다.
일반적인 패턴
패턴 1: 소비 연산을 통해 값 유지하기
[x]가 있고 x를 소비하는 옵코드를 호출해야 하지만 이후에도 x가 필요한 경우.
// 원하는 것: x의 해시를 계산하되, x 유지
// 스택: [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 가스)으로 폐기:
// 스택: [result, garbage1, garbage2]
swap2 // [garbage2, garbage1, result]
pop // [garbage1, result]
pop // [result]
또는 더 효율적으로:
// 스택: [result, garbage1, garbage2]
swap1 pop // [result, garbage2]
swap1 pop // [result]
패턴 4: 쌍 복제
상위 두 항목을 복사해야 합니다:
// 스택: [a, b]
dup2 // [b, a, b]
dup2 // [a, b, a, b]
역순으로 DUP한다는 점에 주목하세요. dup2가 먼저 b(위치 2)를 복사하고, 그 다음 dup2가 a(스택이 증가했으므로 이제 위치 2)를 복사합니다. 이 패턴은 비교-전-산술 코드에서 끊임없이 나타납니다.
스택 시각화 규율
Huff를 작성할 때 이 규율을 채택하세요:
- 모든 줄에 주석 — 실행 후 스택 상태.
- takes/returns 검증 — 진입과 종료 시 스택 항목 수를 세기.
- 모든 분기 추적 — 각 JUMPI에서, 취한 경로와 취하지 않은 경로 모두 스택을 유효한 상태로 남겨야 합니다.
- 스택 드리프트 주의 — 루프 본문이 푸시와 팝을 완벽하게 균형 잡지 못하면, 각 반복에서 스택이 증가하거나 축소됩니다.
#define macro TRANSFER() = takes(3) returns(0) {
// takes: [amount, from, to]
// 발신자 잔액 로드
dup2 // [from, amount, from, to]
sload // [bal_from, amount, from, to]
// 충분한 잔액 확인
dup1 dup3 // [amount, bal_from, bal_from, amount, from, to]
gt // [amount > bal_from?, bal_from, amount, from, to]
insufficient jumpi // [bal_from, amount, from, to]
// 발신자에서 차감
dup2 // [amount, bal_from, amount, from, to]
swap1 sub // [bal_from - amount, amount, from, to]
dup3 // [from, new_bal, amount, from, to]
sstore // [amount, from, to]
// 수신자에 추가
dup3 // [to, amount, from, to]
sload // [bal_to, amount, from, to]
add // [new_bal_to, from, to]
swap2 // [to, from, new_bal_to]
sstore // [from]
pop // []
done jump
insufficient:
0x00 0x00 revert
done:
}
모든 줄에 스택 주석이 있습니다. 모든 분기가 깔끔하게 종료됩니다. 이것이 올바른 Huff를 작성하는 유일한 방법입니다.
스택 오류 디버깅
Huff에서 가장 흔한 버그:
- 스택 언더플로 — 빈 스택에서 팝. EVM이 런타임에 되돌림. 원인: 잘못된
takes수 또는 누락된 DUP. - JUMP 시 스택 불균형 — 두 개의 다른 경로에서 도달한 JUMPDEST가 서로 다른 스택 상태를 기대. 컴파일러가 이를 잡지 못합니다.
- DUP/SWAP 오프바이원 — 이전에 추가 푸시가 있었을 때
dup3대dup4. 이것이 스택 주석이 필수인 이유입니다.
huffc에는 기본 스택 분석을 수행하는 --stack-check 플래그가 있습니다:
huffc src/Contract.huff -r --stack-check
명확한 언더플로는 잡지만 모든 동적 점프 경로를 추적할 수는 없습니다. 복잡한 컨트랙트의 경우 forge debug 또는 evm-trace로 실행을 수동으로 추적하세요.
고급: 레지스터 파일로서의 스택
경험 많은 Huff 개발자는 상위 약 8개의 스택 위치를 레지스터 파일로 생각합니다:
위치 1 (상단): 작업 레지스터 — 현재 계산
위치 2-3: 인수 레지스터 — 다음 연산 입력
위치 4-6: 지역 변수 레지스터 — 곧 필요한 값
위치 7-8: 컨텍스트 레지스터 — 루프 카운터, 베이스 포인터
위치 9+: 스필 영역 — 거의 접근하지 않음, 메모리 고려
이 멘탈 모델은 값을 상단으로 SWAP할지 DUP할지, 그리고 메모리로 스필할 때를 결정하는 데 도움이 됩니다.
요약
스택 관리는 Huff 개발의 핵심 기술입니다. 비파괴적 읽기를 위한 DUP, 재정렬을 위한 SWAP, 그리고 깊이 16을 넘어가는 값을 위한 메모리. 모든 줄에 스택 상태를 주석으로 달고. 모든 분기를 검증하세요. 다음 기사에서는 이러한 기술을 사용하여 압축된 점프 테이블로 O(1) 함수 디스패처를 구축합니다 — 정밀한 스택 관리가 가스 절약으로 직접 변환되는 곳입니다.