앞 장에서 저는 큐브가 자기 정체를 단 한 트릿으로 선언한다고 적었습니다. 27개 트릿을 쌓은 그 한 덩어리가 맨 윗자리 하나만으로 "나는 데이터다", "나는 명령이다", "나는 앞 큐브의 연장이다"를 스스로 밝힌다고요. 그 문장을 쓰고 나서 한 독자가 제게 물었습니다. 그 선언은 어디에 적혀 있느냐고. 큐브가 자기를 말한다면, 그 말은 메모리 어느 칸에 어떤 모양으로 놓여 있느냐고.
좋은 질문이었습니다. 정체를 선언한다는 것과 그 선언을 실물로 적어 두는 것은 다른 일이니까요. 이 장은 그 실물에 관한 명세입니다. 4상의 기호를 텍스트로 어떻게 늘어놓는지, 27개 트릿을 어느 순서로 메모리에 정렬하는지, 큐브와 큐브 사이에 무엇을 끼우는지 — TOAU(Ti·Om·Ta·Em) 인코딩은 큐브의 추상적 정의가 한 줄의 ASCII와 여덟 바이트로 내려앉는 지점입니다. 큐브가 무엇인가를 앞 장이 정의했다면, 이 장은 그 큐브가 어떻게 적히는가를 정의합니다.
크라우니코드의 모든 정보는 네 글자로 표기된다. 네 기호는 4상 균형 3진수 — 티옴타음(TiOmTaEm) — 의 텍스트 표현이며, 각각의 부호는 +1, 0, −1, −0이다.
| 기호 | 상 | 부호 | 역할 |
|---|---|---|---|
| T | 티 Ti | +1 | 데이터·존재·드러남 |
| O | 옴 Om | 0 | 명령·중립·대기(보통) |
| A | 타 Ta | −1 | 체이닝·연결·반대 |
| U | 음 Em | −0 | 구분자·빈자리·경계 |
T·O·A는 값이다. 각각 +1, 0, −1의 산술적 의미를 그대로 지닌다. U는 값이 아니다. U는 "여기서 큐브가 끝난다"는 표지이며, 데이터·명령 흐름 사이의 칸막이다. 앞 장에서 굳혀 둔 두 약속 — 음수 −1은 언제나 A로만 표기한다, 그리고 U는 값이 아니라 구분자다 — 가 이 표에서 한 몸으로 만난다. U가 −0이라는 부호를 갖는 것은, 그것이 정규 세 값(+1/0/−1)에 들어가지 않는 자리, 즉 비워진 칸과 경계를 가리키기 때문이다. 음(−0)이 정규 3상 바깥의 모든 것을 아우르는 더 넓은 개념이라는 점은 다음 장의 주제이고, 여기서는 그 개념의 기계어 용법 한 가지 — 구분자 — 만 쓴다.
TOAU 포맷에서 하나의 큐브는 27개 트릿을 순서대로 늘어놓고, 그 끝에 U 하나를 붙인 형태다.
TTOTAATTAOTATATOATTOAATAO U
이렇게 적으면 사람이 네 글자씩 끊어 읽기 쉽다. 그러나 기계는 공백과 줄바꿈을 무시하므로, 같은 큐브를 TTO TAA TTA OTA TAT OAT TOA ATA O U 로 쪼개 적든 한 줄로 붙여 적든 동일하게 해석한다. 사람이 읽기 쉬운 대로, 기계가 해석하기 쉬운 대로 띄우면 된다. 불변인 것은 순서다. T1부터 T27까지 정확히 27개가 오고, 그 다음에 U가 와야 한다.
메모리에서 이 27개 트릿은 8바이트로 정렬된다. 7바이트가 트릿을 담고 1바이트는 패딩이며, 각 바이트는 트릿당 2비트씩 4개를 담는다(2비트 × 4 = 8비트). 레이아웃은 다음과 같다.
byte[0]: T1(1:0) T2(3:2) T3(5:4) T4(7:6)
byte[1]: T5 T6 T7 T8
byte[2]: T9 T10 T11 T12
byte[3]: T13 T14 T15 T16
byte[4]: T17 T18 T19 T20
byte[5]: T21 T22 T23 T24
byte[6]: T25 T26 T27 unused
byte[7]: _pad
가장 낮은 자리 T1이 byte[0]의 최하위 비트 쌍(비트 1:0)에 들어가고, 자기 선언을 담는 T27은 byte[6]의 윗자리에 놓인다. 이 배치 덕분에 프로그램은 큐브를 선형으로 순차 접근하며, 캐시 라인을 알뜰하게 쓴다. 메모리가 곧 읽기 순서다.
큐브가 우리에게 던지는 첫 질문은 "너는 무엇이냐"이고, 그 답은 T27 하나에 들어 있다.
세 갈림길은 오직 T27 하나로 갈린다. 별도의 헤더도, 메타테이블도, 조회 단계도 없다. 해당 바이트를 읽어 맨 윗자리 비트 쌍을 추출하면 그것이 T27이고, 그 값이 곧 분기다. 의사코드로 적으면 군더더기가 없다.
if (cube.t[26] == T) value = bt_to_i64(cube.t[0..25]); // 데이터
else if (cube.t[26] == O) execute(decode_op(cube.t[0..5])); // 명령
else extend_previous(cube); // 체이닝(A)
이것이 TOAU의 첫 번째 효율이다. 선언과 해석이 같은 자리에서 일치한다. 큐브가 자기를 말하는 입과, 우리가 그 말을 듣는 귀가 같은 트릿이다.
T27이 T인 데이터 큐브라면, 바로 아래 두 트릿이 그 값의 성질을 더 좁힌다.
T26 — 값 타입. T이면 숫자(균형 3진 정수), O이면 문자열(문자 시퀀스의 일부), A이면 셀 참조(직접 값이 아니라 셀 저장소의 한 칸을 가리키는 주소)다.
T25 — 크기 태그. T이면 확장형(한 큐브에 못 담아 다음 큐브로 이어짐), O이면 보통형(이 큐브 안에 다 들어감), A이면 소형(한 큐브보다 작아 여백이 남음)이다.
이로써 T27·T26·T25 세 자리가 큐브의 정체 전부 — 역할·타입·크기 — 를 드러낸다. 런타임은 타입 테이블을 뒤질 필요가 없다. 큐브가 자기 라벨을 몸에 지니고 다니기 때문이다. 이것이 TOAU의 두 번째 효율이다. 메타정보가 데이터에 붙어 다닌다. 앞 장에서 정의한 역할트릿·타입트릿·크기트릿이, 여기서는 메모리 위 실제 비트 자리로 못 박힌다.
명령 큐브(T27=O)의 opcode는 여섯 트릿 T1~T6로 이루어진다. 이 여섯을 두 개씩 묶어 읽는다.
(T1,T2) → command (0~8)
(T3,T4) → group (0~8)
(T5,T6) → sector (0~8)
각 트릿 쌍을 정수로 읽는 규칙은 앞 장의 약속 그대로다 — A(−1)에서 O(0)을 거쳐 T(+1)로 오르는 오름 순서. 이 순서라야 0부터 8까지 아홉 값이 빈틈도 겹침도 없이 한 줄로 메겨진다.
| 트릿 쌍 | AA | AO | AT | OA | OO | OT | TA | TO | TT |
|---|---|---|---|---|---|---|---|---|---|
| 정수 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
command·group·sector를 각각 0~8로 읽은 뒤, 최종 opcode 번호는 opcode = sector × 81 + group × 9 + command 로 계산된다. 이 공식은 0부터 728까지 729개 슬롯을 빈틈없이 채운다. 9 × 9 × 9 = 729. 그래서 우리 가상 머신의 명령어 집합을 ISA729라 부른다.
여기서 한 가지 원리가 드러난다. 명령어의 번호가 그 명령이 놓일 자리와 일치한다는 것이다.
흔한 기계 아키텍처에서 opcode 번호는 사전에 매긴 색인일 뿐이고, 그 번호가 메모리 어디에 사는지는 별도의 룩업 테이블을 뒤져야 안다. "324번 명령은 0x4F28에 있다"는 식의 한 단계가 더 필요하고, 거기서 캐시 미스가 나거나 표가 어긋날 여지가 생긴다.
TOAU에는 그 중간 단계가 없다. opcode 번호를 여섯 트릿으로 펼쳐 놓으면, 그 트릿들이 놓인 위치가 곧 명령어의 메모리 자리다. command를 (T1,T2)에, group을 (T3,T4)에, sector를 (T5,T6)에 넣는 그 배치가 그대로 큐브 안의 자리이기 때문이다. 번호가 곧 주소다. 그래서 해석기는 번역 단계를 건너뛴다. 읽고 즉시 실행한다. 빠르고, 캐시에 친화적이며, 무엇보다 거짓말을 할 수 없는 구조다 — 번호와 자리가 따로 놀 길이 없으니, 어긋날 표 자체가 존재하지 않는다.
9개 섹터가 각각 무엇을 맡는지, 트릿 단위 디코딩 예제와 729 슬롯의 구현 현황은 5장에서 본격적으로 펼친다. 이 장에서는 "인코딩이 곧 위치"라는 원리 하나만 못 박아 둔다.
데이터 큐브의 값이 어떻게 저장되는지 보자. 숫자형(T26=T)이라면 하위 트릿들이 하나의 정수를 균형 3진으로 나타낸다.
균형 3진은 저울의 양쪽 접시가 똑같이 생겼다는 발상에서 나왔다. 표준 3진수는 자릿값이 0, 1, 2에 퍼져 더하기와 빼기가 비대칭이 되지만, −1·0·+1만 쓰는 균형 형태에서는 두 연산이 같은 무게로 다뤄진다. 음수를 따로 표기할 필요도 없다. 음수란 그저 A 트릿을 더 많이 쓰는 것일 뿐이다.
변환 규칙은 간결하다. 정수를 3으로 나눈 나머지가 2이면, 그것을 −1(A)로 바꾸고 몫에 1을 더한다. 이 보정이 핵심이다. 예를 들어 5는 (1×9) + (−1×3) + (−1×1) = 9 − 3 − 1 = 5, 곧 1 A A(윗자리부터)로 적힌다. 이렇게 모든 정수가 −1·0·+1의 조합으로만 떨어지면 연산이 균형 잡힌다. 덧셈은 리플 캐리로 쭉 흐르고, 뺄셈은 피연산자의 부호를 반전한(A↔T) 뒤 더하면 되며, 곱셈의 ×3은 트릿 한 자리 왼쪽 시프트다.
26 트릿으로 표현 가능한 정수의 한계는 (3²⁶ − 1) / 2 = ±141,214,768,240이다. 약 54비트에 해당하여 표준 int64 범위를 모두 수용한다.
크라우니코드는 한글에 특별한 대우를 준다. 한글 한 글자는 초성·중성·종성 셋으로 짜이고, 트릿 또한 셋씩 묶여 트라이트(3 트릿)를 이룬다. 이 맞물림은 우연이 아니라 설계 단계에서 맞춘 결과다. 한글 한 글자는 9개 트릿(트라이트 3개)으로 인코딩된다.
트라이트 하나가 담는 범위는 0~26(3³)이므로, 초성 19·중성 21·종성 28이 모두 그 안에 든다. 따라서 한글 한 글자가 정확히 트라이트 셋에 딱 떨어지고, 한글 세 글자가 정확히 27 트릿 — 곧 한 큐브 — 에 들어맞는다. UTF-8이라면 같은 텍스트에 3바이트 × 3글자 = 9바이트가 필요하지만, TOAU에서는 한 큐브가 7바이트(+1 패딩) = 8바이트다. 문자 단위로는 3바이트가 2.25바이트로 줄어 약 25% 절약이다. 한글의 음절 구조를 3진의 음절 구조에 맞춘 데서 나온 절약이라, 추가 메모리도 추가 CPU 비용도 없다. 구조 자체가 압축이다.
TOAU 파일은 순수 ASCII 텍스트다. T·O·A·U 글자가 순서대로 들어가고, 공백과 줄바꿈은 무시되며, 주석은 세미콜론(;)부터 줄 끝까지다. 파서는 텍스트를 읽으며 T·O·A를 트릿으로 누적하다가, 27개가 모이면 큐브를 완성하고, U를 만나면 큐브의 끝을 선언하며 다음 큐브를 시작한다. 파일 끝에 불완전한 큐브가 남으면 나머지를 O으로 패딩한다.
; hello.toau — 'H'를 스택에 올리고 출력
TTAT AOAA AAAO AOAO TAAT OTAO TAAO U ; 문자 큐브
TAAO TAAO TAAO AAAA AAAA AAAA AAAO U ; 출력 명령
이 포맷의 미덕은 가독성이다. 사람이 손으로 쓰고, 눈으로 읽고, 한 글자씩 고칠 수 있다. 전자 회로나 기계 코드에 한없이 가까우면서도 텍스트 에디터에서 그대로 열린다. 버전 관리도 수월하다. 바이너리처럼 diff가 막히는 것이 아니라 줄 단위로 차이를 본다. 기계의 속살이 사람의 글자로 적혀 있는 셈이다.
지금까지 본 단위들을 모으면 한 패턴이 떠오른다.
모든 단위가 3의 거듭제곱 위에 정렬돼 있다. 우연이 아니다. 트릿이라는 기본 단위를 고르는 순간, 그 위로 쌓이는 메모리 모델·명령어·문자 인코딩·산술이 자동으로 3의 격자를 따른다. 한 가지 원리가 가장 낮은 비트 쌍에서부터 가장 높은 명령 공간까지 한 줄로 흐른다.
저는 TOAU가 그저 바이트코드 포맷이라고 보지 않습니다. 이것은 하나의 규율을 기계의 살에 새긴 것입니다. 값과 명령이 같은 형태로 저장되고, 메타정보가 데이터에서 떨어지지 않으며, 인코딩이 그 자체로 위치가 되는 — 이 세 가지가 포맷의 매 층에 스며 있습니다. 그래서 TOAU는 닫힌 명세가 아니라 계단입니다. 큐브가 어떻게 적히는가를 묻던 그 독자의 질문이, 이제 다른 질문 하나를 끌고 옵니다. 우리는 네 기호 가운데 셋의 자리는 다 더듬었습니다. 그런데 U는요. 값이 아닌 그 −0의 자리, 비워 둔 그 한 칸은 정말 아무것도 아닌 걸까요. 다음 장에서 저희는 두 영 — 옴(0)과 음(−0) — 으로 내려가, 그 빈자리가 어떻게 기계어의 완벽한 매칭이 되고, 끝내 구조물과 화폐와 삶에까지 같은 결로 비치는지를 함께 보겠습니다.
첫 번째 책은 선물이에요. 가입하면 100맘을 드리니까, 두 번째 책부터 자유롭게 읽을 수 있어요. 친구를 추천하면 50맘도 생겨요.