C 컴파일러 개발 (기본편)
HandyPost는 한 도영(HDNua)이 작성하는 포스트 문서입니다.
소스:
문서:
1. 개요
이 문서는 C언어의 컴파일러를 개발하기 위한 기본을 설명하는 문서다. 이 문서를 읽으려면 다음의 조건은 최소한 갖춰져야 한다.
- C/C++ 프로그래밍 언어의 사용에 능숙하다.
- (1장) 1장에서 소개하고 자주 활용하는 StringBuffer 클래스에 대해 이해해야 한다.
- (2장) C 프로그래밍 언어에서 식별자의 정의가 어떻게 이루어지는지 알고 있어야 한다.
- (3장) 컴파일러와 인터프리터의 차이를 이해하고 있어야 한다.
- (4장, 5장) 어셈블리 프로그래밍 언어를 이해하고 활용할 수 있어야 한다.
- (6장) JavaScript 프로그래밍 언어를 이해할 수 있어야 한다.
- (7장) Brackets 도구, node.js/node-webkit의 설치 방법을 알고 설치해야 한다.
다음은 이 문서에서 설명하는 모든 내용을 온전하게 이해하기 위해 필요한 내용이다.
- (8장, 9장) 가상 머신에 니모닉의 행동을 추가하거나, 실행된 프로그램을 분석할 수 있어야 한다.
- (10장) 링커가 HASM 파일을 Low Level Assembly로 어떻게 변환하는지 알고 있어야 한다.
이들이 권장사항인 것은, 이미 컴파일러가 어떤 도구인지를 알고 있는 사람은 Runner나 Linker를 거치지 않고 원하는 대로 컴파일을 하는 방법만 참고하면 되기 때문이다(사실 이게 필요 없는 사람이 이런 문서를 읽고 있진 않겠지만).
컴파일러 개발은 무척 흥미롭다. 이 문서를 처음부터 끝까지 읽은 사람이 있다면 자신 있게 말할 수 있다. 컴파일러 개발은 이전까지 설명한 모든 내용보다 훨씬 재미있다.
2. 준비
컴파일러는 일단 낮은 부분부터 개발을 시작하여, 점차 기능을 확장해가는 식으로 작성한다. 개발이 어떤 식으로 진행되는지를 알려면, 간단한 예제를 통해 실습을 하는 것이 가장 좋다. 따라서 우리는 다음의 코드를 먼저 분석한다.
main.c |
main() { int sum; int a, b; a = 10; b = 20; sum = a + b; } |
이 코드를 성공적으로 분석한다면 우리는 컴파일러 개발을 위한 준비 운동을 마치는 셈이다.
2.1) 가정
한국어를 영어로 번역하려면, 번역하는 사람은 두 언어를 모두 알고 있어야 한다. 우리의 목표는 C 프로그래밍 언어로 작성된 코드를 Low Level Assembly로 변환하는 것이니, 우리는 두 언어를 모두 알고 있어야 한다. 또한, 우리가 원하는 대로 코드가 번역되었는지 결과물을 검증할 수도 있어야 한다. 다시 말해 우리는 위의 C 코드를 Low Level Assembly로 번역했을 때의 결과를 예상할 수 있어야 한다. 따라서 위 코드가 어떻게 어셈블리 언어로 번역되는지를 알아야 이야기를 진행할 수 있다.
상황을 최대한 단순하게 만들기 위해 다음을 가정하자.
- 함수는 main만 존재한다.
- 자료형은 int만 존재한다. sizeof(int)는 4로 가정한다.
- 포인터, 배열, 함수와 같은 확장 형식과 사용자 정의 자료형은 모두 무시한다.
- 연산자는 덧셈 연산자(+)와 대입 연산자(=)만 존재한다.
- 조건문과 반복문 키워드는 존재하지 않는다. 프로그램은 선언과 식만 분석할 수 있다.
한 마디로 이 코드를 읽기 위한 최소한의 조건만 정해놓자는 것이다. 이 가정을 토대로 이야기를 진행해보자.
2.2) 기초 변환
4장 3.7절에서 지역 변수를 생성하는 방법에 대해 다루었다. 바로 그 지식을 여기서 활용한다. main 함수가 호출되어 메모리는 다음 상태가 되었다고 하자.
변수를 선언하는 것은 메모리를 확보하는 것과 같다. 다시 말해 “int sum;”이라는 선언 문장은, 메모리 관점에선 그냥 4바이트를 확보하는 것과 같다.
그리고 뒤따라오는 변수 선언에 대해서도 이는 다르지 않다.
중요한 사실이다. 우리가 변수의 이름을 무엇으로 정하건, 기계어로 변환한 후에는 이름은 몽땅 사라진다. 변수를 사용할 때는 ebp 값을 기준으로 오프셋을 계산하여 메모리를 수정하는 것이다. 다음 코드를 통해 확인해보자. “a = 10;”이라는 문장을 수행하면 메모리는 이렇게 바뀔 것이다.
그런데 이는 다음을 수행하는 것과 정확하게 같다.
즉, 선언한 변수 sum, a, b 모두에 대해 오프셋으로 그 위치를 계산하여 접근할 수 있다.
이제 남은 문장은 sum인데, 어셈블리 언어의 특성 상 이 문장은 한 번에 수행할 수 없다.
그래서 “a + b”를 먼저 분석한 다음, 이 값을 sum에 대입하는 식으로 문장을 분리한다.
따라서 다음과 같이 코드를 변환하면 성공적이라고 볼 수 있다.
main.c | main.hdo |
main() // 함수 정의의 시작입니다. {
// 변수 sum을 선언합니다. int sum;
// 변수 a, b를 선언합니다. int a, b;
// a 변수에 10을 대입합니다. a = 10; // b 변수에 20을 대입합니다. b = 20;
// 덧셈 연산을 수행합니다. sum = a + b;
// 함수 정의의 끝입니다. } | .code global _main _main: ; 프로시저 시작 시에 스택을 확보합니다. push ebp mov ebp, esp
; 4바이트 메모리를 확보합니다. sub esp, 4
; 8바이트 메모리를 확보합니다. sub esp, 8
; [ebp-8] 메모리에 10을 대입합니다. mov [ebp-8], 10 ; [ebp-12] 메모리에 20을 대입합니다. mov [ebp-12], 20
; 덧셈 연산을 수행합니다. mov eax, [ebp-8] add eax, [ebp-12] mov [ebp-4], eax
; 프로시저 종료 시의 루틴입니다. mov esp, ebp pop ebp ret
; 프로그램의 시작 지점은 _main입니다. end _main |
그럼 이제 이 코드가 올바른지 검증하는 방법을 알아보자.
2.3) 검증
main.hdo 파일을 위와 같이 수정한 다음, main.html 파일을 다음과 같이 수정한다.
main.html <html.head.script> (main) |
function main() { Program.Linker.load('main.hdo'); Program.Linker.link('program.hdx'); Program.Runner.load('program.hdx'); Program.Runner.run(); Machine.Memory.show(); } |
그런데 프로그램을 바로 실행하면 이런 결과를 얻는다.
실행 결과 (log) |
Linker.load: 14: [mov [ebp-8], 10] syntax error; right operand must be after comma(,) Linker.load: 16: [mov [ebp-12], 20] syntax error; right operand must be after comma(,) Linker.load: 21: [mov [ebp-4], eax] syntax error; right operand must be after comma(,) |
오른쪽 연산자를 붙일 때 컴마 연산자가 없다는 메시지인데, 문장을 보면 잘 되어있는 것 같다. 그런데도 어째서 이런 오류가 발생했을까?
일단 Linker 모듈의 load 메서드에서 발생한 오류이니 이 메서드를 조사하자. 우리는 분명 왼쪽 피연산자와 오른쪽 피연산자를 잘 넣은 것 같은데, 결과가 그렇지 않다. 그러면 left와 right에 값이 제대로 들어갔는지를 확인해야 한다. code 세그먼트의 문장을 분석하면서 발생한 오류임이 분명하므로, 해당 영역에서 left와 right에 값을 대입하는 문장을 찾아보자. 그러면 결국 획득한 행을 분석하는 decode 메서드가 이 일을 수행하고 있음을 알 수 있다. decode의 결과인 info를 출력해보자.
ProgramLinker.js (load.codeseg.decode) |
... // 논리가 복잡해졌으므로 획득한 행을 분석합니다. var info = decode(line); log('decode.mne/left/right: [%s/%s/%s]', info.mnemonic, info.left, info.right); ... |
실행 결과 (log) |
decode.mne/left/right: [push/ebp/undefined] decode.mne/left/right: [mov/ebp/esp] decode.mne/left/right: [sub/esp/4] decode.mne/left/right: [sub/esp/8] Linker.load: 14: [mov [ebp-8], 10] syntax error; right operand must be after comma(,) Linker.load: 16: [mov [ebp-12], 20] syntax error; right operand must be after comma(,) decode.mne/left/right: [mov/eax/[ebp-8]] decode.mne/left/right: [add/eax/[ebp-12]] Linker.load: 21: [mov [ebp-4], eax] syntax error; right operand must be after comma(,) decode.mne/left/right: [mov/esp/ebp] decode.mne/left/right: [pop/ebp/undefined] decode.mne/left/right: [ret/undefined/undefined] |
결과를 보면, 문제가 발생하는 행에서 decode 이후의 log 메서드가 아예 출력되지 않았음을 알 수 있다. 그러면 결국 예외가 decode 메서드의 내부에서 발생했다는 이야기가 된다. decode의 내부로 이동하자. 여기서 LinkerException 예외를 던지는 부분을 찾고 그 위치에 방금 작성한 출력 메서드를 옮긴다.
ProgramLinker.js (decode) |
... if (right != ',') { // 반점이 아니라면 HASM 문법 위반입니다. log('decode.mne/left/right: [%s/%s/%s]', mne, left, right); throw new LinkerException ('syntax error; right operand must be after comma(,)'); } ... |
그리고 이를 실행하면 다음 결과를 얻는다.
실행 결과 (log) |
decode.mne/left/right: [mov/[ebp-8]/]] Linker.load: 14: [mov [ebp-8], 10] syntax error; right operand must be after comma(,) decode.mne/left/right: [mov/[ebp-12]/]] Linker.load: 16: [mov [ebp-12], 20] syntax error; right operand must be after comma(,) decode.mne/left/right: [mov/[ebp-4]/]] Linker.load: 21: [mov [ebp-4], eax] syntax error; right operand must be after comma(,) |
여기서 decode 메서드가 잘못된 결과를 내놓았음을 알 수 있다. 이건 무엇이 문제일까? right에 반점이 들어가야 함에도 불구하고, 어째서 닫는 대괄호가 들어갔는가?
decode는 left와 right를 획득하기 위해 buffer.get_token 메서드를 사용한다. 그렇다면 문제는 여기에 있을 것이다. get_token 메서드의 정의로 이동한다. 메모리 토큰을 획득하는 과정이 문제이므로 이 코드를 유심히 보면, 다음과 같이 코드를 수정해야 함을 알 수 있다.
StringBuffer.js (get_token.get_memory_string) |
... else if (ch == '[') { // 메모리의 시작이라면 result = ''; // 반환할 수 있도록 문자열을 초기화합니다. this.getc(); // 이미 획득한 토큰이므로 넘어갑니다. while (this.is_empty() == false) { // 버퍼가 비어있는 동안 if (this.peekc() == ']') // 닫는 대괄호가 나타났다면 탈출합니다. break; result += this.getc(); // 문자를 추가합니다. } this.getc(); // 추가된 문장: 마지막 닫는 대괄호는 사용했습니다. result = '[' + result + ']'; } ... |
왜냐하면 마지막 닫는 대괄호는 메모리의 끝임을 알리는 용도로 이미 사용했기 때문이다. 사용한 코드는 다음에 토큰을 분석할 때 무시하고 넘어가야 한다. 이 사실은 굉장히 중요하다. 이제 프로그램을 다시 실행하면 다음 결과를 얻는다.
실행 결과 (expression) |
call 0x0004 push ebp mov ebp,esp sub esp,4 sub esp,8 mov [ebp-8],10 mov [ebp-12],20 mov eax,[ebp-8] add eax,[ebp-12] mov [ebp-4],eax mov esp,ebp pop ebp ret |
실행 결과 (expression) |
eax = 00000000, ebx = 00000000, ecx = 00000000, edx = 00000000 ebp = 0400, esp = 03fc, eip = 000000a1, eflags = 00000000 eax = 00000000, ebx = 00000000, ecx = 00000000, edx = 00000000 ebp = 0400, esp = 03f8, eip = 0000000d, eflags = 00000000 eax = 00000000, ebx = 00000000, ecx = 00000000, edx = 00000000 ebp = 03f8, esp = 03f8, eip = 00000019, eflags = 00000000 eax = 00000000, ebx = 00000000, ecx = 00000000, edx = 00000000 ebp = 03f8, esp = 03f4, eip = 00000023, eflags = 00000000 eax = 00000000, ebx = 00000000, ecx = 00000000, edx = 00000000 ebp = 03f8, esp = 03ec, eip = 0000002d, eflags = 00000000 eax = 00000000, ebx = 00000000, ecx = 00000000, edx = 00000000 ebp = 03f8, esp = 03ec, eip = 0000003c, eflags = 00000000 eax = 00000000, ebx = 00000000, ecx = 00000000, edx = 00000000 ebp = 03f8, esp = 03ec, eip = 0000004c, eflags = 00000000 eax = 00000000, ebx = 00000000, ecx = 00000000, edx = 00000000 ebp = 03f8, esp = 03ec, eip = 0000005c, eflags = 00000000 eax = 00000NaN, ebx = 00000000, ecx = 00000000, edx = 00000000 ebp = 03f8, esp = 03ec, eip = 0000006d, eflags = 00000000 eax = 00000NaN, ebx = 00000000, ecx = 00000000, edx = 00000000 ebp = 03f8, esp = 03ec, eip = 0000007d, eflags = 00000000 eax = 00000NaN, ebx = 00000000, ecx = 00000000, edx = 00000000 ebp = 03f8, esp = 03f8, eip = 00000089, eflags = 00000000 eax = 00000NaN, ebx = 00000000, ecx = 00000000, edx = 00000000 ebp = 0400, esp = 03fc, eip = 00000091, eflags = 00000000 eax = 00000NaN, ebx = 00000000, ecx = 00000000, edx = 00000000 ebp = 0400, esp = 0400, eip = 000000a1, eflags = 00000000 |
이는 일견 잘 실행된 것처럼 보인다. 실제로 expression은 문제가 없다. 하지만 register에는 여전히 문제가 있다. 일정 코드 이하부터 eax 레지스터가 NaN이 되어있는 것이다. 이번엔 또 뭘까?
일단 eax 레지스터의 값이 최초로 바뀌는 지점은 mov eax, [ebp-8] 문장을 실행한 경우다. 그러면 mov 니모닉을 구현한 Operate 모듈에서 문제가 발생했을 수 있다. 파일을 열어보자. 무엇이 문제인지 알겠는가?
MachineOperation.js (mov) |
function mov(left, right) { var Register = Machine.Processor.Register; var Memory = Machine.Memory;
if (Register.is_register(right) == true) { // 레지스터라면 // 해당 레지스터의 값을 대입합니다. Register.set(left, Register.get(right)); } else if (Memory.is_memory(right)) { // 메모리라면 // 메모리의 값을 대입합니다. Register.set(left, Memory.get_memory_value(right)); } else { // 레지스터가 아니라면 // 정수로 간주하고, 정수로 변환하여 대입합니다. Register.set(left, parseInt(right)); } } |
문제는 왼쪽 피연산자를 모두 레지스터라고 간주한 데에 있다. 어셈블리에서는 왼쪽 피연산자가 레지스터가 아니라 메모리일 수도 있는데, mov 니모닉에는 이것이 반영되지 않은 것이다. 그래서 모든 왼쪽 피연산자를 레지스터로 간주해서 오류가 발생한 것이라고 할 수 있다.
일단 지금 코드에서 사용하고 있는 mov 니모닉과 add 니모닉에 대해서만 수정해보자. 이를 위해 필자는 다음의 메서드를 제안한다.
MachineOperation.js (get_value, set_value) |
/** 근원지로부터 값을 가져옵니다. @param {string} src @return {number} */ function get_value(src) { var Register = Machine.Processor.Register; var Memory = Machine.Memory;
var value = null; if (Register.is_register(src)) { // 레지스터라면 value = Register.get(src); // 레지스터의 값을 획득합니다. } else if (Memory.is_memory(src)) { // 메모리라면 value = Memory.get_memory_value(src); // 메모리의 값을 획득합니다. } else { // 레지스터가 아니라면 정수로 간주하고, 정수로 변환한 값을 획득합니다. value = parseInt(src); } return value; // 획득한 값을 반환합니다. } /** 목적지에 맞게 값을 설정합니다. @param {string} dst @param {number} src */ function set_value(dst, src) { var Register = Machine.Processor.Register; var Memory = Machine.Memory;
if (Register.is_register(dst)) { // 목적지가 레지스터라면 Register.set(dst, src); // 레지스터에 값을 기록합니다. } else if (Memory.is_memory(dst)) { // 목적지가 메모리라면 Memory.set_memory_value(dst, src); // 메모리에 값을 기록합니다. } else { // 그 외의 경우 예외 처리합니다. throw new ProcessorException('invalid left value'); } } |
그리고 이전에 정의하지 않은 set_memory_value를 Memory 모듈에 정의하자.
MachineMemory.js (set_memory_value) |
... /** 메모리에 4바이트 값을 기록합니다. @param {string} dst @param {number} src */ function set_memory_value(dst, src) { var Register = Machine.Processor.Register; var addr; // 값을 설정할 메모리의 주소 값입니다.
// 대괄호를 제외한 문자열을 획득하고, 이를 이용해 버퍼를 생성합니다. var buffer = new StringBuffer(dst.slice(1, dst.length-1));
// 가장 앞은 언제나 레지스터입니다. var reg = buffer.get_token();
// 획득한 레지스터가 보관하고 있는 값을 획득합니다. var regval = Register.get(reg);
// 다음 토큰을 획득합니다. var op = buffer.get_token(); if (op == null) { // 다음 토큰이 존재하지 않는다면 [reg] 형식입니다. addr = regval; } else if (op == '+') { var imm = parseInt(buffer.get_token()); addr = regval + imm; } else if (op == '-') { var imm = parseInt(buffer.get_token()); addr = regval - imm; } else throw new MemoryException("invalid memory token");
// 획득한 위치에 4바이트 값을 설정합니다. return Machine.Memory.set_dword(src, addr); } ... memory.set_memory_value = set_memory_value; ... |
이를 이용하여 mov와 add, sub를 수정하면 다음과 같다.
MachineOperation.js (mov, add, sub) |
function mov(left, right) { // right의 값을 획득하고 left에 기록합니다. set_value(left, get_value(right)); } function add(left, right) { // left의 값을 두 값의 합으로 갱신합니다. set_value(left, get_value(left) + get_value(right)); } function sub(left, right) { // left의 값을 두 값의 차로 갱신합니다. set_value(left, get_value(left) - get_value(right)); } |
이를 바탕으로 프로그램을 실행하여 다음 결과를 얻는다.
실행 결과 (register) |
eax = 00000000, ebx = 00000000, ecx = 00000000, edx = 00000000 ebp = 0400, esp = 03fc, eip = 000000a1, eflags = 00000000 eax = 00000000, ebx = 00000000, ecx = 00000000, edx = 00000000 ebp = 0400, esp = 03f8, eip = 0000000d, eflags = 00000000 eax = 00000000, ebx = 00000000, ecx = 00000000, edx = 00000000 ebp = 03f8, esp = 03f8, eip = 00000019, eflags = 00000000 eax = 00000000, ebx = 00000000, ecx = 00000000, edx = 00000000 ebp = 03f8, esp = 03f4, eip = 00000023, eflags = 00000000 eax = 00000000, ebx = 00000000, ecx = 00000000, edx = 00000000 ebp = 03f8, esp = 03ec, eip = 0000002d, eflags = 00000000 eax = 00000000, ebx = 00000000, ecx = 00000000, edx = 00000000 ebp = 03f8, esp = 03ec, eip = 0000003c, eflags = 00000000 eax = 00000000, ebx = 00000000, ecx = 00000000, edx = 00000000 ebp = 03f8, esp = 03ec, eip = 0000004c, eflags = 00000000 eax = 0000000a, ebx = 00000000, ecx = 00000000, edx = 00000000 ebp = 03f8, esp = 03ec, eip = 0000005c, eflags = 00000000 eax = 0000001e, ebx = 00000000, ecx = 00000000, edx = 00000000 ebp = 03f8, esp = 03ec, eip = 0000006d, eflags = 00000000 eax = 0000001e, ebx = 00000000, ecx = 00000000, edx = 00000000 ebp = 03f8, esp = 03ec, eip = 0000007d, eflags = 00000000 eax = 0000001e, ebx = 00000000, ecx = 00000000, edx = 00000000 ebp = 03f8, esp = 03f8, eip = 00000089, eflags = 00000000 eax = 0000001e, ebx = 00000000, ecx = 00000000, edx = 00000000 ebp = 0400, esp = 03fc, eip = 00000091, eflags = 00000000 eax = 0000001e, ebx = 00000000, ecx = 00000000, edx = 00000000 ebp = 0400, esp = 0400, eip = 000000a1, eflags = 00000000 |
실행 결과 (memory) |
이와 같이 프로그램이 잘 실행되는 것을 확인할 수 있다.
3. 컴파일 시도
이제 우리가 C 코드를 어떻게 변환해야 하는지 알았으니, 한 번 컴파일을 시도해보자.
3.1) 모듈 준비
컴파일러를 의미하는 Compiler 모듈을 준비한다.
ProgramCompiler.js |
/** C 프로그래밍 언어 컴파일러입니다. */ function initProgramCompiler(program) { var compiler = {};
// Compiler 모듈에 대한 예외 형식을 정의합니다. function CompilerException(msg, data) { ... }
/* ... */
program.Compiler = compiler; } |
컴파일러는 컴파일을 하는 모듈이므로, compile 메서드를 다음과 같이 추가한다.
ProgramCompiler.js (compile) |
/** C언어로 작성된 파일을 목적 파일로 변환합니다. @param {string} filename */ function compile(filename) { // 파일 텍스트를 가져옵니다. 실패하면 예외 처리합니다. var filedata = HandyFileSystem.load(filename); if (filedata == null) throw new CompilerException('cannot open file ', filename);
// 가져온 텍스트를 바탕으로 새 StringBuffer 인스턴스를 생성합니다. var buffer = new StringBuffer(String(filedata));
// 컴파일이 성공했음을 알립니다. log('Compiler: compile complete'); return 0; // 반환하는 값이 0이 아니면 프로그램을 중단합니다. } |
compile 메서드는 어찌되었든 C언어로 작성된 파일을 열어서 분석을 할 것이다. 그래서 파일로부터 문자열을 얻어서 이를 이용해 StringBuffer를 생성하는 구문이 포함된다. 마지막엔 컴파일이 성공했다는 문장을 로그 스트림에 출력하는 것으로 compile 메서드를 끝낸다.
Program.js 파일을 열고 다음과 같이 컴파일러를 초기화하는 코드를 넣는다.
Program.js |
/** JSCC 및 그 테스트 모듈을 관리하는 Program을 생성합니다. */ function initProgram() { var program = {}; initProgramStream(program); initProgramRunner(program); initProgramLinker(program); initProgramCompiler(program); // 등록 window.Program = program; } |
마지막으로 main.html 모듈을 다음과 같이 수정하자.
main.html <html.head> (ProgramCompiler) |
<!-- 컴파일러를 위한 모듈 목록입니다. --> <script src="Machine.js"></script> <script src="MachineSystem.js"></script> <script src="MachineMemory.js"></script> <script src="MachineProcessor.js"></script> <script src="MachineOperation.js"></script> <script src="Program.js"></script> <script src="ProgramStream.js"></script> <script src="ProgramRunner.js"></script> <script src="ProgramLinker.js"></script> <script src="ProgramCompiler.js"></script> ... |
main.html <html.head.script> (main) |
function main() { if (Program.Compiler.compile('main.c') != 0) return; Program.Linker.load('main.hdo'); Program.Linker.link('program.hdx'); Program.Runner.load('program.hdx'); Program.Runner.run(); Machine.Memory.show(); } |
Linker 모듈의 link 메서드 마지막에 있는 test() 코드를 제거하고 실행하면 결과는 다음과 같다.
실행 결과 (log) |
Compiler: compile complete link complete load complete run complete |
이 정도면 분석을 위한 준비는 끝마친 셈이다.
3.2) get_ctoken
StringBuffer 형식에는 get_operator라는, 정말 쓸모없어 보이는 메서드가 정의되어있다.
StringBuffer.js (get_operator) |
/** 버퍼로부터 C 연산자를 획득합니다. @return {string} */ StringBuffer.prototype.get_operator = function() { this.trim(); if (this.is_empty()) return null; var ch = this.getc(); // 현재 문자를 획득하고 포인터를 이동한다 var op = ''; switch (ch) { case '+': op = ch; break; case '-': op = ch; break; case '*': op = ch; break; case '/': op = ch; break; default: op = ch; } return op; } |
어찌되었건 이 메서드는 문자 하나만 획득하고 만다. 이걸 쓰느니 차라리 getc를 쓰는 것이 성능에도 도움이 되는 것이다. 도대체 이런 메서드를 왜 넣었을까?
이 메서드의 설명을 잘 보면, 이것이 C언어의 연산자를 획득하기 위한 것이라는 사실을 알 수 있다. 즉, 이 메서드는 C 컴파일러를 구현하기 전에는 아무데도 쓸 필요가 없다. 하지만 이제 우리는 컴파일러를 개발하고 있다. 따라서 이 메서드를 우리 입맛에 맞게 수정하여 사용하면 되는 것이다.
그리고 여기서 StringBuffer에, get_token 메서드를 사용하지 않으면서, C 프로그래밍 언어의 토큰을 획득하는 get_ctoken 메서드를 새롭게 작성한다. 그리고 get_token에서 get_operator를 사용하는 부분을 getc로 대체하고, get_ctoken에서 이를 활용하는 식으로 작성할 것이다. 두 개의 코드가 분리되어야 하는 이유는 단순하다. C와 어셈블리는 서로 다른 언어이기 때문이다. 설명하지 않았지만, 사실 어셈블리에서는 점(.) 조차도 식별자의 일부로 인정이 된다. 또한 C에서는 작은따옴표는 문자, 큰따옴표는 문자열이지만 어셈블리에서는 둘 다 문자열을 의미한다. 주석 기호가 다른 것은 두 말 할 것이 없다. 이렇듯 둘은 식별자를 구하는 기준, 연산자를 구하는 기준이 서로 완전히 같지 않고, 따라서 둘을 동시에 사용하는 것보다 용도를 나누는 것이 가독성에도, 유지 및 보수에도 도움이 된다.
StringBuffer.js 파일을 열고 get_token 메서드에서 get_operator 메서드를 getc 메서드로 대체한다.
StringBuffer.js (get_token.else) |
... else { // 아니라면 일단 획득합니다. result = this.getc(); // this.get_operator(); } ... |
그리고 get_token을 복사하여, C 프로그래밍 언어의 토큰을 획득하는 get_ctoken 메서드를 작성한다.
/** 버퍼로부터 C 토큰을 획득합니다. @return {string} */ StringBuffer.prototype.get_ctoken = function() { ... result = this.get_operator(); // 일단 연산자를 획득하는 부분만 변경합니다. ... } |
그리고 get_operator의 용도를 이에 맞게 수정하면 다음과 같이 된다.
/** 버퍼로부터 C 연산자를 획득합니다. @return {string} */ StringBuffer.prototype.get_operator = function() { this.trim(); if (this.is_empty()) return null; var ch = this.getc(); // 현재 문자를 획득하고 포인터를 이동합니다. var op = null; switch (ch) { // 획득한 문자에 따라 분기합니다. case '+': var nextCh = this.peekc(); switch (nextCh) { case '+': this.getc(); op = '++'; break; case '=': this.getc(); op = '+='; break; default: op = '+'; break; } break; case '-': ... default: op = ch; } return op; } |
이로써 get_operator는 길이가 2 이상인 연산자를 한 번에 획득하는 데 유용한 메서드가 되었다. 그런데 한 번 생각해보자. get_ctoken, get_operator는 StringBuffer의 메서드지만, 이들을 사용하는 모듈은 Compiler가 유일하다. 그것뿐만이 아니다. get_ctoken에서 문자열을 획득하는 것이 실패한 경우의 예외 형식은 StringBufferException도 가능하지만, CompilerException으로도 볼 수 있으며 이 쪽이 타당하다. 결국 get_ctoken과 get_operator의 정의는 StringBuffer보다는 Compiler가 어울린다는 것이다. 그러니 이들의 정의를 initProgramCompiler로 옮기자.
ProgramCompiler.js (StringBufferExtension) |
... StringBuffer.prototype.get_operator = function() { ... } StringBuffer.prototype.get_ctoken = function() { ... } ... |
is_space 메서드에 존재하는 오류를 하나 해결한다.
common.js (is_space) |
function is_space(ch) { return (ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n'); // '\r' 추가 } |
그리고 테스트를 위해 획득한 C 코드의 토큰을 나열하도록 compile 메서드를 작성한다.
ProgramCompiler.js (compile) |
function compile(filename) { ... // 획득한 토큰을 출력합니다. while (buffer.is_empty() == false) { var token = buffer.get_ctoken(); log('token: [%s]', token); } ... return 1; // 반환하는 값이 0이 아니면 프로그램을 중단합니다. } |
그러면 프로그램을 실행하여 다음 결과를 얻는다.
실행 결과 (log) |
token: [main] token: [(] token: [)] token: [{] token: [int] token: [sum] token: [;] token: [int] token: [a] token: [,] token: [b] token: [;] token: [a] token: [=] token: [10] token: [;] token: [b] token: [=] token: [20] token: [;] token: [sum] token: [=] token: [a] token: [+] token: [b] token: [;] token: [}] compile complete |
모든 토큰을 성공적으로 분리해냈다. 이로써 get_ctoken 메서드의 작성은 완료가 된다.
3.3) 분석 1: 함수 외부 분석
우리가 분석해야 할 코드를 다시 한 번 보자.
main.c | main.hdo |
main() ... | .code global _main _main: ... |
여기서 우리는, 식별자와 소괄호가 나타나면 그것을 전역 함수로 간주해야 함을 알 수 있다. 일단 최대한 단순하게, 식별자 다음 소괄호가 나타나면 오른쪽과 같이 변환하여, 이를 코드 세그먼트에 기록한다고 생각하자. 여기서 우리는 데이터 세그먼트와 코드 세그먼트 각각에 대한 문자열 스트림이 필요하다는 사실을 알 수 있다.
바로 시작해보자. 각 세그먼트에 대한 문자열 스트림을 생성한다.
ProgramCompiler.js (compile) |
... // 세그먼트에 대한 문자열 스트림을 생성합니다. var dataseg = new Program.Stream.StringStream(); var codeseg = new Program.Stream.StringStream(); ... |
식별자 다음에 소괄호가 나타난 경우에 대해 처리해보자.
ProgramCompiler.js (compile) |
... // 획득한 토큰을 분석합니다. while (buffer.is_empty() == false) { var token = buffer.get_ctoken(); // 토큰을 획득합니다.
// 식별자가 발견되었다면 if (is_fnamch(token.charAt(0)) == true) { var identifier = token; // 토큰을 보관합니다. token = buffer.get_ctoken(); // 다음 토큰을 획득합니다.
if (token == '(') { // 여는 소괄호가 발견되었다면, var nextToken = buffer.get_ctoken(); // 다음 토큰을 획득합니다. if (nextToken != ')') { // 다음 토큰이 닫는 소괄호가 아니라면 throw new CompilerException // 예외 처리합니다. ('syntax error; missing )'); } ... } }
} ... |
토큰을 획득해본 다음, 이것이 식별자라면 다음 토큰을 본다. 이때 다음 토큰이 소괄호인지 확인하고, 소괄호가 아니라면 예외 처리하는 코드다.
그 다음은 함수 정의의 시작을 의미하는, 여는 중괄호(‘{’) 기호를 발견한 경우에 대해 처리해야 한다. 코드는 이렇게 변환될 것이다.
main.c | main.hdo |
... {
| ... push ebp mov ebp, esp |
이를 반영하면 이렇게 된다.
ProgramCompiler.js (compile) |
... var nextToken = buffer.get_ctoken(); // 다음 토큰을 획득합니다. if (nextToken != '{') { // 여는 중괄호가 아니라면 함수 정의가 아닙니다. throw new CompilerException // 일단 예외 처리합니다. ('not implemented'); } codeseg.writeln('global _%s', identifier); codeseg.writeln('_%s:', identifier); codeseg.writeln('push ebp'); codeseg.writeln('mov ebp, esp');
... |
여기서 우리가 무엇을 하는지가 나타난다. 우리는 C언어로 작성된 코드를 분석한 다음, 세그먼트에 이를 텍스트로 기록하면 된다! 마지막에 이들을 모아 목적 파일을 생성하기만 하면, 그 후부터는 Linker와 Processor의 몫이 된다.
함수의 선언과 정의를 분석하기에 앞서, 함수 정의를 닫아보자. 닫는 중괄호가 발견되었을 때의 처리를 작성해보자는 것이다. 코드는 이렇게 변환될 것이다.
main.c | main.hdo |
... } | ... mov esp, ebp pop ebp ret |
따라서 이를 반영하여 코드를 작성한다.
ProgramCompiler.js (compile) |
... codeseg.writeln('mov ebp, esp');
/* 함수 내부의 처리 */
var nextToken = buffer.get_ctoken(); // 다음 토큰을 획득합니다. if (nextToken != '}') { // 닫는 중괄호가 아니라면 함수 정의 종료가 아닙니다. throw new CompilerException // 일단 예외 처리합니다. ('not implemented'); } codeseg.writeln('mov esp, ebp'); codeseg.writeln('pop ebp'); codeseg.writeln('ret'); ... |
마지막으로 우리가 변환한 정보를 파일에 출력하는 코드를 작성하자.
ProgramCompiler.js (compile) |
... // 컴파일한 결과를 파일에 출력합니다. var outputName = Handy.format ('%s.hdo', filename.substr(0, filename.length-2)); var output = new Program.Stream.StringStream(); output.writeln('.data'); // 데이터 세그먼트의 시작을 알립니다. output.writeln(dataseg.str); // 데이터 세그먼트의 정보를 기록합니다. output.writeln('.code'); // 코드 세그먼트의 시작을 알립니다. output.writeln(codeseg.str); // 코드 세그먼트의 정보를 기록합니다. output.write('end _main'); // _main 프로시저에서 시작함을 알립니다. HandyFileSystem.save(outputName, output.str); // 파일에 출력합니다. ... // 컴파일이 성공했음을 알립니다. log('compile complete'); return 1; // 반환하는 값이 0이 아니면 프로그램을 중단합니다. |
그러면 프로그램을 실행했을 때 다음과 같은 코드를,
main.c |
main() {} |
이렇게 변환할 수 있다.
main.hdo |
.data
.code global _main _main: push ebp mov ebp, esp mov esp, ebp pop ebp ret
end _main |
결과는 성공적이다.
3.4) 분석 2: 함수 내부 분석
이 과정에서 이상함을 느꼈더라도 일단 진행을 해보자. 다음은 변수의 선언이다.
main.c | main.hdo |
... int sum; | ... sub esp, 4 |
대치하는 건 어렵지 않다. 문제는 이 코드가 어디에 있어야 하느냐다. 일단 함수 정의의 다음에 존재하는 구문이니, 대치도 함수 정의 다음에 있어야 할 것 같다.
코드를 수정하자. 변수를 선언하고 사용하려면, 변수의 오프셋은 반드시 기억하고 있어야 한다. 이를 위해 토큰 분석 반복문의 바깥에 변수 정의 목록 표 table을 하나 생성하자. 또한 생성한 지역 변수의 개수를 보관하는 loc_var_count 변수도 만든다.
ProgramCompiler.js (compile.local_var_table) |
... // 획득한 토큰을 분석합니다. var table = {}; // 지역 변수 목록 표입니다. var loc_var_count = 0; // 지역 변수의 개수입니다. while (buffer.is_empty() == false) { var token = buffer.get_ctoken(); // 토큰을 획득합니다. ... |
그리고 C 문장을 어셈블리로 대치하는 코드를 작성한다.
ProgramCompiler.js (compile.replace_c) |
... // 함수 정의 내부에 존재하는 문장을 어셈블리 코드로 대치합니다. while (buffer.is_empty() == false) { var nextToken2 = buffer.get_ctoken(); // 토큰을 획득합니다.
// 변수의 선언이라면 if (nextToken2 == 'int') { var nextToken3 = buffer.get_ctoken(); // 토큰을 획득합니다.
// 식별자라면 지역 변수의 선언으로 간주합니다. if (is_fnamch(nextToken3)) { // 지역 변수의 수가 하나 늘어납니다. ++loc_var_count; // 오프셋은 (총_지역_변수의_수 * sizeof(int))입니다. table[nextToken3] = loc_var_count * (-4); // 선언한 변수의 크기만큼 메모리를 확보합니다. codeseg.writeln('sub esp, 4'); }
nextToken3 = buffer.get_ctoken(); // 토큰을 획득합니다. if (nextToken3 != ';') { // 문장의 종료가 아니라면 throw new CompilerException // 일단 예외 처리합니다. ('cannot find end of declaration'); } }
/* ... */ } ... |
가면 갈수록 nextToken의 개수가 늘어나는 느낌이지만, 일단 무시하자. 이걸로 "int sum;" 문장은 분석을 해냈다. 그 다음에 나오는 것은 반점을 이용해 여러 개의 변수를 동시에 선언한 것인데, 이는 다음과 같이 구현이 될 것이다(아마도). 참고로 이 코드는 집중해서 보지 않았으면 한다.
ProgramCompiler.js (compile.replace_c) |
... nextToken3 = buffer.get_ctoken(); // 토큰을 획득합니다. if (nextToken3 == ',') { // 변수를 한 번에 여러 개 선언한다면, var nextToken4 = null;
// 반점이 끝날 때까지 변수를 계속 선언합니다. while (buffer.is_empty() == false) { nextToken4 = buffer.get_ctoken();
// 반점이라면 그냥 진행합니다. if (nextToken4 == ',') continue; // 다음 토큰이 식별자가 아니라면 탈출합니다. else if (is_fnamch(nextToken4) == false) break;
// 새 식별자라면 지역 변수의 선언으로 간주합니다. // 지역 변수의 수가 하나 늘어납니다. ++loc_var_count; // 오프셋은 (총_지역_변수의_수 * sizeof(int))입니다. table[nextToken4] = loc_var_count * (-4); // 선언한 변수의 크기만큼 메모리를 확보합니다. codeseg.writeln('sub esp, 4'); }
// 마지막으로 획득한 토큰이 문장 종료가 아니라면 if (nextToken4 != ';') { // 일단 예외 처리합니다. throw new CompilerException ('cannot find end of declaration'); } } else if (nextToken3 != ';') { // 문장의 종료가 아니라면 throw new CompilerException // 일단 예외 처리합니다. ('cannot find end of declaration'); } ... |
뭔가 nextToken의 개수가 계속 늘어가고, 코드가 갈수록 읽기 어려워지고 있다. buffer.is_empty로 버퍼가 비었는지 체크하는 구문도 계속 생기고, 지금 무슨 부분을 작성하고 있는지도 명확하지 않다. 같은 코드가 지나치게 중복되고 있다. 이 상태로는 도저히 진행할 수 없을 것 같다.
4. 뭔가 이상한데요?
프로그램을 잘 작성하고 있는 줄 알았는데, 막상 작성하고 보니 뭔가 엉망진창이다. 도대체 무엇이 문제일까?
이 문제는 사실 어느 한 부분의 문제가 아니다. 적절한 설계가 없이 프로그램을 작성했기 때문에 이런 이상한 결론에 도달한 것이다. 처음 컴파일러를 작성하는 입장에서는 꼭 스스로 할 수 있을 것 같은 마음에, 이 내용을 받아들이지 못할 수도 있다. 이것은 필자의 경험이다. C 프로그래밍 언어의 전체 문법을 알고 이를 이용해서 컴파일러를 작성하고 나면, 진심으로 우리가 얼마나 무모하게 이 프로젝트에 덤벼들었는지 느끼게 될 것이다. 우리가 배운 내용만을 이용하여 C언어를 추론하고 컴파일러를 작성한다는 것은 터무니없는 생각인 것이다. C 프로그래밍 언어는 배우기 쉬운 언어이지만, 그렇다고 모든 것을 이해하기 쉬운 언어는 절대 아니다.
5. 단원 마무리
마무리가 되지 않았는데 마무리라는 이름을 붙이니 껄끄럽지만, 일관성을 위한 것이라고 생각해주면 좋겠다. 이 문서의 목표는 컴파일러를 위한 기본을 쌓는 것에 있다. 사실 이 문서는 아주 기초적인 변환법을 설명하는 것도 있지만, 그보다는 설계가 없는 개발의 위험성을 알리고자 하는 것이 더 크다. 이것을 달성한 시점에서 문서를 끝내고 마무리라는 이름을 붙이는 것도 납득할 수는 있다고 생각한다.
다음 문서에서는 컴파일러 개발을 위한 C 프로그래밍 언어의 문법을 학습한다. 자신이 이미 C 프로그래밍 언어의 문법을 잘 알고 있다고 생각하더라도, 다음 문서는 반드시 읽어야 한다. 다음 문서에서 다루는 내용은 비교적 고급 문서이고, 이를 학습하지 않으면 이후의 내용을 진행할 수 없다.
'★ JSCC' 카테고리의 다른 글
[JSCC] 13. C 컴파일러 개발 (실전편) (0) | 2015.07.31 |
---|---|
[JSCC] 12. C 컴파일러 개발 (문법편) (0) | 2015.07.24 |
[JSCC] 10. 링커 개발 (0) | 2015.07.10 |
[JSCC] 9. 가상 머신 개발 2 (0) | 2015.07.03 |
[JSCC] 8. 가상 머신 개발 1 (2) | 2015.06.26 |