정적 바이너리 번역으로 Windows/x86 프로그램을 실행하는 새로운 에뮬레이터 Theseus와 그 접근 방식의 장점, 한계, 그리고 배경을 소개합니다.
이 글은 단일 항목입니다. 더 보려면 첫 페이지로 돌아가세요.
2026년 4월 19일
retrowin32 시리즈에 관한 글은 아마 이번이 마지막일 것 같습니다.
소개합니다: Theseus. 프로그램을 정적으로 번역하는 새로운 Windows/x86 에뮬레이터로, 에뮬레이션의 여러 문제를 해결하는 동시에 분명 새로운 문제들도 만들어 냅니다.
저는 제 win32 에뮬레이터 retrowin32를 한동안 작업하지 않았습니다. 일부는 개인적인 사정 때문이었고, 일부는 그것을 어디로 가져가고 싶은지 확신이 없었기 때문입니다. 그러다 과거에 여기에 기여했던 누군가가 retrotick이라는 자신만의 웹 기반 Windows 에뮬레이터를 공개했는데, 제 수년간의 작업보다 더 좋아 보였고, HN에서는 Claude로 한 시간이면 만들었다고 댓글을 달았습니다.
이 글은 AI에 대한 글이 아닙니다. 이미 그런 글은 너무 많기도 하고, 저 자신도 아직 그것에 대해 어떤 감정을 가져야 할지 확실하지 않기 때문입니다. 하지만 제가 계속 생각해 온 작은 점 하나는, (1) AI가 천천히 그러나 확실하게 주니어 엔지니어에서 시니어 엔지니어의 영역으로 올라오고 있다는 점, 그리고 (2) 시니어 엔지니어의 핵심 역할 중 하나는 어떻게 만들지를 아는 것과 구분되는, 무엇을 _만들어야 하는지_를 더 잘 이해하는 것이라는 점입니다.
(이게 결국 Innovator's Dilemma의 “상위 시장으로 후퇴하기”라는 개념을, 인간으로서의 제 효용에 적용한 것일까요? 저도 잘 모르겠습니다. 다행히 저는 이 일을 과정 자체를 위해, 제 호기심을 충족하기 위해 하고 있습니다. 그래서 이런 상황에서 기업처럼 존재론적 위협을 느끼지는 않습니다. Benny Feldman이 말했듯이: “나는 물질적 부에 집착하지 않기 때문에 비밀리에 카지노를 속인다!”)
그렇다면, 시니어 엔지니어 씨, 우리는 무엇을 만들어야 할까요? 애초에 에뮬레이터로 무슨 문제를 풀고 있는 걸까요? 그리고 우리의 접근 방식은 그것에 어떻게 부합할까요? 저는 조금은 정통적이지 않은 해법에 도달했고, 그것을 여러분께 이야기하고 싶습니다!
가장 단순한 CPU 에뮬레이터는 인터프리터와 매우 비슷합니다. 입력 프로그램은 파싱을 거친 뒤 다음과 같은 x86 명령어가 됩니다:
mov eax, 3
add eax, 4
call ... ; some Windows system API
인터프리팅 방식의 에뮬레이터는 명령어를 하나씩 실행하는 거대한 루프입니다. 대략 이런 모습입니다:
loop {
let instr = next_instruction();
match instr {
// e.g. `mov eax, 3`
Mov => { set(argument_1(), argument_2()); }
// e.g. `add eax, 4`
Add => { set(argument_1(), argument_1() + argument_2()); }
...
}
}
인터프리터와 마찬가지로, 이 접근은 느립니다.
큰 틀에서 보면 인터프리터가 느린 이유는 각 명령어마다 많은 동적 작업을 수행하기 때문입니다. 루프 안에서 같은 add 명령을 계속 실행하는 프로그램을 에뮬레이션한다고 상상해 보세요. 위의 에뮬레이터 루프는 반복할 때마다 “지금 내가 실행 중인 명령어가 무엇인가?”를 묻고 인자를 조사하기 위해 이런 함수 호출들을 계속 수행하고, 결국 매 반복마다 같은 add를 하게 됩니다. x86 메모리 참조는 매우 유연하기 때문에 특히 더 고통스럽습니다.
게다가 x86에서 add 명령은 숫자를 더할 뿐 아니라, 패리티 플래그처럼 결과에 1 비트가 짝수 개 들어 있는지 여부 같은 것을 포함해 여섯 가지 파생 값도 계산합니다. 올바른 에뮬레이터라면 이것들도 모두 계산해야 하거나, 아니면 코드를 어떤 식으로든 부가 분석해서 효율적으로 실행하는 방법을 결정해야 합니다.
에뮬레이터를 개선하기 위한 여러 흥미로운 기법들이 있습니다. 하지만 정말 빠르게 가고 싶다면, 결국 필요한 것은 코드 분석과 그것으로부터 네이티브 머신 코드를 생성하는 것의 조합, 즉 JIT입니다. JIT는 쓰기 어렵기로 유명합니다! 사실상 최적화 컴파일러이기 때문에, 최적화와 머신 코드 생성의 모든 복잡성을 떠안으면서도, 컴파일 자체의 실행 시간이 성능의 핵심 경로 안에 들어갑니다. Python JIT 시도가 15번도 넘었다고 언급하는, 왜 JIT가 어려운지에 대한 이 글의 설명이 특히 좋았습니다.
그렇다면 효율적인 머신 코드를 생성하고 싶지만 JIT는 쓰고 싶지 않다고 해 봅시다. 코드 분석과 효율적인 머신 코드 생성에 정말 뛰어난 것이 무엇일까요? 바로 컴파일러입니다!
그래서 핵심 아이디어는 이렇습니다. 위와 같은 입력 x86 코드 조각이 주어졌을 때, 이것을 다음과 같은 소스 코드 형태로 변환할 수 있습니다:
regs.eax = 3;
regs.eax = add(regs.eax, 4);
windows_api(); // some native implementation of the API that was called
그다음 이 코드를 최적화 컴파일러에 다시 넣어 현재 아키텍처에 네이티브인 프로그램을 얻습니다. 이제 x86은 더 이상 필요 없습니다.
즉, .exe 파일을 직접 에뮬레이터에 넘겨 JIT로 코드를 생성하게 하는 대신, .exe를 중간에 또 하나의 컴파일러를 거쳐 곧바로 “네이티브” 실행 파일로 정적으로 번역하는 일종의 컴파일러를 두는 셈입니다.
(여기서 “네이티브”에 따옴표를 붙이는 이유는, 결과 실행 파일이 네이티브 바이너리이긴 하지만 그 안에 x86 상태를 표현하는 일종의 내부 가상 머신을 들고 다니기 때문입니다. 위 코드의 regs 구조체 같은 것입니다. 이건 조금 뒤에 더 설명하겠습니다.)
저는 달성하고자 하는 바를 깊이 생각하다가 이 기본 아이디어를 스스로 떠올렸다고 생각했는데, 알고 보니 이 접근은 정적 바이너리 번역이라고 불리며 잘 연구된 분야였습니다. 좋은 성질도 있고, 큰 문제들도 있습니다.
그 이야기에 들어가기 전에, 제가 여기까지 오게 된 경로를 설명하는 작은 우회를 먼저 하겠습니다.
여러분은 디컴파일을 들어보셨나요? 이 대단한 사람들은 오래된 비디오 게임의 소스 코드를 함수 하나씩 수동으로 다시 만들어 내고 있습니다. 게임 바이너리에서 어떤 함수의 머신 코드를 추출한 다음, 정교한 UI를 사용해("Recent activity" 아래 항목 중 하나를 눌러 보세요) 정확히 같은 머신 코드를 생성하는 더 고수준 코드를 반복적으로 조정합니다. 정말 놀랍습니다.
(이 작업을 하려면 대상 게임을 컴파일할 때 사용된 것과 동일한 원본 컴파일러까지 실행해야 합니다. 그런 컴파일러는 종종 Windows 프로그램이므로, 위의 멋진 UI를 구현하려면 Linux 서버에서 오래된 Windows 바이너리를 실행해야 합니다. 제가 이들을 처음 알게 된 것도 바로 이 때문이었습니다. 그들에게는 Windows 에뮬레이터가 필요했던 것입니다!)
디컴파일은 단지 이상하고 매혹적인(그리고 아마도 지루한?) 인간의 노력일 뿐만 아니라, 제게 중요한 점도 보여 주었습니다. 저는 임의의 아무 프로그램이나 실행할 수 있는 에뮬레이터 자체에는 그렇게 큰 관심이 없고, 극히 특정한 몇몇 프로그램을 실행하는 데 관심이 있으며, 그걸 위해서라면 어느 정도 수작업도 기꺼이 감수할 수 있다는 점입니다.
실제로 Windows 에뮬레이터를 만드는 사람을 보면, 결국 대상 프로그램의 심장을 손으로 집어넣고 펌프질하는 외과의사 같은 역할을 하게 됩니다. 대상 프로그램을 디버깅하고 그 프로그램 고유의 버그를 우회하는 일까지 포함해서 말입니다. 에뮬레이터가 아예 어떤 프로그램이 동작하고 어떤 프로그램이 실패하는지 목록을 수동으로 관리하는 일도 흔합니다.
머신 코드를 정적으로 번역하는 것은 새로운 아이디어가 아닙니다. 그렇다면 왜 더 널리 쓰이지 않을까요? 제가 관련 글들을 읽으며 받은 인상은, 종종 이것은 작동할 수 없다는 이유로 기각된다는 것입니다. 하지만 적어도 지금까지는 꽤 잘 작동했습니다. 어쩌면 제가 아직, 지금까지 놓치고 있던 어떤 불가능한 문제를 만나지 않았을 뿐인지도 모르겠습니다.
(이 블로그 글을 위해 관련 연구를 찾아보려 했을 때, NES를 정적으로 번역하려던 이 시도는 불가능하다고 결론 내렸지만, 또 한편으로는 이 사람들은 실제로 성공하고 있는 것처럼 보입니다. 그래서 단정하기 어렵습니다.)
제 생각에 여기에는 두 가지 주요 문제가 있습니다. 하나는 기술적인 문제이고, 다른 하나는 더 문화적인 문제입니다.
기술적인 쪽은, 단순한 아이디어에 복잡한 세부 사항이 따라온다는 것입니다. 우선 런타임에 코드를 생성하는 프로그램(예를 들어, 스스로 JIT를 포함하는 프로그램)은 동작하지 않겠지만, 저는 그런 프로그램을 그냥 범위 밖이라고 쉽게 치워 버릴 수 있습니다. 제어 흐름이 어떻게 작동하는지 같은 부분에도 도전 과제가 있지만, 그것들은 작고 흥미로운 문제이고 아마 나중 글에서 다룰 수도 있습니다.
흔한 연구 주제 중 하나는, 런타임에 코드를 생성하지 않는 프로그램이라 해도 vtable이나 점프 테이블에서 비롯되는 동적 제어 흐름 때문에 실행될 수 있는 모든 코드를 정적으로 찾는 것이 극한에서는 불가능하다는 점입니다. 특히 대부분의 코드를 찾는 기법은 있어도, 완벽하게 동작한다고 보장되는 접근은 없습니다. 바로 이 지점에서 디컴파일이 제 관점을 바꿨습니다. 특정 프로그램에 대해 제가 조금 수동으로 도와줄 의향이 있다면, 이 문제는 괜찮을지도 모른다는 것입니다.
제가 생각하기에 바이너리 번역이 더 흔하지 않은 주된 문화적 이유는, 이미 대부분의 프로그램을 처리할 수 있는 범용 에뮬레이터만큼 편리하지 않기 때문입니다. 사용자들은 컴파일러 툴체인을 돌리고 싶어 하지 않을 가능성이 높습니다. 물론 이를 피하려고 컴파일러(예: LLVM)를 직접 내장한 프로젝트들도 보았습니다.
또 다른 문화적 문제는, 번역된 프로그램을 배포하려 할 경우 법적 함의가 따른다는 점입니다. 모든 비디오 게임 에뮬레이터는 “먼저 이미 소유한 실물 복사본에서 게임 데이터를 복사하고, 그것을 입력으로 제공하라”는 법적 허구에 의존하기 때문에, 그럴듯하게 비파생 저작물로 남을 수 있습니다.
하지만 저는 사용자를 위한 해법을 찾는 것이 아니라 제 자신의 흥미를 위한 해법을 찾고 있습니다. 이런 문화적 문제는 제게 중요하지 않습니다.
다시 위의 코드 조각, 즉 3과 4를 더하는 예를 생각해 봅시다. 정적 번역기의 세계에서는 명령어 스트림을 미리 파싱하므로, 컴파일러는 우리가 eax에 3을 넣고 싶다는 사실을 볼 수 있습니다. 인터프리터처럼 런타임에 어디에서 어떤 값을 읽고 쓰는지 고민하느라 시간을 쓰지 않아도 됩니다.
컴파일러는 대상 아키텍처에 맞는 올바른 머신 코드를 생성할 뿐 아니라, 위와 같은 코드를 최적화해서 결과값 7만 저장하도록 만들 수도 있습니다. 그리고 적절히 구조를 잡으면 패리티 계산 같은 불필요한 코드도 제거할 수 있습니다. Theseus의 코드 생성은 프로그램 실행과 분리된 “오프라인” 작업이기 때문에, 코드를 분석해 도움을 얻기 위해 런타임을 들이는 문제에 대해서는 JIT보다 덜 걱정해도 됩니다.
처음 시작했을 때 저는 성능이 이 접근의 전부인 장점일 거라고 생각했습니다. 하지만 실제로는 다른 개발 도구들을 모두 끌어들일 수 있기 때문에 개발하기도 더 쉬웠습니다.
retrowin32에서는 문제를 추적하려고 디버거 UI를 따로 하나 통째로 만들게 되었지만, Theseus에서는 지금까지 그냥 시스템 디버거만 써도 충분했습니다.
retrowin32에서는 또 에뮬레이터와 네이티브 코드 사이의 브리지를 다루느라 많은 시간을 보냈습니다. Theseus에도 이 경계는 여전히 존재하지만, 훨씬 작아졌습니다. 번역된 코드가 제 네이티브 win32 시스템 API 구현을 직접 호출할 수 있기 때문입니다(내부 머신 표현 안팎으로 데이터를 옮기는 약간의 접착 코드는 필요합니다).
MacOS에서는 retrowin32를 Rosetta 아래에서 실행할 수 있었지만, 전체 실행 파일이 x86-64 바이너리여야 했고, 따라서 SDL도 크로스 컴파일된 것이 필요했습니다. Theseus 바이너리는 네이티브 코드이므로 그냥 네이티브 SDL을 호출합니다.
종합하면, 그냥 훨씬 더 단순합니다. 이 아이디어를 떠올린 시점부터, 제가 그동안 계속 만지작거리던 테스트 프로그램이 첫 장면을 실행하게 만들기까지, DirectX, FPU, MMX까지 포함해서도 고작 몇 주밖에 걸리지 않았습니다.
인터프리터, JIT, 정적 바이너리라는 서로 다른 접근은, 사전에 얼마나 많은 일을 하고 런타임에 얼마나 많은 일을 하느냐의 스펙트럼으로 볼 수 있습니다. Theseus는 “이 mov는 어떤 종류인가?”라는 동적인 질문을 사전 컴파일 단계로 옮기고, 일반적인 명령어 핸들러를 인자가 고정된 구체적인 명령어로 부분 평가합니다. (다시 한 번 C 코드 메타 트레이싱에 대한 훌륭한 블로그 글을 링크합니다. 이 아이디어가 극한까지 밀려간 형태로는 Futamura projections를 읽어 보세요!)
또 다른 예로, 일반적인 Windows 에뮬레이터는 시작 시 PE 실행 파일을 파싱하고 로드해야 하지만, Theseus는 그것을 컴파일 시점에 하고, 실행에 필요한 데이터 구조만 써 넣습니다. PE 파싱 코드는 출력물에 필요하지 않습니다.
마찬가지로 실행 파일 시작 과정에는 참조된 DLL과 시스템 DLL을 연결하고 로드하는 일도 포함되지만, Theseus는 자신이 실행할 모든 코드를 봐야 하므로 이 링크 작업도 사전에 수행합니다. 다음은 Windows API 호출 근처의 출력 일부인데, 컴파일 시점에 IAT 참조(ds:[...] 주소)를 제가 작성한 Rust 구현으로 직접 해석한 모습입니다:
// 004012a0 push 4070A4h
push(ctx, 0x4070a4u32);
// 004012a5 push 8
push(ctx, 0x8u32);
// 004012a7 call dword ptr ds:[4060E8h]
call(ctx, 0x4012ad, Cont(user32::CreateWindowExA_stdcall))
어떤 의미에서는 Theseus가 컴파일 시점에 시스템 바이너리 로더를 부분적으로 실행하고, 출력 소스 코드가 준비된 상태의 스냅샷이 되는 셈입니다. 이 점은 실행 파일 언패킹 문제를 조금 떠올리게 합니다.
Theseus는 WebAssembly 환경의 웹에서도 쉽게 확장될 수 있어야 합니다. 대부분은 생성된 프로그램을 대상 아키텍처로 wasm을 지정해 컴파일하는 것뿐입니다. (처음에는 이걸 작동하게 해 두었다가, 지금은 추가 복잡성이 필요 없다고 판단해 구현하지 않았습니다.)
별개로, Theseus의 출력 프로그램은 WebAssembly가 실행되는 방식에서 영감을 받았습니다. 둘 다 외부의 호스트 프로그램이 내부에 자신만의 코드와 메모리 개념을 가진 “기계”를 품고 있습니다. 그 기계 내부의 코드는 자신의 메모리만 읽고 쓸 수 있고, 호스트 바깥으로 나가려면 제공된 훅을 호출해야 합니다. WebAssembly처럼 Theseus 출력 실행 코드도 데이터와 분리되어 있어서, 의도적이든 악의적이든 메모리 쓰기만으로는 새로운 코드를 만들어 낼 수 없다는 좋은 성질이 있습니다.
wasm Theseus는 기계가 들어 있는 기계가 들어 있는 기계, 일종의 터더켄이 될 것입니다:
이 점을 생각하다 보면, 여기서 몇몇 기계 계층을 섞어, WebAssembly 프로그램의 메모리를 입력 Windows 프로그램의 메모리 개념과 1:1로 맞추고 싶다는 유혹이 듭니다. 즉 입력 프로그램이 어떤 주소 에 쓴다면, 그것을 그대로 WebAssembly 메모리 주소 에 쓰도록 번역할 수 있다는 것입니다. (그 경우 중간 계층은 x86 프로그램이 사용하지 않는 위치에 자신의 자료구조를 숨기도록 조정해야 합니다.) 저는 retrowin32를 x86 에뮬레이터 아래에서 동작시키기 위해 비슷한 일을 해야 했습니다. WebAssembly는 심지어 바이너리로부터 메모리를 직접 배치하는 것도 허용합니다. 실제로 큰 이득이 있는지는 모르겠고, 그냥 좀 귀여울 뿐일 것 같습니다.
WebAssembly와 정적 바이너리 번역이라는 주제와 관련해서는, WebAssembly 실행 문제에 정적 바이너리 번역을 적용한 wastrel을 보세요. 그 글을 읽은 것이 분명히 이 아이디어의 씨앗을 제게 준 것 같습니다.
저는 이 프로젝트의 이름을 배에서 따온 Theseus라고 지었습니다.
다시 글 맨 위의 x86 어셈블리를 생각해 봅시다. 이것은 무엇을 할까요? 어떻게 보느냐에 따라, 한 가지 올바른 답은 “3과 4를 더한다”일 수도 있고, 심지어 그냥 “7을 계산한다”일 수도 있습니다. 또는 eax 레지스터에 3을 넣고, eax 레지스터에 4를 더하고, 몇 CPU 클록을 소비하고, 여러 CPU 플래그를 설정한다고 말할 수도 있습니다.
저나 제 컴파일러가 이런 해석 중 하나를 다른 것으로 바꿔치기한다면, 그것은 여전히 같은 프로그램일까요? 어떤 맥락을 중요하게 보느냐에 따라 — 제 인상으로는 NES 같은 시스템을 에뮬레이션하려면 클록을 정확하게 맞춰야 합니다 — 이런 세부 사항은 중요할 수도 있고 그렇지 않을 수도 있습니다. Theseus의 경우 저는 입력 프로그램의 모든 부품을 하나씩 대체했기 때문에, 그 프로그램을 명시적으로 버리고 있는 셈입니다.
조금 더 멀리 있는 아이디어도 하나 있습니다. 역시 테세우스의 배와 비슷한 방향의 생각입니다. Windows API를 구현하는 일은, 40년에 걸친 Hyrum's Law의 결과를 우회하는 끝없는 흐름입니다. 아까 그 임의의 버그 우회를 다시 생각해 봅시다. DirectPlayEnumerateA의 API를 문서화한다면, 콜백을 호출한다고 써야 할까요? 아니면 콜백을 호출하고, 보존된 스택 포인터까지 복구한다고 쓰는 편이 더 정확할까요? 오늘날 Wine 같은 Windows 에뮬레이터의 코드를 보면 이런 것들로 가득 차 있습니다.
제가 생각하고 있는 아이디어 하나는, 이런 종류의 문제에서는 에뮬레이터를 더 복잡하게 만드는 대신, 디컴파일 플레이북에서 한 페이지를 가져와 프로그램 자체의 일부를 대체하는 일을 쉽게 관리할 방법을 제공하는 것입니다.
일단 프로그램의 일부를 대체할 수 있다고 받아들이면, 더 흥미로운 가능성들이 열립니다. 프로그램 안에 성능이 좋지 않은 코드가 있다면, JIT를 더 복잡하게 만드는 대신 그냥 그 코드를 손수 작성한 구현으로 대체할 수 있습니다. (알고리즘 자체를 바꿀 필요조차 없을 가능성이 있습니다. 같은 알고리즘을 네이티브 코드로 다시 쓰고, 현대 컴파일러가 자동 벡터화 로직을 적용하게 두는 것만으로도 충분할 수 있습니다.) 기계 장치가 충분히 갖춰진다면, 기능을 추가하기 위해 일부를 대체하는 것조차 가능할 것입니다. retrowin32의 한 기여자가 여기서 조사했고, 심지어 몇몇 GameBoy 게임에 대해 실제로 구현하기도 했습니다.