예전에 Emscripten을 이용하여 간단한 윈도 GUI 소스 코드를 javascript로 변환하는 win32.js를 만든 적이 있었는데(데모 페이지), 이에 대해 간단하게 설명해 보도록 하겠습니다.

Emscripten

EmscriptenLLVM IR 언어를 Javascript로 변환하는 LLVM 백엔드입니다. (LLVM의 구조에 대해서는 The Architecture of Open Source Applications: LLVM를 참조하세요.)

다음의 C 코드를 예로 들어 보겠습니다.

#include <stdio.h>

int main() {
    printf("hello world!\n");
}

clang은 이 코드를 대략 다음과 같이 변환합니다. (emcc hello.c --save-bc hello.bc && llvm-dis -o hello.ll hello.bc로 얻을 수 있습니다.)

...

@.str = private unnamed_addr constant [13 x i8] c"hello world\0A\00", align 1

define i32 @main() #0 {
  %1 = call i32 (i8*, ...)* @printf(i8* getelementptr inbounds ([13 x i8]* @.str, i32 0, i32 0))
  ret i32 0
}

declare i32 @printf(i8*, ...) #1

...

여기에서 define은 LLVM IR에서의 함수 정의이며, declare는 함수 선언입니다. int main()이 LLVM IR로 변환되었고, printf는 외부에서 정의되어 이후 링크되겠죠. 대충 보면 @.str의 메모리 주소를 printf 처음 변수로 넘기고 있습니다.

Emscripten은 이 LLVM IR 코드를 받아, 약 5천 라인의 javascript 코드로 변환합니다. 물론 이들을 전부 읽어볼 필요는 없습니다. 우선 main이 어떻게 변환되었는지를 보도록 하죠.

function _main(){
 var label=0;
 var tempVarArgs=0;
 var sp=STACKTOP; (assert((STACKTOP|0) < (STACK_MAX|0))|0);
 var $1=_printf(8,(tempVarArgs=STACKTOP,STACKTOP = (STACKTOP + 1)|0,STACKTOP = (((STACKTOP)+7)&-8),(assert((STACKTOP|0) < (STACK_MAX|0))|0),HEAP32[((tempVarArgs)>>2)]=0,tempVarArgs)); STACKTOP=tempVarArgs;
 STACKTOP=sp;return 0;
}

우선 STACKTOP|0과 같은 표기가 눈을 혼란스럽게 하는데.. |0은 숫자를 정수로 변환해주는 기능을 합니다. Javascript의 모든 숫자는 내부적으로 64-bit 부동 소수점으로 표현되는데, ToInt32라는 연산을 정의하여 해당 소수점 값을 정수로 변환할 수 있습니다. |0이 바로 그러한 기능을 하는 것이죠. (정의에 대해서는 ECMA-262: 8.5 The Number Type와 9.5 ToInt32를 참조하세요. 2ality의 number encodingsafe integers도 한번 읽어보면 좋습니다.) 자세히 보면 정수와 정수를 더한 후 다시 |0을 하는 것을 볼 수 있는데, 이것은 해당 계산이 모두 정수 범위에서 이루어진다는 것을 보장하기 위해서입니다. 요새 모던한 브라우저에서는 계산식 코드를 분석하여 모든 계산이 정수 범위라는 것이 보장된다면 그에 대한 최적화를 수행합니다. (이러한 추가 연산을 이용해서 함수의 모든 변수에 ‘타입’을 표현하여, 브라우저가 최적화하기 좋도록 의도하는 것이 바로 asm.js입니다.)

_printf(...) 부분의 첫번째 변수는 8이 들어있는데, 이것은 메모리 상에서의 “hello world!” 위치를 나타냅니다. 변환된 코드 어딘가에 들어있겠죠?

/* memory initializer */ allocate([104,101,108,108,111,32,119,111,114,108,100,10,0,0,0,0], "i8", ALLOC_NONE, Runtime.GLOBAL_BASE)

js 코드를 처음 로딩할 때 내부 메모리 버퍼에 할당하는 것을 알 수 있습니다. 그러면 _printf는 어떻게 정의되어 있을까요? 이 함수는 변환된 js코드 안에 함께 들어있습니다.

function _printf(format, varargs) {
  // int printf(const char *restrict format, ...);
  // http://pubs.opengroup.org/onlinepubs/000095399/functions/printf.html
  var stdout = HEAP32[((_stdout)>>2)];
  return _fprintf(stdout, format, varargs);
}

_fprintf의 정의를 쫓아 흘러가 보면 다음을 만납니다.

function _write(fildes, buf, nbyte) {
  // ssize_t write(int fildes, const void *buf, size_t nbyte);
  // http://pubs.opengroup.org/onlinepubs/000095399/functions/write.html
  var stream = FS.getStream(fildes);
  if (!stream) {
    ___setErrNo(ERRNO_CODES.EBADF);
    return -1;
  }
  try {
    var slab = HEAP8;
    return FS.write(stream, slab, buf, nbyte);
  } catch (e) {
    FS.handleFSError(e);
    return -1;
  }
}

FS.write()를 부르네요. FS는 Emscripten에서 구현해 둔 Filesystem I/O 라이브러리로, library_fs.js에 소스 코드가 있습니다. 자세히 볼 필요는 없지만, 어쨌거나 따라가 보면 Module['print']에 등록된 함수를 호출합니다.

// src/shell.js
else if (ENVIRONMENT_IS_WEB || ENVIRONMENT_IS_WORKER) {
    Module['print'] = function print(x) {
      console.log(x);
    };
}

이밖에도 Emscripten 내부에는 여러 C 함수들이 javascript로 짜여져 있습니다. 위에서 설명한 filesystem 외에도 OpenGL 에뮬레이션(!), glfw(!!), SDL(!!!) 등이 구현되어 있습니다. 게임 코드를 변환하기 위해서는 필수적으로 있어야 하는 것들이겠죠. 앞서 printf("hello world\n"); 코드가 5천 라인으로 변환된다고 했는데, 사실은 이러한 라이브러리 코드 및 메모리 에뮬레이션 코드가 대부분을 차지하기 때문입니다.

win32.js 만들기

그렇다면 “윈도 어플리케이션”을 Emscripten으로 변환하려면 할 일은 하나뿐입니다. 윈도 API들을 javascript로 구현하는 일이죠. Emscripten은 이러한 서드파티 라이브러리 지원을 위해 --js-library 플래그를 제공하고 있습니다. 그리고 --pre-js, --post-js를 이용해 변환된 코드 앞뒤로 추가 코드를 붙일 수도 있습니다.

MessageBox

간단하게 MessageBox부터 시작해봅시다. (모든 코드는 UNICODE를 가정합니다.)

#include <windows.h>

int main() {
    MessageBox(NULL, L"hello world", L"messagebox", 0);
}

컴파일러가 MessageBox가 어떻게 생겼는지 알려면 mingw나 mingw-w64 헤더가 있어야겠죠. 일단 mingw에서 w32apicompiler-rt를 받아 적당한 폴더에 압축을 해제했습니다.

em++ hello.cpp -I/path/to/mingw/include -D_X86_ -DWIN32 -DUNICODE -DWIN32_LEAN_AND_MEAN --save-bc hello.bc

hello.bcllvm-dis로 변환하니 이런 게 나오네요.

@.str = private unnamed_addr constant [12 x i32] [i32 104, i32 101, i32 108, i32 108, i32 111, i32 32, i32 119, i32 111, i32 114, i32 108, i32 100, i32 0], align 4
@.str1 = private unnamed_addr constant [11 x i32] [i32 109, i32 101, i32 115, i32 115, i32 97, i32 103, i32 101, i32 98, i32 111, i32 120, i32 0], align 4

define i32 @main() #0 {
  %1 = call i32 @MessageBoxW(%struct.HWND__* null, i32* getelementptr inbounds ([12 x i32]* @.str, i32 0, i32 0), i32* getelementptr inbounds ([11 x i32]* @.str1, i32 0, i32 0), i32 0)
  ret i32 0
}

declare i32 @MessageBoxW(%struct.HWND__*, i32*, i32*, i32) #0

할 일은 명확합니다. Javascript로 MesasgeBoxW를 구현해주면 되겠죠. 우선 library_win32.js 껍데기를 만듭시다.

var LibraryWin32 = {
    MessageBoxW: function(hwnd, $message, $title, v) {
        // TODO
        console.log("hwnd:", hwnd, "$message:", $message, "$title:", $title, "v:", v);
    }
};

mergeInto(LibraryManager.library, LibraryWin32);

mergeInto는 Emscripten에서 제공하는 라이브러리 프레임워크 API입니다. MessageBoxW 변수명에 $는 그냥 제가 멋대로 붙였는데, 메모리 포인터 타입을 표시하겠습니다.

적당히 빌드하고(위의 em++ 실행 명령어에 --js-library library_win32.js를 추가해주면 됩니다), a.out.js를 열어 보면 다음과 같이 변환된 것을 볼 수 있습니다.

function _MessageBoxW(hwnd, $message, $title, v) {
    // TODO
    console.log("hwnd:", hwnd, "$message:", $message, "$title:", $title, "v:", v);
}

function _main(){
 var label=0;
 var $1=_MessageBoxW(0,56,8,0);
 return 0;
}

실행 결과도 예상할 수 있겠고요.

"hwnd:" 0 "$message:" 56 "$title:" 8 "v:" 0

56이나 8은 메모리 주소겠죠. 메모리는 다음과 같이 초기화됩니다.

/* memory initializer */ allocate([109,0,0,0,101,0,0,0,115,0,0,0,115,0,0,0,97,0,0,0,103,0,0,0,101,0,0,0,98,0,0,0,111,0,0,0,120,0,0,0,0,0,0,0,0,0,0,0,104,0,0,0,101,0,0,0,108,0,0,0,108,0,0,0,111,0,0,0,32,0,0,0,119,0,0,0,111,0,0,0,114,0,0,0,108,0,0,0,100,0,0,0,0,0,0,0], "i8", ALLOC_NONE, Runtime.GLOBAL_BASE)

그런데 L"hello world"104,0,0,0,101,0,0,0,108,0,0,0,108,0,0,0,...으로 변환되었네요. 이건 L"hello world"wchar_t들을 담고 있고 Emscripten가 사용하는 le32-unknown-nacl 타겟에서는 wchar_t가 4바이트이기 때문입니다.

어쨌거나 MessageBox의 구현으로 가장 적당한 방법은 alert()일 것 같은데(title은 무시하죠), 그러려면 wchar_t에 저장된 문자열을 읽어야 합니다. 일단 모든 문자열이 BMP라고 가정하고, 32비트씩 읽는 u16 함수를 구현합니다.

var LibraryWin32 = {
    $Util: {
        u16: function(ptr, length) {
            if (ptr === 0) {
                return "";
            }
            var ret = [];
            var i = 0;
            while (true) {
              assert(ptr + i < TOTAL_MEMORY);
              var t = getValue(ptr + i * 4, 'i32');
              if (t === 0 && !length) {
                break;
              }
              ret.push(t);
              i++;
              if (length && i === length) {
                break;
              }
            }
            ret = String.fromCharCode.apply(String, ret);
            return ret;
        },
    },
    MessageBoxW: function(hwnd, $message, $title, v) {
        message = Util.u16($message);
        alert(message);
    }
};

autoAddDeps(LibraryWin32, '$Util');
mergeInto(LibraryManager.library, LibraryWin32);

여기에서 getValue는 Emscripten에서 제공하는 메모리 접근 함수입니다. 그리고 Util.u16을 사용하기 위해 autoAddDepsUtil을 등록해주었고요. 빌드하고 실행하면 alert("hello world")와 동일한 동작이 실행됩니다. 만세!

RegisterClass

다음으로는 RegisterClass를 구현할 차례겠죠. RegisterClassWNDCLASS 구조체를 받아 시스템에 등록하는 함수입니다. 그러면 구현체 어딘가에 글로벌한 Class 맵을 관리하는 곳이 있어야 하겠죠. 이러한 시스템을 Emscripten 래퍼와는 별도로 구현하여 --pre-js로 붙이도록 하겠습니다.

System = (function() {
    function System(desktop) {
        this.desktop = desktop;
        this.mq = [];
        this.classes = {};
        this.active_window = null;
    }

    return System;
})();

system = new System(window.Win32.desktop);
window.Win32.system = system;

Class = (function() {
    function Class(clsname, wnd_proc) {
        this.clsname = clsname;
        this.wnd_proc = wnd_proc;
    }

    return Class;
})();

window.Win32.Class = Class;

window.Win32.system = ...은 CoffeeScript에서 자주 쓰는 global variable 만드는 트릭입니다.

html 페이지에는 다음과 같은 초기화를 넣었습니다.

<div id="desktop"></div>
<script>
    window.Win32 = {};
    window.Win32.desktop = $('#desktop');
</script>

이제 RegisterClass를 위한 준비가 끝났습니다. 어차피 간단하게 만드는 거니까 WNDCLASS.lpszClassNamelpfnWndProc 두 요소만 가져오도록 하죠.

RegisterClassW: function($cls) {
    var clsname = Util.u16(getValue($cls + 36, 'i32'));
    var wnd_proc = getValue($cls + 4);
    var cls = new window.Win32.Class(clsname, wnd_proc);
    window.Win32.system.classes[clsname] = cls;
    return 0;
},

물론 완벽한 에뮬레이션을 위해서는 더 복잡한 작업이 필요하겠지만요. wnd_proc이 재미있는 부분인데, 함수 포인터를 받아왔습니다. 함수 포인터는 Emscripten에서는 다음과 같이 변환됩니다.

var FUNCTION_TABLE = [0,0,_wnd_proc];
...
function _wnd_proc($wnd, $msg, $wparam, $lparam) {
...
}

여기에서 _wnd_proc은 javascript로 변환된 C++ 콜백 함수이고요. 콜백 함수를 호출하려면 Emscripten Runtime.dynCall() API를 이용할 수도 있지만.. 그냥 대충 하도록 합시다. :-)

    var func = FUNCTION_TABLE[wnd_proc];
    var ret = func(hwnd, msg, wparam, lparam);

CreateWindow

Window 시스템을 구현하려면 앞에서와 마찬가지로 Window 목록을 관리하는 시스템이 필요하겠죠.

var Window = (function() {
    function Window(hwnd, clsname, name, style, x, y, w, h, parent, m, i, param, exstyle) {
        ...
    }

    Window.prototype.on_proc = function(m, w, l) {
        var ret = 0;
        if (this.cls && this.cls.wnd_proc) {
          var func = FUNCTION_TABLE[this.cls.wnd_proc];
          if (func) {
            ret = func(this.hwnd, m, w, l);
          }
        } else {
          ret = this.def_proc(m, w, l);
        }
        this.on_after_proc(m, w, l, ret);
        return ret;
    };

    Window.prototype.on_after_proc = function(m, w, l, ret) {
        switch (m) {
            case 0x0001: // WM_CREATE
                if (ret === 0) {
                    this.on_create();
                }
            break;
            case 0x0002: // WM_DESTROY
                this.on_destroy();
            break;
            case 0x0003: // WM_MOVE
                this.on_move(l & 0xFFFF, l >> 16);
            break;
            case 0x0005: // WM_SIZE
                this.on_size(l & 0xFFFF, l >> 16);
            break;
            case 0x0018: // WM_SHOW
                this.on_show();
            break;
        }
        this.reshape();
        return ret;
    };
    ...
    return Window;

})();

window.Win32.Window = Window;

on_proc이 재미있는 부분입니다. 이 함수는 메시지 핸들러에서 불릴 예정인데, 만약 현재 윈도에 연결된 WndProc이 있다면 그 함수를 호출하고 없으면 DefWindowProc을 부릅니다. 그리고 메시지를 WndProc에서 처리한 다음 결과에 따른 후처리를 on_after_proc에서 합니다.

Message Queue

일반적인 윈도 어플리케이션은 while문을 돌면서 메시지 큐를 기다리는 루틴이 있습니다.

int main() {
    ...
    MSG msg = {};
    while (GetMessage(&msg, NULL, 0, 0)) {
        TranslateMessage(&msg);
        DispatchMessage(&msg);
    }
    ...
}

문제는 여기에서 GetMessage()는 메시지가 올 때까지 blocking되는 동작을 수행해야 하는데, 자바스크립트로 그러한 동작을 구현하는 것은 불가능합니다. 따라서 Emscripten에서는 이 부분을 별도의 커스텀 핸들러로 대체하는 것을 제안합니다.

#ifdef EMSCRIPTEN
    emscripten_win32_loop();
#else
    MSG msg = {};
    while (GetMessage(&msg, NULL, 0, 0)) {
        TranslateMessage(&msg);
        DispatchMessage(&msg);
    }
#endif

마음에 드는 건 아니지만 어쩔 수 없죠. 이렇게 해두고 emscripten_win32_loop()를 별도로 구현합시다.

var LibraryWin32 = {
    ...
    emscripten_win32_loop: function() {
        return setTimeout(window.Win32.system.main_loop, 0);
    },
    ...
};

System에도 메시지 큐와 메인 루프를 구현합시다.

var __bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; };

System = (function() {
    function System(desktop) {
        this.desktop = desktop;
        this.main_loop = __bind(this.main_loop, this);
        this.mq = []; // message queue
    }

    System.prototype.main_loop = function() {
        while (this.mq.length > 0) {
            msg = this.mq.shift();
            this.send_msg(msg);
        }
        return setTimeout(this.main_loop, 500);
    };
...

setTimeout이 보기 슬프지만 딱히 더 나은 방법은 없습니다. (하지만 아래에서는 일부 이벤트에 대해 main_loop를 거치지 않고 즉시 처리하는 트릭을 사용합니다.) 이제 PostMessage 같은 함수는 this.mq에 메시지를 추가하는 식으로 구현하면 되겠죠.

GUI!

이제는 윈도를 실제 화면에 렌더링해볼 차례입니다. 윈도 그래픽을 어떻게 간편하게 할 수 있을까 싶은데, 마침 우연히도 fake-mswin이라는 멋지고 굉장한 라이브러리가 있으니 이걸 쓰도록 합시다. :-)

    var me = $("<div class='window' id='hwnd-" + this.hwnd + "'/>");
    Var title_bar = $("<div class=\"title-bar\">\n    <div class=\"title-icon\"></div>\n    <div class=\"title\">" + this.name + "</div>\n    <div class=\"title-button-group\">\n        <div class=\"title-button minimize\"></div>\n        <div class=\"title-button maximize\"></div>\n        <div class=\"title-button close\"></div>\n    </div>\n</div>");
    me.append(title_bar);
    Var inner_win = $("<div class='inner-window' style='width: 100%; height: 100%; position: relative;'/>");
    me.append(inner_win);

fake-mswin은 div에 몇 가지 class를 지정해주어 생성하면 알아서 윈도 프레임과 버튼에 해당하는 css를 적용해줍니다.

윈도 껍데기가 다 준비되었으니 resize나 move에도 대응해줬으면 좋겠습니다. jQuery-ui 매직을 이용하도록 합니다.

    var _this = this;
    me.draggable({
        handle: ".title",
        drag: function(e, u) {
            var x = u.position.left;
            var y = u.position.top;
            return _this.on_proc(0x0003, 0, x | (y << 16));
        }
    });
    me.resizable({
        handles: "all",
        minWidth: parseInt(me.css("min-width")),
        minHeight: parseInt(me.css("min-height")),
        resize: function(e, u) {
            var x = u.position.left;
            var y = u.position.top;
            var w = u.size.width;
            var h = u.size.height;
            console.log("resize:", x, y, w, h);
            _this.on_proc(0x0005, 0, w | (h << 16));
            _this.on_proc(0x0003, 0, x | (y << 16));
            return _this.on_proc(0x000F, 0, 0);
        }
    });

jQuery-ui 이벤트에 on_proc을 넣어 두었습니다. 약간의 트릭인데요, WM_SIZE/WM_MOVE 메시지를 큐에 넣어두는 것보다는 resize 이벤트가 온 즉시 그 이벤트를 처리하도록 하려는 의도입니다. 이렇게 구현하면 main_loop가 500ms 후 호출되길 기다리지 않고도 UI 이벤트를 바로바로 처리할 수 있습니다.

Remarks

여기서 설명한 것 이외에도 구현에 고민해볼 부분들이 이것저것 있습니다. 가령 이 글에서는 CreateWindow에서는 RegisterClass로 등록한 윈도만이 생성되는 것을 가정했는데, 실제로는 CreateWindow(L"BUTTON", ...)처럼 시스템 class를 생성하고 싶을 수도 있겠죠. 그리고 이 때에는 HTML <input>을 만들어주는 것이 그럴듯해 보일 거고요.

현재까지의 구현 결과물은 GitHub repo에 올라가 있습니다. 아주 간단한 예제를 돌릴 정도의 수준만 구현되어 있긴 하지만요.

참고 링크

  • 현재 win32.js는 간단한 장난감 수준으로만 만들어져 있지만, emscripten-qt라는 것이 존재합니다. Qt 거의 전부를 구현해놓은 듯 해요. 최근에 webkit.js라는 포팅이 진행중입니다.
  • 이밖에도 Emscripten wiki에 다양한 포팅 사례들이 소개되어 있습니다.