Emscripten의 엠바인드

JS를 wasm에 바인딩합니다.

지난 wasm 도움말에서는 웹에서 사용할 수 있도록 C 라이브러리를 wasm으로 컴파일하는 방법을 설명했습니다. 저와 많은 독자에게 눈에 띄는 한 가지는 사용 중인 wasm 모듈의 함수를 수동으로 선언해야 하는 대략적이고 약간 어색한 방식입니다. 참고로 말씀드린 코드 스니펫은 다음과 같습니다.

const api = {
    version: Module.cwrap('version', 'number', []),
    create_buffer: Module.cwrap('create_buffer', 'number', ['number', 'number']),
    destroy_buffer: Module.cwrap('destroy_buffer', '', ['number']),
};

여기서는 EMSCRIPTEN_KEEPALIVE로 표시한 함수의 이름, 반환 유형, 인수 유형을 선언합니다. 그런 다음 api 객체의 메서드를 사용하여 이러한 함수를 호출할 수 있습니다. 하지만 이 방법으로 wasm을 사용하면 문자열이 지원되지 않으며 메모리 청크를 수동으로 이동해야 하므로 많은 라이브러리 API를 사용하는 것이 매우 번거로워집니다. 더 나은 방법이 없나요? 물론입니다. 그렇지 않다면 이 도움말의 주제가 무엇이겠어요?

C++ 이름 매글링

개발자 환경은 이러한 바인딩에 도움이 되는 도구를 빌드하기에 충분한 이유이지만, 실제로는 더 긴급한 이유가 있습니다. C 또는 C++ 코드를 컴파일할 때 각 파일이 별도로 컴파일되기 때문입니다. 그런 다음 링커가 이러한 소위 객체 파일을 모두 함께 뭉쳐 wasm 파일로 변환합니다. C에서는 링커가 사용할 수 있도록 함수 이름이 여전히 객체 파일에 있습니다. C 함수를 호출하는 데 필요한 것은 이름뿐이며, 이 이름은 cwrap()에 문자열로 제공됩니다.

반면 C++는 함수 오버로드를 지원합니다. 즉, 서명이 다른 한 (예: 유형이 다른 매개변수) 동일한 함수를 여러 번 구현할 수 있습니다. 컴파일러 수준에서 add와 같은 멋진 이름은 링커의 함수 이름에 서명을 인코딩하는 것으로 매칭됩니다. 따라서 더 이상 이름으로 함수를 조회할 수 없습니다.

embind 입력

embind는 Emscripten 도구 모음의 일부이며 C++ 코드에 주석을 추가할 수 있는 여러 C++ 매크로를 제공합니다. JavaScript에서 사용할 함수, enum, 클래스 또는 값 유형을 선언할 수 있습니다. 간단한 함수로 시작해 보겠습니다.

#include <emscripten/bind.h>

using namespace emscripten;

double add(double a, double b) {
    return a + b;
}

std::string exclaim(std::string message) {
    return message + "!";
}

EMSCRIPTEN_BINDINGS(my_module) {
    function("add", &add);
    function("exclaim", &exclaim);
}

이전 도움말과 비교하여 더 이상 함수에 EMSCRIPTEN_KEEPALIVE 주석을 지정할 필요가 없으므로 더 이상 emscripten.h를 포함하지 않습니다. 대신 함수를 JavaScript에 노출할 이름을 나열하는 EMSCRIPTEN_BINDINGS 섹션이 있습니다.

이 파일을 컴파일하려면 이전 도움말과 동일한 설정 (또는 원하는 경우 동일한 Docker 이미지)을 사용할 수 있습니다. embind를 사용하려면 --bind 플래그를 추가합니다.

$ emcc --bind -O3 add.cpp

이제 새로 만든 wasm 모듈을 로드하는 HTML 파일을 만들기만 하면 됩니다.

<script src="/a.out.js"></script>
<script>
Module.onRuntimeInitialized = _ => {
    console.log(Module.add(1, 2.3));
    console.log(Module.exclaim("hello world"));
};
</script>

보시다시피 더 이상 cwrap()를 사용하지 않습니다. 즉시 사용할 수 있습니다. 하지만 더 중요한 점은 문자열을 사용하기 위해 메모리 청크를 수동으로 복사할 필요가 없다는 것입니다. embind는 유형 검사와 함께 이 기능을 무료로 제공합니다.

인수 개수가 잘못되거나 인수 유형이 잘못된 함수를 호출할 때 DevTools 오류가 발생함

이는 가끔 다루기 어려운 wasm 오류를 처리하는 대신 일부 오류를 조기에 포착할 수 있으므로 매우 유용합니다.

객체

많은 JavaScript 생성자와 함수가 옵션 객체를 사용합니다. JavaScript에서는 좋은 패턴이지만 wasm에서 수동으로 구현하는 것은 매우 지루합니다. embind도 여기에서 도움이 될 수 있습니다.

예를 들어 문자열을 처리하는 이 매우 유용한 C++ 함수를 생각해 냈는데 웹에서 긴급하게 사용하고 싶습니다. 방법은 다음과 같습니다.

#include <emscripten/bind.h>
#include <algorithm>

using namespace emscripten;

struct ProcessMessageOpts {
    bool reverse;
    bool exclaim;
    int repeat;
};

std::string processMessage(std::string message, ProcessMessageOpts opts) {
    std::string copy = std::string(message);
    if(opts.reverse) {
    std::reverse(copy.begin(), copy.end());
    }
    if(opts.exclaim) {
    copy += "!";
    }
    std::string acc = std::string("");
    for(int i = 0; i < opts.repeat; i++) {
    acc += copy;
    }
    return acc;
}

EMSCRIPTEN_BINDINGS(my_module) {
    value_object<ProcessMessageOpts>("ProcessMessageOpts")
    .field("reverse", &ProcessMessageOpts::reverse)
    .field("exclaim", &ProcessMessageOpts::exclaim)
    .field("repeat", &ProcessMessageOpts::repeat);

    function("processMessage", &processMessage);
}

processMessage() 함수의 옵션에 관한 구조체를 정의하고 있습니다. EMSCRIPTEN_BINDINGS 블록에서 value_object를 사용하여 JavaScript가 이 C++ 값을 객체로 보도록 할 수 있습니다. 이 C++ 값을 배열로 사용하고 싶다면 value_array를 사용할 수도 있습니다. processMessage() 함수도 바인딩하고 나머지는 embind 마법입니다. 이제 더 이상 불필요한 코드 없이 JavaScript에서 processMessage() 함수를 호출할 수 있습니다.

console.log(Module.processMessage(
    "hello world",
    {
    reverse: false,
    exclaim: true,
    repeat: 3
    }
)); // Prints "hello world!hello world!hello world!"

클래스

완벽성을 위해 embind를 사용하여 전체 클래스를 노출하는 방법도 보여드려야 합니다. 이렇게 하면 ES6 클래스와 많은 시너지를 얻을 수 있습니다. 이제 패턴을 파악할 수 있을 것입니다.

#include <emscripten/bind.h>
#include <algorithm>

using namespace emscripten;

class Counter {
public:
    int counter;

    Counter(int init) :
    counter(init) {
    }

    void increase() {
    counter++;
    }

    int squareCounter() {
    return counter * counter;
    }
};

EMSCRIPTEN_BINDINGS(my_module) {
    class_<Counter>("Counter")
    .constructor<int>()
    .function("increase", &Counter::increase)
    .function("squareCounter", &Counter::squareCounter)
    .property("counter", &Counter::counter);
}

JavaScript 측에서는 거의 네이티브 클래스처럼 느껴집니다.

<script src="/a.out.js"></script>
<script>
Module.onRuntimeInitialized = _ => {
    const c = new Module.Counter(22);
    console.log(c.counter); // prints 22
    c.increase();
    console.log(c.counter); // prints 23
    console.log(c.squareCounter()); // prints 529
};
</script>

C는 어때요?

embind는 C++용으로 작성되었으며 C++ 파일에서만 사용할 수 있지만 C 파일에 연결할 수 없다는 의미는 아닙니다. C와 C++를 혼합하려면 입력 파일을 C 파일과 C++ 파일의 두 그룹으로 나누고 다음과 같이 emcc의 CLI 플래그를 보강하기만 하면 됩니다.

$ emcc --bind -O3 --std=c++11 a_c_file.c another_c_file.c -x c++ your_cpp_file.cpp

결론

embind를 사용하면 wasm 및 C/C++로 작업할 때 개발자 환경이 크게 개선됩니다. 이 도움말에서는 embind에서 제공하는 모든 옵션을 다루지는 않습니다. 관심이 있으시면 embind 문서를 계속 참고하시기 바랍니다. embind를 사용하면 gzip을 적용할 때 특히 작은 모듈에서 wasm 모듈과 JavaScript 글루 코드가 최대 11,000바이트까지 커질 수 있습니다. wasm 노출 영역이 매우 작은 경우 프로덕션 환경에서 embind가 그만한 가치가 없는 비용이 들 수 있습니다. 하지만 한 번 시도해 보세요.