EIP-7979 EVM 호출과 복귀 명령어 (Call and Return Opcodes for the EVM)

Hegota 포함 검토 (CFI) · EVM·실행 · 원문 상태 Review · 2025-12-17 작성

EVM에 서브루틴 호출과 복귀를 위한 세 명령어 CALLSUB, CALLDEST, RETURNSUB를 추가합니다. 동적 점프로 흉내 내던 함수 호출을 명시적으로 만들어 코드와 가스를 아끼고 분석을 쉽게 합니다.

왜 필요한가

지금의 EVM에는 함수를 부르고 돌아오는 전용 명령어가 없습니다. 컴파일러는 목적지를 스택에서 꺼내는 동적 점프(JUMP) 명령어를 조합해 호출과 복귀를 흉내 냅니다. 이 방식에는 두 가지 문제가 있습니다. 첫째, 점프를 여러 개 이어 붙이다 보니 바이트코드 공간과 가스가 낭비됩니다. 둘째, 더 중요한 문제로, 복귀 주소가 데이터로 취급되기 때문에 코드의 흐름이 사람과 도구 모두에게 가려집니다. 코드를 분석하는 도구는 가능한 모든 목적지를 표시해야 해서 코드 크기의 제곱에 비례하는 시간이 들 수 있고 모든 경로를 실제로 따라가면 지수 시간 이상이 걸릴 수 있습니다. 이더리움에서는 선형보다 나쁜 복잡도가 바이트코드 검증이나 실시간 컴파일 같은 온라인 정적 분석에 대한 서비스 거부(DoS) 취약점이 됩니다. 오프라인에서도 자동 정확성 증명이나 형식 분석이 비현실적으로 느려지거나 수학적으로 불가능해질 수 있습니다.

무엇이 바뀌나

지금

EIP-7979 이후

누구에게 영향이 있나

심층 설명

CALLSUB (0xBC): 서브루틴 호출

데이터 스택 맨 위에서 destination을 꺼내고 복귀 주소 PC + 1을 리턴 스택에 올린 뒤, PC를 destination으로 설정합니다. destination이 CALLDEST가 아니거나 리턴 스택에 이미 1024개가 쌓여 있으면 예외적 중단(exceptional halting) 상태가 됩니다. 가스 비용은 mid(8)입니다. JUMP의 mid 비용에 리턴 스택에 정수 하나를 올리는 일만 더해지므로 이 비용이 정당하다고 원문은 설명하며 비용 균형은 벤치마크로 확인이 필요하다고 덧붙입니다.

CALLDEST (0xBB): 입구 표시와 꼬리 호출

서브루틴 입구를 표시하는 명령어로, JUMPDEST처럼 그 외에는 아무 일도 하지 않고 실행이 아래로 흐릅니다. 모든 CALLSUB의 목적지는 반드시 CALLDEST여야 합니다. 가스는 jumpdest(1)입니다. CALLDEST는 JUMP와 JUMPI의 유효한 목적지이기도 합니다. 점프로 들어가면 복귀 주소를 올리지 않으므로 그 안의 RETURNSUB는 리턴 스택에 이미 있는 주소로 돌아갑니다. 이를 허용하는 이유는 꼬리 호출(tail call) 때문입니다. 복귀 직전의 호출을 점프로 바꾸면 명령어 하나와 복귀 주소 하나가 줄고 자기 자신을 다시 부르는 재귀는 리턴 스택 깊이를 늘리지 않고 돌 수 있습니다. 컴파일러는 꼬리 호출, 상호 재귀, 상태 기계, 공유 에필로그에 이 변환을 씁니다.

RETURNSUB (0xBF): 복귀

리턴 스택에서 꺼낸 복귀 주소로 PC를 설정합니다. 리턴 스택이 비어 있으면 예외적 중단 상태가 됩니다. 가스는 low(5)로, 리턴 스택을 PC로 팝하기만 하면 되어 JUMP보다 일이 적다는 근거입니다. 리턴 스택 값은 CALLSUB와 RETURNSUB만 바꿀 수 있으므로 꺼낼 때 검사할 필요가 없습니다. 리턴 스택은 의미론을 기술할 뿐 실제 상태는 EVM 코드에서 관찰할 수 없고 합의에 중요하지도 않아서 RETURNSUB가 관찰 가능하게 PC + 1로 돌아가기만 하면 구현이 PC를 저장해도 됩니다. 0xBD와 0xBE는 다중 분기 호출·점프 가능성을 위해 비워 두었습니다.

왜 별도의 리턴 스택인가

x86, ARM, RISC-V 같은 레지스터 머신은 복귀 주소를 링크 레지스터나 데이터와 섞인 하나의 스택에 둡니다. 반면 Forth, JVM, Wasm, CLR 같은 스택 머신은 사실상 데이터 스택과 리턴 스택을 분리해 씁니다. EVM은 스택 머신이므로 같은 검증된 방식을 택합니다. 데이터 스택 항목이 32바이트인데 점프 목적지는 한두 바이트면 충분하다는 점도 이유입니다. 복귀 주소가 자기 스택에만 있어 일반 스택 연산으로 읽거나 고치거나 옮길 수 없으므로, 코드가 자신의 제어 흐름을 오염시키는 취약점 부류가 통째로 사라집니다. 남는 위험은 명령어 정의대로 실행 시점에 검사됩니다.

코드 크기와 가스 절감

숫자를 제곱하는 간단한 서브루틴을 호출하는 예시에서, JUMP로 합성한 코드는 17바이트에 50가스를 쓰지만 CALLSUB를 쓴 코드는 12바이트에 34가스를 씁니다. 29% 적은 바이트와 32% 적은 가스입니다. JUMP 방식에서는 호출하는 쪽조차 swap1과 jump로 자기 복귀를 합성해야 합니다. 코드가 커지고 최적화될수록 이득 폭은 줄지만, CALLSUB를 쓴 코드는 같은 일을 하는 코드보다 항상 공간과 가스를 덜 씁니다. 즉시 인자나 코드 섹션을 쓰지 않은 것은 하위 호환성이 주된 이유이며 EOF는 코드 섹션과 상대 점프라는 반대 경로를 택해 서브루틴 간 점프를 위한 전용 옵코드가 따로 필요했습니다.

실행 성능과 ZK 증명 비용

동적 점프를 쓰지 않은 코드는 한 번의 패스로 모든 명령어 지점의 데이터 스택 깊이가 실행마다 같음을 검사할 수 있고 통과한 코드는 JIT이 즉석에서, AOT가 선형 시간에 컴파일할 수 있습니다. 원문은 다섯 개 커널(mul chain, call tree, xorshift, euclid, store and sum)에서 실행된 RISC-V 명령어 수를 측정했습니다. 인터프리터 대비 단순 컴파일러는 19배에서 51배, LLVM -O2 최적화 컴파일러는 33배에서 971배 빠릅니다. Polygon의 RISC-V zkVM인 Zisk에서 확인한 결과 증명 비용은 실행 명령어 수에 고정 시작 비용 444스텝을 더한 값과 정확히 같아 컴파일한 코드의 증명이 5배에서 51배 쌉니다. 다만 대부분의 트랜잭션은 스토리지가 실행 시간을 지배하므로, 이득은 L1 프리컴파일, L2, EVM 호환 체인처럼 오버헤드가 적은 곳에서 가장 잘 드러납니다.

하위 호환성과 검증 구현

새 옵코드는 어디서나 동일하게 동작하고 기존 EVM 코드의 의미는 바뀌지 않습니다. 미지정 동작에 의존하던 코드만 영향을 받을 수 있는데, 그런 코드는 원래 깨진 것입니다. 이 변경은 ZK 실행, EOF, RISC-V 같은 다른 변화를 막지 않습니다. 참조 구현(EELS 기준)에서는 머신 상태에 return_stack 필드가, JUMPDEST 분석에 valid_call_destinations 집합이 추가됩니다. CALLDEST 위치는 두 집합 모두에 들어가므로 jump와 jumpi는 바꿀 필요가 없습니다. 새 예외로 ReturnStackOverflowError와 ReturnStackUnderflowError가 생깁니다.

상태와 코어 개발자 콜 결정

이 EIP의 상태는 Review로, 명세가 아직 바뀔 수 있습니다. 09.10 ACDE #245에서는 결정을 미루고 솔리디티와 바이퍼 컴파일러 팀이 실제로 통합할지 의견을 낼 때까지 2주를 유예했으며 진전이 없으면 자동 DFI가 기본값이라고 정했습니다. 이후 09.24 ACDE #246에서 EIP-7979를 헤고타(Hegota) 포함 검토(CFI)로 올렸습니다. 클라이언트들이 구현을 전제로 검토한다는 뜻입니다.

EIP 원문 · ← EIPs