본문으로 건너뛰기

그 `.wasm` 안에 무엇이 있을까? 소개: `wasm-decompile`

· 약 6분
Wouter van Oortmerssen ([@wvo](https://twitter.com/wvo))

우리는 .wasm 파일을 생성하거나 조작하는 컴파일러 및 기타 도구의 수가 점점 늘어나고 있으며, 때로는 그 안을 살펴보고 싶을 때가 있습니다. 아마도 당신은 그런 도구의 개발자이거나, 더 직접적으로는 Wasm을 대상으로 삼는 프로그래머로서 생성된 코드가 어떤 모습인지 성능이나 기타 이유로 궁금할 수 있습니다.

문제는 Wasm이 실제 어셈블리 코드처럼 상당히 저수준이라는 것입니다. 특히 JVM과는 달리 모든 데이터 구조가 편리하게 이름이 붙은 클래스와 필드로 컴파일되지 않고 로드/스토어 작업으로 축소됩니다. LLVM과 같은 컴파일러는 들어간 코드와 전혀 다른 모습의 생성된 코드를 만들 수 있는 인상적인 변환을 수행할 수 있습니다.

디스어셈블 또는.. 디컴파일?

.wasm을 Wasm 표준 텍스트 형식인 .wat으로 변환하기 위해 wasm2wat (WABT 도구 세트의 일부)을 사용할 수 있습니다. .wat은 매우 정확하지만 특히 가독성이 좋지 않은 표현입니다.

예를 들어, 점 곱과 같은 간단한 C 함수:

typedef struct { float x, y, z; } vec3;

float dot(const vec3 *a, const vec3 *b) {
return a->x * b->x +
a->y * b->y +
a->z * b->z;
}

clang dot.c -c -target wasm32 -O2를 사용한 후 wasm2wat -f dot.o를 실행하여 이를 다음과 같은 .wat로 변환합니다:

(func $dot (type 0) (param i32 i32) (result f32)
(f32.add
(f32.add
(f32.mul
(f32.load
(local.get 0))
(f32.load
(local.get 1)))
(f32.mul
(f32.load offset=4
(local.get 0))
(f32.load offset=4
(local.get 1))))
(f32.mul
(f32.load offset=8
(local.get 0))
(f32.load offset=8
(local.get 1))))))

이는 매우 작은 코드이지만 이미 읽기가 쉽지 않은 여러 이유가 있습니다. 표현 기반 문법의 부족과 일반적인 장황함 외에도 메모리 로드로 데이터 구조를 이해해야 한다는 것은 쉽지 않습니다. 이제 큰 프로그램의 출력을 본다고 상상해보면, 상황은 빠르게 이해할 수 없게 됩니다.

wasm2wat 대신 wasm-decompile dot.o를 실행하면 다음과 같이 됩니다:

function dot(a:{ a:float, b:float, c:float },
b:{ a:float, b:float, c:float }):float {
return a.a * b.a + a.b * b.b + a.c * b.c
}

이것은 훨씬 더 익숙해 보입니다. 표현 기반 문법 외에도, 디컴파일러는 함수 내 모든 로드 및 스토어를 살펴보고 이를 구조적으로 추론하려고 노력합니다. 그런 다음 포인터로 사용되는 각 변수에 대해 "인라인" 구조 선언을 주석 처리합니다. 이는 반드시 동일한 개념을 나타내는 3개의 float 사용 사례를 알 필요가 없기 때문에 명명된 구조 선언을 생성하지 않습니다.

무엇으로 디컴파일?

wasm-decompile은 Wasm을 나타내면서도 "매우 평균적인 프로그래밍 언어"처럼 보이도록 출력물을 생성합니다.

첫 번째 목표는 가독성입니다: .wasm에 무엇이 있는지 가능한 한 따라가기 쉬운 코드로 독자를 안내하는 것입니다. 두 번째 목표는 여전히 디스어셈블러로서의 가능성을 잃지 않도록 Wasm을 가능한 한 1:1로 표현하는 것입니다. 이 두 목표는 항상 통합할 수 있는 것은 아닙니다.

이 출력물은 실제 프로그래밍 언어용이 아니며 현재 이를 Wasm으로 다시 컴파일할 방법이 없습니다.

로드와 스토어

위에서 보여준 것처럼 wasm-decompile은 특정 포인터 위에 있는 모든 로드와 스토어를 살펴봅니다. 연속적인 접근 집합을 형성하면 "인라인" 구조 선언 중 하나를 출력합니다.

만약 모든 "필드"가 접근되지 않으면, 이것이 구조체인지 아니면 관련 없는 메모리 접근인지 확실히 알 수 없습니다. 그런 경우 float_ptr과 같은 간단한 유형으로 되돌아가거나, 최악의 경우 o[2]:int와 같은 배열 접근을 출력합니다. 이는 oint 값을 가리키며, 세 번째 값을 접근하고 있음을 나타냅니다.

이 마지막 경우는 생각보다 더 자주 발생합니다. Wasm 로컬은 변수보다는 레지스터처럼 더 작동하기 때문에, 최적화된 코드에서는 서로 관련 없는 객체에 동일한 포인터를 공유할 수 있습니다.

디컴파일러는 인덱싱에 대해 스마트하게 동작하며, 정규 C 배열 인덱싱 작업에서 base[index]와 같은 패턴을 탐지합니다. 이는 base가 4바이트 유형을 가리키고 있는 경우 (base + (index << 2))[0]:int로 나타날 수 있습니다. Wasm에서 로드 및 스토어는 상수 오프셋만 가지고 있으므로 이러한 패턴은 코드에서 매우 일반적입니다. wasm-decompile 출력은 이를 원래 형태인 base[index]:int로 변환합니다.

또한 절대 주소가 데이터 섹션을 참조할 때 이것을 인식할 수 있습니다.

제어 흐름

가장 익숙한 것은 Wasm의 if-then 구조로, 이는 익숙한 if (cond) { A } else { B } 구문으로 변환되며, Wasm에서는 실제로 값을 반환할 수 있으므로 일부 언어에서 사용할 수 있는 삼항 cond ? A : B 구문도 표현할 수 있습니다.

Wasm의 나머지 제어 흐름은 blockloop 블록, 그리고 br, br_if, br_table 점프에 기반을 두고 있습니다. 디컴파일러는 while/for/switch 구조를 유추하기보다는 이러한 구조를 대체로 가까운 상태로 유지합니다. 이는 최적화된 출력과 더 잘 작동하는 경향이 있기 때문입니다. 예를 들어, wasm-decompile 출력의 일반적인 루프는 다음과 같이 보일 수 있습니다:

loop A {
// 여기 루프 본문.
if (cond) continue A;
}

여기서 A는 여러 개를 중첩할 수 있는 레이블입니다. 루프를 제어하기 위해 ifcontinue를 사용하는 것은 while 루프와 비교했을 때 약간 생소해 보일 수 있지만, 이는 Wasm의 br_if와 직접적으로 일치합니다.

블록은 유사하나, 뒤로 분기하지 않고 앞으로 분기합니다:

block {
if (cond) break;
// 본문이 여기에 있습니다.
}

이는 실제로 if-then을 구현합니다. 디컴파일러의 향후 버전은 가능할 경우 이를 실제 if-then으로 변환할 수 있습니다.

Wasm의 가장 놀라운 제어 구조는 br_table인데, 이는 뭔가 switch와 비슷한 것을 구현합니다. 그러나 중첩된 block을 사용하여 이를 구현하기 때문에 읽기가 어려울 수 있습니다. 디컴파일러는 이를 약간 읽기 쉽게 하기 위해 평탄하게 처리합니다. 예를 들어: 다음과 같습니다:

br_table[A, B, C, ..D](a);
label A:
return 0;
label B:
return 1;
label C:
return 2;
label D:

이는 a에 대한 switch와 유사하며, D는 기본 케이스에 해당합니다.

기타 흥미로운 기능

디컴파일러:

  • 디버그 또는 링크 정보에서 이름을 가져오거나 자체적으로 이름을 생성할 수 있습니다. 기존 이름을 사용할 때는 C++ 이름 맹글링된 기호를 간소화하는 특별 코드를 포함하고 있습니다.
  • 다중 값 제안을 이미 지원하며, 이를 표현식 및 문으로 변환하는 것이 약간 더 어려워집니다. 여러 값을 반환할 경우 추가 변수가 사용됩니다.
  • 데이터 섹션의 _내용_에서 이름을 생성할 수도 있습니다.
  • 코드뿐만 아니라 모든 Wasm 섹션 유형에 대해 멋진 선언을 출력합니다. 예를 들어, 데이터 섹션을 텍스트로 가능한 경우 읽기 쉽게 출력하려고 합니다.
  • 일반적인 C 스타일 언어에 공통적인 연산자 우선순위를 지원하여 일반적인 표현식에서 ()를 줄입니다.

제한 사항

Wasm 디컴파일은 기본적으로 JVM 바이트 코드를 디컴파일하는 것보다 더 어렵습니다.

후자는 최적화되지 않아 원래 코드 구조에 비교적 충실하며 이름이 없을 수는 있지만 메모리 위치가 아닌 고유 클래스에 참조됩니다.

대조적으로, 대부분의 .wasm 출력은 종종 원래 구조를 대부분 잃어버린 LLVM에 의해 강하게 최적화되었습니다. 출력 코드는 프로그래머가 작성한 코드와 매우 다릅니다. 이는 Wasm을 위한 디컴파일러를 더욱 유용하게 만들 도전을 제공하지만, 그렇다고 해서 우리가 시도하지 말아야 한다는 것은 아닙니다!

더 많은 정보

더 많은 정보를 보려면 물론 자신의 Wasm 프로젝트를 디컴파일해 보는 것이 가장 좋은 방법입니다!

추가로, wasm-decompile에 대한 더 심층적인 가이드는 여기에서 확인할 수 있습니다. 구현은 decompiler로 시작하는 소스 파일 여기에 있습니다(더 나아지게 PR을 기여해 주세요!). .wat과 디컴파일러 간의 차이를 보여주는 몇몇 테스트 케이스는 여기에서 확인할 수 있습니다.