일반적으로 C언어에서 struct에 의해 구조화된 바이너리 데이터를 자바스크립트에서 사용할 수 있게 변환하고 이를 다시 조작하여 바이너리로 생성하는 과정이 엘레강스(?)하지가 않아서 C의 struct와 유사하게 자바스크립트에서도 구조체를 사용할 수 있도록 작은 유틸리티를 만들었습니다. 만들고 보니, 다른 곳에서도 유용하게 사용될 수 있을 것 같아 공개합니다.

우선, 자바스크립트만으로 ArrayBuffer를 다루어 보겠습니다. 예제에 사용되는 바이너리 데이터는 0번째 번지에 Uint8 유형의 정수, 1번째 번지에 Int8 유형의 정수, 3번째 번지에는 Uint16 유형의 2바이트짜리 정수이며, 이를 쓰고 다시 읽어내는 것입니다:

// define struct
var struct = {
  foo: 255,
  bar: 127,
  baz: {
    qux: 65535
  }
};

// write arraybuffer from javascript object
var ab = new ArrayBuffer(4);
var dv = new DataView(ab);
dv.setUint8(0, struct.foo);
dv.setInt8(1, struct.bar);
dv.setUint16(2, struct.baz.qux, true);

console.log(dv.buffer);
// => ArrayBuffer {byteLength: 4, slice: function}

// read data from arraybuffer
var dv2 = new DataView(dv.buffer);
var data = {
  foo: dv2.getUint8(0),
  bar: dv2.getInt8(1),
  baz: {
    qux: dv2.getUint16(2, true)
  }
};

console.log(data);
// => Obejct {foo: 255, bar: 127, baz: {qux: 65535}}}

옵셋을 손으로 패딩해야하며 형식이 동일한 구조의 데이터를 읽고 생성하려 했지만 도저히 같다고는 느껴지지 않습니다. 그리고 버퍼의 크기가 크면 클수록 사용성이 떨어지는 문제도 있습니다. struct.js를 사용하면 다음과 같이 코드를 작성할 수 있습니다:

// define struct
var struct = new Struct({
  foo: ['uint8', 255],
  bar: ['int8', 127], 
  baz: {
    qux: ['uint16', 65535]
  }
}, 0, true);

// write arraybuffer from javascript object
var ab = struct.write();
console.log(ab);
// => ArrayBuffer {byteLength: 4, slice: function}

// read data from arraybuffer
var data = struct.read(ab);
console.log(data);
// => Obejct {foo: 255, bar: 127, baz: {qux: 65535}}

옵셋을 자동으로 카운트하고, 자바스크립트 형식으로 작성한 데이터 구조체를 재활용하여 새로운 arraybuffer를 생성하거나 반대로 자바스립트에서 읽을 수 있는 데이터로 만들어 사용하기가 수월합니다. 이는 마치 C에서 생성하는 구조체를 사용하는 느낌입니다.

속성(키)/[유형(타입), 값(밸류)]로 구조를 작성해야 하며 '속성/유형'만 지정하면 버퍼를 작성하는 경우 기본값이 할당됩니다. 즉, '밸류'는 write 메서드를 이용하여 ArrayBuffer를 생성하는 곳에만 사용되며, 단순히 read 메서드로 데이터를 읽기만 한다거나, 속성마다 특정한 값을 설정할 필요가 없는 경우라면 타입만 지정해도 된다는 의미입니다. 다음은 read 메서드의 두 번째 인자에 사용자 지정 옵셋을 입력하여 동일한 형식의 데이터가 복수로 담긴 청크를 처리하는 모습입니다.

/**
 * read multiple data with custom offset
 */

var struct = new Struct({
  sig: 'uint8',
  mimeType: 'uint8',
  id: 'uint16',
  byteLength: 'uint32'
});

...

function parseBinary(chunk, count, callback) {
  var offset = 0;
  for (var index = 0; index < count; index++) {
    var meta = struct.read(chunk, offset)
      , buffer = chunk.slice(
        offset += struct.byteLength,
        offset += meta.byteLength
      );

    callback(meta, new Uint8Array(buffer));
  }
}

write 메서드에 변경할 내용이 담긴 객체를 인자로 전달하여 복수의 값을 갱신할 수 있도 있습니다. 입력 객체는 하위 구조의 값까지 모두 비교하여 값을 할당하기 때문에 다음과 같이 작성해도 무방합니다.(jquery의 $.extend와는 개념이 다름)

// define struct
var struct = new Struct({
  foo: ['uint8', 255],
  bar: 'int8', 
  baz: {
    qux: ['uint16', 65535],
    quux: ['uint32', 0]
  }
}, 1, true);

// update values and write arraybuffer
var ab = struct.write({
  foo: 0,
  baz: {
    quux: 4294967295
  }
});
// write => ArrayBuffer {byteLength: 8, slice: function}
// read => Obejct {foo: 0, bar: 1, baz: {qux: 65535, quux: 4294967295}}

끝으로, 하나의 속성에 멀티-바이트 타입 배열을 지정할 수 있도록 했습니다. 각각의 번지마다 연속된 값(문자열 또는 배열)을 지정할 수 있으며, 속성에 지정되는 배열의 마지막에 버퍼의 크기를 지정하거나 생략한 경우 그 크기를 자동으로 계산하합니다. 그리고 문자열 형식으로 선언된 속성은 버퍼에서 값을 읽어 올 때 정수들을 모두 문자열로 자동 변환하여 반환합니다.

/**
 * create struct with multi-byte value
 */

var struct = new Struct({
  foo: ['uint16', [0xffff, 4095]],
  bar: ['uint8', 'firejune', 8]
});

var ab = struct.write();
// => ArrayBuffer {byteLength: 12, slice: function}

var obj = struct.read(ab);
// => Object {bar: [65535, 4095], qux: "firejune"}

처음 구성한 구조의 크기(유형)는 변경될 수 없으며, 값만 갱신할 수 있는 규칙을 가집니다. 이 작은 유틸리티의 이름은 거창하게도 struct.js이며, MIT 라이센스를 따릅니다. 대략적인 사용법과 소스코드를 GitHub에 올려 두었으니 필요하신 분은 맘껏 사용하세요.

Comments

자바스크립트에서 버퍼를 읽거나 쓰는 예문들을 볼때 C에서 사용되는 'signed'와 'unsigned'라는 키워드에 비유하는 내용을 자주 접하게 됩니다. 이게 무엇이고 왜 구분을 해야 하는 것인지를 몰라서 우리 팀장님께 커피 한 잔 사드리고 특강을 받았습니다. 제가 이해하기 쉽게 '음수를 표현하느냐 안 하느냐의 차이'라고 알려 주셨고, 메모리에 비트를 기록하는 방식이 다르다고 했습니다. "더 자세히 설명해 주세요~"했더니 CPU가 어쩌니 어셈블리가 저쩌니 한 귀로 듣고 흘려 버릴수 밖에 없는 내용이어서... 나중을 대비해 나름 이해한 내용을 정리합니다.

예를 들어 C언어에서는 다음과 같이 8비트 정수 타입을 선언할 수 있습니다.

signed char
unsigned char

unsigned char는 비트를 투명하게 볼 수 있는 특성이 있으며, 임의의 메모리에 바이트 단위로 접근해서 값을 다룰 수 있습디다. 이 경우에는 unsigned char를 사용하는 것이 강제됩니다. 그리고 signed char는 unsigned char와 값이 같아도 같지 않은 경우가 발생할 수도 있습니다. 왜냐하면 signed는 음수 표현을 위해 2의 보수 체계를 사용하고 부호 비트(MSB)가 필요하기 때문입니다.(0 이면 양수 1 이면 음수) 그래서 부호 비트가 없는 unsigned는 양수 범위를 두 배로 늘리는 역활을 한답디다. 즉, char 형식은 8비트이므로 signed char은 -128~127의 범위를 표현할 수 있고 unsigned chare은 0~255의 표현범위를 가지는 것입니다.

조금더 이해하기 쉽게 그림으로 예를 들어봅시다. 정수 3인 1바이트(8비트)를 2진수로 기록하면 00000011이 됩니다. Unsigned에서는 다음과 같이 부호가 할당되겠죠.

0 0 0 0 0 0 1 1

Signed는 음수를 표현하기 위해 제일 앞 하나의 비트를 소비한다고 했습니다. 그럼 정수 3은 이렇게 되겠군요.

0 0 0 0 0 0 1 1

온라인 이진수-정수 변환기를 사용해 보면 금방 이해할 수 있습니다.

(나도 언젠가는 CPU의 마음과 메모리의 정신을 이해할 수 있겠...)

Comments

자바스크립트의 DataView를 이용하여 바이너리를 메모리에 쓰거나(Write) 전송(Send)하면서 삽질하는 과정들이 저에게는 마냥 신세경입니다. ArrayBuffer API를 소개하면서 간소하게 언급했던 DataView 인터페이스에서 등장하는 리틀엔디안과 빅엔디안에 대한 개념을 탑재해 보겠습니다.

컴퓨터에서 어떤 크기의 데이터를 메모리에 저장할 때 바이트 단위로 나누어 저장합니다. CPU 아키텍처에 따라 바이트 저장순서가 달라질 수 있기 때문에 두 가지로 나뉘는 데 그것이 바로 '리틀-엔디안'과 '빅-엔디안' 방식입니다. 어떤 CPU에서는 이 두 가지 방식을 모두 지원하도록 구성할 수도 있답디다.

리틀-엔디안 (Little-Endian)

낮은(시작) 주소에 하위 바이트부터 기록, Intel CPU 계열
예) 32비트형 (4바이트) 값: 0x01020304

하위 주소 0x04 0x03 0x02 0x01 상위 주소

빅-엔디안 (Big-Endian)

낮은(시작) 주소에 상위 바이트부터 기록, Sparc / RISC CPU 계열
예) 32비트형 (4바이트) 값: 0x01020304

하위 주소 0x01 0x02 0x03 0x04 상위 주소

빅엔디안은 우리가 평소에 보던 방식으로 메모리에 쓴다고 생각하면 되고 리틀엔디안은 뒤집혀서 쓴다고 이해하면 되겠죠? 그럼 왜 빅엔디안으로 안 쓰는 걸까요? 그 이유는 산술연산유닛(ALU)에서 메모리를 읽는 방식이 메모리 주소가 낮은 쪽에서부터 높은 쪽으로 읽기 때문에 산술 연산의 수행이 더 쉽습니다. 또한, 데이터를 다른 시스템으로 전송할 때 서로 다른 데이터 저장 방식의 시스템끼리 통신하게 되면 전혀 엉뚱한 값을 주고받기 때문이랍니다.

자바스크립트의 DataView 인터페이스는 특정 파일 또는 수신된 바이너리 데이터를 읽고 쓸 수 있도록 설계되었습니다. 브라우저가 작동 중인 CPU에 상관 없이 일관되고 올바른 결과를 얻을 수 있도록 작동하기 위해 모든 값의 모든 접근에 엔디안(Endianness)을 지정해야 합니다.

var buffer = new ArrayBuffer(12);
var dv = new DataView(buffer);
dv.setInt32(0, 25, false); // set big-endian int32 at byte offset 0 to 25
dv.setInt32(4, 25); // set big-endian int32 at byte offset 4 to 25
dv.setFloat32(8, 2.5, true); // set little-endian float32 at byte offset 8 

Comments