가상 머신 개발 2
HandyPost는 한 도영(HDNua)이 작성하는 포스트 문서입니다.
소스:
문서:
1. 개요
eip 레지스터와 eflags 레지스터에 관한 예제를 토대로 각 레지스터의 필요성을 이해한다. 데이터 세그먼트를 처리하고 가상 머신 개발을 마친다.
2. 명령 포인터(Instruction Pointer)
다음은 _main 프로시저의 마지막에 _end 레이블이 있는 HASM 소스 파일이다. 코드에는 빈 줄이 있는데, 우리가 만약 mov eax, 5 명령을 실행하지 않고 건너뛰고 싶다면 어떤 명령이 들어가야 하겠는가?
.code _main: push ebp mov ebp, esp
mov eax, 3 ____________ mov eax, 5
_end: mov esp, ebp pop ebp ret |
NASM을 공부한 기억이 난다면 다음과 같이 빈 칸을 채웠을 것이다.
jmp _end
타당한 판단이다. HASM은 필자가 구현한 것이니 여기서도 그 방식을 따른다. 그런데 이것을 구현하려면 어떻게 해야 할까?
2.1) 개념
3장에서 세그먼트의 개념에 대해 다음과 같이 설명한 바 있다.
- 코드 영역(code area): 실행할 프로그램의 기계어 명령이 올라오는 메모리 영역이다.
이 설명대로라면 코드 영역에는 실행할 프로그램의 기계어 명령이 올라온다. 어? 그렇다! 우리가 작성한 코드는 통째로 메모리, 즉 바이트 배열에 올라간다! 이를 통해 우리는 소스에서 제시된 문제를 해결하기 위한 실마리를 찾을 수 있다.
생각해보자. 작성한 코드가 메모리에 통째로 올라간다면 아마도 이런 상황이 될 것이다.
위 그림은 코드 영역의 모든 정보가 메모리에 올라간 상황을 표현한 것이다. 0번지는 사용하지 않기로 했으므로 0번지를 제외하고 코드를 표현했다. 그와 동시에, 각 명령의 크기는 언제나 서로 같다고 장담할 수 없다는 것도 표현했다. 이는 push ebp 명령이 3바이트, 다른 명령이 4바이트로 표현된 것으로 알 수 있을 것이다.
프로그램이 메모리에 올라가서 프로세스가 되어, 메모리가 다음 상태가 되었다고 하자. 이때 가장 첫 번째 명령을 가리키는 포인터를 하나 가지고 있다고 하자.
그러면 첫 번째 명령을 실행하고 나면 다음 상태가 된다.
그 다음 명령을 실행하면 이렇게 될 것이다.
같은 식으로 반복하면 된다. 다음의 jmp 구문을 보자.
jmp 구문이 실행되면 다음 상태가 된다.
재미있는 점은 이를 통해 mov eax, 5 명령을 실행하지 않고 그냥 넘어갔다는 것이다! 명령을 한 차례 더 분석하면 프로그램은 다음 상태가 될 것이다.
결과적으로 mov eax, 5가 아주 잘 무시되었다. 이때 사용하는 명령어에 대한 바이트 포인터를 명령 포인터(Instruction Pointer)라고 부른다. CPU에서는 eip 레지스터가 이 역할을 수행한다. eip라는 단어는 extended instruction pointer의 줄임말이니, 직관적이어서 기억하기 쉽다. 명령 포인터의 개념은 이 정도로 정리가 된다.
2.2) fetch, decode, execute
fetch, decode와 execute는 이전 문서에서 Runner 모듈을 작성할 때 멤버로 추가한 메서드의 이름이다. 지금 이 얘기를 왜 다시 하는 걸까? 그 이유는 우리가 명령 포인터의 존재를 이전 절을 통해 알았기 때문이다.
처음으로 돌아가보자. 프로세스가 다음과 같은 상태로 실행되었다.
그러면 먼저 메모리에 올라간 명령을 획득하는 fetch 과정이 수행된다.
그 다음 획득한 명령을 분석하여 실행 가능한 상태로 만드는 decode 작업이 수행된다.
이때는 메모리가 변하지 않는다. 마지막으로 명령을 실행한다.
이렇게 push ebp 명령 하나를 분석하는 과정이 끝이 난다.
2.3) 전략
이제 eip 레지스터의 필요성을 이해했으니, 이를 Runner 모듈에 반영해보자. 그 전에 프로그램과 프로세스가 어떻게 표현되는지를 한 번 정리하고 가는 것이 좋겠다.
프로세스가 되면 크게 네 개의 세그먼트로 구분할 수 있다고 하였다. 데이터 세그먼트, 코드 세그먼트, 스택 세그먼트, 힙 세그먼트가 그것이다. 각각의 역할에 대해서는 3장을 참조하라.
중요한 내용인데, 힙과 스택 메모리는 보통 다음과 같은 식으로 구현이 된다.
위 그림은 프로세스가 메모리에 올라갔을 때, 메모리의 상태를 그림으로 표현한 것이다. 여기서 주목해야 할 점은, 힙 메모리는 그 시작점이 왼쪽에, 스택 메모리의 시작점은 오른쪽에 있어서, 이 둘에 접근하는 포인터가 가운데로 모이도록 되어있다는 것이다. 여기서 우리가 push 니모닉을 구현할 때 esp 레지스터의 값을 뺀 이유가 나타난다. 스택에 값을 푸시하거나 지역 변수를 생성할 때는 esp 레지스터의 값을 확보하려는 영역의 크기만큼 뺀다! 지면 낭비를 막기 위해 앞으로는 색깔에 대해 별도로 그림에 표시하지 않을 것이니, 이들 색깔에 대해 잘 기억하기 바란다.
일단 힙은 지금의 수준으로는 구현할 수 없다. 스택의 경우는 사실 이미 구현이 되었다. push 니모닉을 이용해 메모리에 값을 푸시할 수 있지 않았는가! 따라서 여기서는 코드 세그먼트와 스택 세그먼트에 대해서만 우선 처리한 다음 데이터 세그먼트와 힙에 대해서도 차근차근 작업을 진행하도록 하겠다.
한 가지 더 고려해야 할 것이 있다. 코드 세그먼트에 코드를 어떻게 올릴 것인가? 컴퓨터가 처음 나왔을 시점에는 기계어밖에 없었으므로, 1000은 push, 1001은 eax 등으로 정의한 다음 10001001을 메모리에 집어넣는 식으로 프로그램을 만들었을 것이다. 하지만 이 과정은 너무나도 지루하고 실수할 확률이 지나치게 높다. 그래서 여기서는 생각할 수 있는 가장 직관적이고 단순한 방법을 채택하기로 한다.
프로그램을 종료하는 니모닉을 ret과 구별하여 exit라는 새로운 니모닉을 사용하는 것으로 하자. 다음과 같은 HASM 코드가 있다고 하면,
mov eax,0 exit |
이 코드는 메모리에 이렇게 올린다.
그냥 코드를 바이트 값에 맞춰 바이트 배열에 집어넣었다! 너무나도 단순한 방법이 아닐 수 없다. 널 문자가 각 명령을 구분하는 역할을 하니, 널 문자가 나타날 때까지 문장을 읽는 fetch 함수를 구현한 다음, fetch로 획득한 문장을 형식에 맞춰 decode하여 적당한 객체로 넘기면, execute 함수는 전혀 수정되지 않는다. 이 방식은 무식하지만, 어차피 실생활에 사용하는 컴파일러가 아니고, 기계어가 아니라 디버깅이 상대적으로 쉽다는 장점 때문에 컴파일러를 공부하는 우리의 입장에서는 아주 탁월한 선택이다. 그래도 이게 영 기계어랑 상관이 없다는 생각이 든다면, 위의 명령어 하나의 아스키 코드를 모두 구해서 일렬로 붙이면 기계어 같다는 느낌이 좀 들 것이다. 예를 들어 mov eax, 0에 대응하는 인텔의 명령 코드(opcode)는 다음과 같다.
a1 00 ; A1 = mov, 00 = 0
그걸 우리는 그냥 이렇게 표현한 것이다.
6d 6f 76 20 65 61 78 2c 30 00 ; 6d='m', 6f='o', 76='v', 20=' ', ..., 30='0', 00='\0'
명령어 길이가 길어졌으니 성능이야 당연히 무지하게 떨어진다. 이 정도면 납득할 수 있으리라 생각한다.
2.4) 반영
그럼 이제 이를 반영해보자. 부담스럽지 않게 HelloExit.hda 파일을 먼저 분석하겠다.
HelloExit.hda |
.code mov eax,0 exit |
그전에 handy.js 파일을 열고 vformat을 약간만 수정하자.
handy.js (vformat) |
... // 인자를 획득하고 타당하게 만듭니다. var param = params[pi++]; if (param == undefined) param = 'undefined';
// 가져온 문자를 기준으로 조건 분기합니다. switch (nextChar) { case 'b': // 2진수를 출력합니다. value = param.toString(2); break; case 'x': // 16진수를 출력합니다. value = param.toString(16); break; case 'd': // 정수를 출력합니다. value = param.toString(); break; case 's': // 문자열을 출력합니다. value = param; break; case 'c': // 문자 코드에 해당하는 문자를 출력합니다. // 문자열이라면 첫 번째 글자만 획득합니다. if (typeof(param) == 'string') { value = param.charAt(0); } else { // 그 외의 경우 정수로 간주합니다. value = String.fromCharCode(param); } break; case '%': // 퍼센트를 출력합니다. --pi; // 인자를 사용하지 않았으므로 되돌립니다. value = '%'; break; } ... |
인자를 타당하게 만드는 과정이 추가되었다. 만약 정상적으로 인자를 획득할 수 없으면 인자를 문자열 undefined가 들어온 것으로 간주한다.
그리고 메모리 출력도 보다 단순하게 하기 위해 다음과 같이 바꾼다.
Memory.js (show) |
... mem_str += Handy.format ('0x%04x | %-48s | %-16s \n', mem_addr, mem_byte, mem_char); ... mem_byte += Handy.format('%02x ', byte); ... |
예상 결과 |
이제 Register 모듈에 eip 레지스터를 추가하자.
Register.js |
... register.eip = 4; ... |
그런데 이전에는 파일을 읽으면서 바로 분석을 실행했지만, 여기서는 일단 모든 코드를 메모리에 올려야 하므로 run 메서드에서 파일을 불러오는 것이 이치상 맞지 않는다. 따라서 파일로부터 코드를 가져와 메모리에 올리는 load 메서드가 생긴다.
Runner.js (load) |
/** 지정한 파일을 불러와 메모리에 올립니다. @param {string} filename */ function load(filename) { // 파일로부터 텍스트를 획득합니다. var code = HandyFileSystem.load(filename); if (code == null) // 텍스트를 획득하지 못한 경우 예외를 발생합니다. throw new RunnerException('Cannot load file', filename);
// 획득한 텍스트를 줄 단위로 나눈 배열을 획득합니다. var lines = String(code).split(NEWLINE);
// 코드를 메모리에 기록하기 위해 바이트 포인터를 맞춥니다. Memory.bytePtr = 4;
// 획득한 텍스트를 메모리에 기록합니다. var segment = null; for (var i=0, len=lines.length; i<len; ++i) { try { // i번째 줄에서 양 옆 공백이 제거된 문자열을 가져옵니다. var line = lines[i].trim();
// 지시어 및 예외적 상황을 처리합니다. if (line == '') continue; // 빈 줄은 넘어갑니다. else if (line.charAt(0) == ';') continue; // 주석은 넘어갑니다. else if (line.charAt(0) == '.') { // 세그먼트를 처리합니다. // 세그먼트 정보를 보관합니다. // 이후에 나타나는 코드의 영역을 결정하기 위해 사용합니다. segment = line; continue; }
// 코드를 메모리에 올립니다. if (segment == null) // 세그먼트가 정의되지 않았다면 예외 처리합니다. throw new RunnerException("segment is null"); for (var j=0, lineLen=line.length; j<lineLen; ++j) Memory.write_byte(line.charCodeAt(j));
// 명령을 구분하기 위해 널 문자를 삽입합니다. Memory.write_byte(0);
} catch (ex) { if (ex instanceof RunnerException) { log('Runner.load: '); log(i + ': [' + lines[i] + '] ' + ex.description); } else { throw ex; } } }
log('load complete'); } |
또한 이를 바탕으로 메모리에 올라간 코드를 분석하도록 run 메서드가 바뀐다.
Runner.js (run) |
/** 분석한 프로그램을 실행합니다. */ function run() { // 코드 영역의 시작 지점으로 바이트 포인터를 옮깁니다. Memory.bytePtr = 4;
while (true) { // 메모리로부터 명령 코드를 획득합니다. var opcode = fetch(); if (opcode == 'exit') // 명령 코드가 exit이면 프로그램을 종료합니다. break;
// fetch를 통해 가져온 정보를 분석합니다. var info = decode(opcode);
// decode를 통해 분석된 정보를 바탕으로 명령을 실행합니다. execute(info);
// 식을 분석하고 실행한 결과를 스트림에 출력합니다. Runner.exp_write(opcode); Runner.reg_write('eax = %08x, ebx = %08x, ecx = %08x, edx = %08x', Register.eax, Register.ebx, Register.ecx, Register.edx); Runner.reg_write('ebp = %02x, esp = %02x', Register.ebp, Register.esp); } log('run complete'); } |
이렇게 파일을 불러오는 과정과 코드를 해석하는 과정이 나뉘면, 코드를 해석하는 입장에서 넘겨받은 코드에 문제가 없다고 가정하고 분석을 진행할 수 있기 때문에 매우 편리해진다. 이제 fetch 메서드만 수정하면 우리가 하려는 작업은 완료가 된다.
Runner.js (fetch) |
/** 메모리로부터 명령 코드를 가져와 문자열로 반환합니다. @return {string} */ function fetch() { var opcode = ''; // 획득할 명령 코드를 보관합니다. var MEMORY_END = Memory.MaxSize() - 1; // 메모리의 끝입니다.
// 메모리의 끝이 나타나기 전까지 분석합니다. while (Memory.bytePtr <= MEMORY_END) { // 현재 바이트 포인터가 가리키는 바이트를 획득합니다. var byte = Memory.read_byte(); if (byte == 0) // 널을 획득했다면 명령 코드 획득을 끝냅니다. break;
// 획득한 바이트 코드에 해당하는 문자를 덧붙입니다. opcode += String.fromCharCode(byte); }
// 획득한 명령 코드를 반환합니다. return opcode; } |
마지막으로 main 메서드에서 이들을 실행한 다음, (보기 좋게 하기 위해 요소의 크기를 바꿨다)
main.html <html.head.script> (main) |
function main() { Runner.load('HelloExit.hda'); Runner.run(); Memory.show(); } ... |
그리고 프로그램을 실행하여 다음 결과를 얻는다.
실행 결과 (Memory) |
메모리의 경우는 더할 나위 없이 깔끔하게 출력이 되었다.
실행 결과 (expression, log) |
식 스트림과 로그 스트림도 정상적으로 값이 잘 출력되었는데,
실행 결과 (register) |
레지스터 스트림만 그렇게 마음에 들지 않는 결과가 나왔다. eax에 undefined가 들어간 것이 보이는가? mov eax, 0 명령을 실행했는데 왜 이런 값이 들어갔을까?
2.5) 피연산자 구별하기
그 답은 execute 메서드에서 찾을 수 있다. execute 메서드에서 mov를 어떻게 구현했는지 보자.
Runner.js (execute.mov) |
... else if (mne == 'mov') { // mov 니모닉에 대한 처리 // right의 레지스터 값을 left에 대입합니다. Register[left] = Register[right]; } ... |
문제는 right에 레지스터가 아닌 상수 값이 들어갔는데 이를 레지스터처럼 처리한 것에 있다. 즉 우리는 피연산자가 레지스터인지, 상수인지를 코드 상에서 서로 구별할 수 있어야 한다. 나중에는 피연산자가 메모리인지도 판정해야 하는데 이건 후에 다루기로 하자.
이를 위한 방법 중의 하나는, 주어진 인자를 정수로 변환할 수 있는지를 따져서, 변환할 수 없다면 문자열이므로 레지스터, 변환할 수 있다면 정수로 간주하는 것이다. 예를 들어 다음의 문장이 실행되었다고 하자.
mov eax, 5
그러면 execute 내부에서 각각의 객체는 다음과 같다.
mne = 'mov' (string), left = 'eax' (string), right = '5' (string)
결국 left와 right가 모두 문자열이다. 먼저 left를 정수로 바꿀 수 있는지 확인한다. JavaScript에는 문자열을 정수로 바꿀 수 있다면 값을 바꿔서 돌려주는 parseInt라는 메서드가 제공된다.
var value = parseInt(left); // parseInt('eax')를 실행. 숫자가 아니면 NaN이라는 값을 반환
그리고 'eax'는 숫자가 아니므로 NaN이라는 괴상한 것이 반환된다. NaN은 Not a Number의 줄임말이다. 따라서 이때 value는 NaN과 같으므로, 조건문에 다음과 같이 작성함으로써 정수가 아니라고 판정할 수 있다.
if (isNaN(value) == true) alert('register'); // 정수로 변환할 수 없으므로 레지스터!
이를 반영하여보자.
Runner.js (execute.mov) |
... else if (mne == 'mov') { // mov 니모닉에 대한 처리 // right를 정수로 변환해봅니다. var value = parseInt(right);
if (isNaN(value) == true) { // 정수가 아니라면 // 레지스터로 간주하고 대입합니다. Register[left] = Register[right]; } else { // 정수라면 // 정수를 바로 대입합니다. Register[left] = value; } } ... |
그러면 비로소 실행 결과가 잘 나오는 것을 확인할 수 있다.
실행 결과 |
eax = 00000000, ebx = 00000000, ecx = 00000000, edx = 00000000 ebp = 64, esp = 64 |
그런데 피연산자를 구분하는 더 괜찮은 방법은, isNaN같은 사이드 이펙트를 이용하는 것이 아니라, 정말 인자가 레지스터인지 판정하는 is_register와 같은 메서드를 만드는 것이다. 일단 위의 경우는 후에 다룰 메모리에 대해서는 대응하지 못한다. 이 코드를 is_register를 만들어서 바꿔보자.
Register.js 파일을 열고 다음을 추가한다.
Register.js (is_register) |
... // 메서드를 정의합니다. /** 레지스터라면 true를, 아니라면 false를 반환합니다. @return {boolean} */ function is_register(param) { return (param == 'eax' || param == 'ebx' || param == 'ecx' || param == 'edx' || param == 'ebp' || param == 'esp' || param == 'eip'); }
// 메서드를 등록합니다. register.is_register = is_register; ... |
이렇게 하면 이전의 코드는 다음과 같이 개선할 수 있다.
Runner.js (execute.mov) |
... else if (mne == 'mov') { // mov 니모닉에 대한 처리 if (Register.is_register(right) == true) { // 레지스터라면 // 해당 레지스터의 값을 대입합니다. Register[left] = Register[right]; } /* 후에 else if (is_memory(right))로 작성하여 메모리에도 대응할 수 있다 */ else { // 레지스터가 아니라면 // 정수로 간주하고, 정수로 변환하여 대입합니다. Register[left] = parseInt(right); } } ... |
is_register 코드가 좀 못생기긴 했지만 이대로 나쁘지 않은 것 같다. 이 정도로 HelloExit의 분석은 마무리할 수 있다.
3. 레이블
이번에는 2절의 처음에 보였던 HelloLabel.hda 소스 파일을 분석해보자. 이 소스를 바로 실행하면 프로그램이 정상적으로 실행되지 않는다. 그 이유는 우리가 load와 run 메서드를 새로 구현하면서 레이블에 대한 처리를 해주지 않았기 때문이다. 그럼 고민해보자. 레이블을 스스로 처리할 수 있겠는가?
3.1) 레이블 인식
사실 이 문제는 결론을 내려면 제법 시간이 오래 걸린다. 일단 레이블을 무시하는 문장부터 만드는 것으로 시작해보자. 일단 여기서는 레이블을 load 메서드에 구현하려고 한다.
Runner.js (load) |
... // 레이블에 대해 처리합니다. if (line.charAt(line.length-1) == ':') { log('[%s]: label', line); continue; } ... |
그리고 아직 작성하지 않은 니모닉을 파악하기 쉽도록 execute를 수정한다.
Runner.js (execute) |
... else if (mne == 'ret') { // ret 니모닉에 대한 처리 /* ... */ } else { // 추가된 부분 log('unknown mnemonic: [' + mne + ']'); } ... |
그러면 일단 레이블을 무시한 채 분석하는 것은 성공한다.
실행 결과 (log) |
[_main:]: label [_end:]: label load complete unknown mnemonic: [jmp] run complete |
그럼 이제 실제로 레이블을, 레이블로서 기능할 수 있게 만들어야 한다. 어떻게 해야 할까?
3.2) 레이블의 값
jmp 니모닉은 피연산자가 가리키는 위치로 명령 포인터를 옮기는 역할을 한다. 일반적으로는 다음과 같이 jmp 니모닉 뒤에 레이블이 위치한다. 아래와 같이 쓰면 label 레이블로 점프하게 된다.
jmp label
그런데 사실 jmp 뒤에는 정수도 올 수 있다.
jmp 200
이렇게 코드를 작성하면 200번지로 명령 포인터가 이동한다. 사실 jmp 니모닉 뒤에는 정수만 온다. 레이블은 메모리의 위치를 정수로 표현한 것에 지나지 않는다. jmp 니모닉 뒤에 레이블이 오면, 사실상 레이블이 가리키는 메모리의 위치 값으로 피연산자가 치환되는 것으로 봐야 한다.
그러면 생각해보자. HelloLabel.hda 파일을 실행하면 코드 영역의 메모리는 다음의 상태가 된다.
실행 결과 (Memory) |
0x0000 | | 0x0000 | 00 00 00 00 70 75 73 68 20 65 62 70 00 6d 6f 76 | ....push ebp.mov 0x0010 | 20 65 62 70 2c 20 65 73 70 00 6d 6f 76 20 65 61 | ebp, esp.mov ea 0x0020 | 78 2c 20 33 00 6a 6d 70 20 5f 65 6e 64 00 6d 6f | x, 3.jmp _end.mo 0x0030 | 76 20 65 61 78 2c 20 35 00 6d 6f 76 20 65 73 70 | v eax, 5.mov esp 0x0040 | 2c 20 65 62 70 00 70 6f 70 20 65 62 70 00 65 78 | , ebp.pop ebp.ex 0x0050 | 69 74 00 00 00 00 00 00 00 00 00 00 00 00 00 00 | it.............. 0x0060 | 64 00 00 00 | d... |
그런데 HelloHASM.hda 파일은 이렇게 생겼다.
HelloLabel.hda |
.code _main: push ebp mov ebp, esp mov eax, 3 jmp _end mov eax, 5 _end: mov esp, ebp pop ebp exit |
이때 “jmp _end”가 실행되고 나면 “mov esp, ebp” 명령이 실행되는 것으로 보아, 바이트 포인터가 원래 위치가 아닌 다른 위치로 이동했음을 알 수 있다. jmp 니모닉이 fetch된 직후의 메모리 상태는 다음과 같을 것이다.
여기서 execute 메서드가 진행된 후의 ip의 값을 말할 수 있는가? 다른 말로 하면, execute 메서드가 진행된 후에 ip가 어느 메모리를 가리키고 있는지 말할 수 있는가?
다음과 같이 ip의 위치를 그렸다면 내용을 정확하게 이해하고 있는 것이다.
그리고 이를 통해 명령 포인터가 어디를 가리켜야 하는지도 답이 나왔다. ip는 0x39번지를 가리켜야 한다. 이는 jmp _end가 사실은 jmp 0x39를 수행했음을 의미한다. 더 정확히 말하면, _end 레이블을 만나면 이 레이블의 위치가 0x39번지라는 것을 알아내서 저장한 다음, 후에 _end 레이블을 참조할 때 0x39라는 값을 가져와야 한다는 것을 의미한다.
이는 아주 복잡한 과정이다. 코드로 옮기기 쉽도록(이해하기 쉽진 않지만) 다시 설명하겠다.
1. load 메서드를 통해 코드를 읽는다.
2. 레이블의 정의를 만난다면, 저장한다.
3. 레이블을 참조하는 부분을 만난다면, 저장했던 값으로 대치한다.
그런데 이것만이라면 아래와 같은 코드는 어렵지 않게 분석할 수 있다. 문제는, 레이블의 참조가 정의 이전에 나타날 수 있다는 것이다. 예제에서도 보이지만 사실 이 경우가 더 일반적이다.
스스로 해결해볼 수 있다면 좋고, 아니면 상관없다. 이 문제를 해결하는 가장 단순한 방법은 그냥 코드를 두 번 읽는 것이다. 첫 번째에선 정의만을 탐색하고, 두 번째 분석 시에 획득한 레이블의 정의를 바탕으로 레이블을 값으로 대치한다. 하지만 이 문서에서는 레이블을 얻기 위해 코드 전체를 두 번 읽지는 않고, 대신 정의를 기억하면서 코드를 수정한 다음 기억된 레이블을 바탕으로 메모리를 수정할 것이다. 당장 이해할 필요는 없고 코드를 보면서 같이 이해하자. 이 부분은 헷갈리지만 꽤 재미있다.
3.3) 전략
구현에 앞서 레이블을 어떻게 대치할지를 고민해야 한다. 여기서 “어떻게 대치할지”라는 부분은 코드를 작성하는 방법에 관한 것이 아니라, 레이블을 어떤 값으로 대치할지를 결정하는 문제를 말한다. 크게 고민할 것은 없고 레이블은 메모리의 주소 값과 같기 때문에, 결과적으로 레지스터의 크기와 항상 같다. 우리는 기본적으로 레지스터의 크기를 4바이트로 잡았으니, 이를 계속 유지하는 것으로 한다. 다만, 메모리의 최대 크기를 웬만하면 작게 하는 것이 도움이 될 것이라고 생각해서, 사용 가능한 메모리의 범위를 2바이트로 표현 가능한 정수의 최댓값으로 제한했다. 즉 우리가 만들 가상 머신의 최대 메모리 크기는 0xFFFF, 즉 64KB이다. 단순히 메모리를 0번지부터 0x0000FFFF번지까지만 사용하는 것으로 이해하면 된다.
또한 레이블을 보관하는 방법에 대해 생각해보자. 필자는 처음에 이런 생각을 했다.
1. 레이블의 정의가 먼저 나타난다면, 해당 정의를 이용하여 이후의 레이블을 이 값으로 대치한다.
2. 레이블의 참조가 먼저 나타난다면, 해당 레이블은 일단 사용하지 않을 임의의 값으로 초기화한 다음 정의가 나타난 후에 이를 대치한다.
이렇게 하면 일단 원하는 대로 구현될 것이다. 이를 위해 다음의 내용 또한 고려해야 한다.
1. 레이블의 참조를 정의로 대치하려면, 해당 참조가 일어난 위치 또한 보관해야 한다.
2. 레이블의 정의가 나타난 시점에서 대치를 바꾸면, 레이블이 나타날 때마다 해당 레이블이 정의되어있는지를 판정하는 구문이 들어간다.
이 두 가지 사실을 반영하여, 필자는 다음과 같이 전략을 바꿨다.
1. 레이블의 참조가 나타나면 일단 0x0000으로 바꾼다. 또한 해당 위치를 보관한다.
2. load 내부에서 레이블의 정의가 나타나는 시점에 해당 레이블의 위치를 등록만 해놓는다.
2. 코드를 모두 메모리에 올린 다음, 등록된 레이블의 위치를 보관해놓은 참조 위치에 덮어씌운다.
그리고 이것이 반영되면 HelloLabel.hda를 실행한 코드의 메모리 영역은 다음과 같이 바뀐다.
실행 결과 (Memory) |
0x0000 | | 0x0000 | 00 00 00 00 70 75 73 68 20 65 62 70 00 6d 6f 76 | ....push ebp.mov 0x0010 | 20 65 62 70 2c 20 65 73 70 00 6d 6f 76 20 65 61 | ebp, esp.mov ea 0x0020 | 78 2c 20 33 00 6a 6d 70 20 30 78 30 30 33 42 00 | x, 3.jmp 0x003B. 0x0030 | 6d 6f 76 20 65 61 78 2c 20 35 00 6d 6f 76 20 65 | mov eax, 5.mov e 0x0040 | 73 70 2c 20 65 62 70 00 70 6f 70 20 65 62 70 00 | sp, ebp.pop ebp. 0x0050 | 65 78 69 74 00 00 00 00 00 00 00 00 00 00 00 00 | exit............ 0x0060 | 64 00 00 00 | d... |
이 결과를 통해 알 수 있는 것 중 가장 중요한 것은, 레이블을 정수 값으로 대치했더니 명령어의 위치가 바뀌었다는 사실이다. 우리가 원래 이동해야 했을 위치는 0x0039이지만, _end 레이블을 0x003B로 수정했더니 글자 수가 2개 늘어나는 바람에 위치가 바뀌어버렸다! 바로 이 점이 레이블을 대치하는 일을 헷갈리게 하므로 집중해서 봐야 한다.
이제 전략은 모두 갖춰졌다. 그런데 레이블을 반영하기에 앞서 준비를 먼저 하자.
3.4) 준비
먼저 이전에는 문자를 획득하지 못했으면 예외 처리하던 것을, 실패 시 모두 null을 반환하는 것으로 고치자.
StringBuffer.js (getc, peekc) |
/** 버퍼로부터 문자를 하나 읽습니다. 포인터가 이동합니다. @return {string} */ StringBuffer.prototype.getc = function() { if (this.is_empty()) // 버퍼가 비었다면 null을 반환합니다. return null; return this.str[this.idx++]; // 다음 문자를 읽고 포인터를 옮깁니다. } /** 버퍼의 포인터가 가리키는 문자를 가져옵니다. 포인터는 이동하지 않습니다. @return {string} */ StringBuffer.prototype.peekc = function() { if (this.is_empty()) // 버퍼가 비었다면 null을 반환합니다. return null; return this.str[this.idx]; } |
그리고 피연산자로 16진수를 획득할 수 있도록 get_number를 수정하겠다.
StringBuffer.js (get_number) |
/** 버퍼로부터 정수를 획득합니다. @return {string} */ StringBuffer.prototype.get_number = function() { this.trim(); // 공백 제거 if (this.is_empty()) return null; // 버퍼에 남은 문자가 없다면 null을 반환합니다. else if (is_digit(this.peekc()) == false) return null; // 첫 문자가 숫자가 아니면 null을 반환합니다.
// 정수를 획득합니다. var value = ''; // 획득한 정수에 대한 문자열 객체입니다.
// 0이 먼저 발견되었다면 16진수입니다. 8진수는 고려하지 않습니다. if (this.peekc() == '0') { this.getc(); // 수를 획득합니다. if (this.is_empty()) // 더 획득할 수가 없다면 0을 반환합니다. return 0;
// 다음에 나타난 문자가 'x'라면 16진수를 획득합니다. if (this.peekc() == 'x') { // 해석한 x는 지나갑니다. this.getc(); // 16진수를 해석합니다. while (this.is_empty() == false) { // 버퍼에 데이터가 남아있는 동안 // 16진수의 자릿수 문자가 아니면 if ('0123456789abcdefABCDEF'.indexOf(this.peekc()) < 0) break; // 탈출합니다. value += this.getc(); // 문자를 반환할 문자열에 추가합니다. } // 획득한 16진수 문자열을 반환합니다. return '0x' + value; } // 아니라면 원래 수를 획득합니다. else { // 획득했던 정수 0을 되돌립니다. this.ungetc(); } }
// 10진수를 해석합니다. while (this.is_empty() == false) { // 버퍼에 값이 남아있는 동안 if (is_digit(this.peekc()) == false) // 자릿수 문자가 아니면 break; // 탈출합니다. value += this.getc(); // 문자를 반환할 문자열에 추가합니다. } return value; } |
여기서 참 재미있는 패턴이 나오는데, 바로 이 부분이다.
... while (this.is_empty() == false) { // 버퍼에 데이터가 남아있는 동안 // 16진수의 자릿수 문자가 아니면 if ('0123456789abcdefABCDEF'.indexOf(this.peekc()) < 0) break; // 탈출합니다. value += this.getc(); // 문자를 반환할 문자열에 추가합니다. } ... |
이게 무슨 뜻인지 이해할 수 있겠는가? 문자열을 하나 놓고 indexOf 멤버를 이용해 뭔가의 인덱스를 찾더니 그것이 0보다 작으면 탈출한다. 도대체 이게 뭐하는 구문이기에 16진수의 자릿수를 판정할 수 있는 것일까?
indexOf는 문자열에서 인자로 주어진 문자가 존재하는지를 찾고, 해당 문자를 찾은 경우 그 문자의 인덱스를 반환한다. 이 경우 this.peekc는 현재 참조하고 있는 문자이고, 따라서 이 문자를 인자로 넘긴다는 것은 현재 확인하고 있는 이 문자가 주어진 문자열에 속해있는가를 확인하는 것이다. indexOf 메서드는 문자열에 해당 문자가 없으면 0보다 작은 값을 반환하기 때문에, 16진수의 자릿수 문자가 아닌 경우 이것과 탈출 조건이 완전히 같아서 이를 사용할 수 있는 것이다.
이를 참조하여 나머지 메서드들도 throw new StringBufferException 부분을 모두 return null;로 변경하면 된다. 또한 빈 문자열을 획득한 경우 null을 반환하도록 get_token 메서드를 수정하자.
StringBuffer.js (get_token) |
/** 현재 위치 다음에 존재하는 토큰을 획득합니다. 토큰 획득에 실패하면 null을 반환합니다. @return {string} */ StringBuffer.prototype.get_token = function() { this.trim(); // 공백 제거 var ch = this.peekc(); var result = null; // 문자열 스트림 생성
if (is_digit(ch)) // 정수를 발견했다면 정수 획득 result = this.get_number(); // cout 출력 스트림처럼 사용하면 된다 else if (is_fnamch(ch)) // 식별자 문자를 발견했다면 식별자 획득 result = this.get_identifier(); else // 이외의 경우 일단 획득 result = this.get_operator();
// 획득한 문자열이 없으면 null을 반환합니다. return (result != '' ? result : null); // 획득한 문자열을 반환한다 } |
이제 레이블을 반영하기 위한 StringBuffer의 준비는 모두 끝났다.
3.5) 레이블 대치
그럼 바로 시작하자. 목표는 3.3절에서 보인 바와 같이 레이블을 해당 레이블의 위치 값으로 대치하는 것이다. 레이블의 위치는 load에서 발견하므로, load 메서드에서 이에 대한 정보를 기록해야 한다. 그전에 앞서 사용할 메모리의 총 크기를 좀 늘리자. 아무래도 100은 작다. 1024로 하면 좋겠다.
Memory.js (MAX_MEMORY_SIZ) |
var MAX_MEMORY_SIZ = 1024; |
또한 구현을 편하게 하기 위해 모든 레이블의 앞에 밑줄(_) 기호가 있다고 가정하겠다. 다음은 이해를 돕기 위한 예제다.
_label - label
label - not label
그리고 다음의 두 가지를 정의한다.
1. 레이블 정보를 표현하는 형식 LabelInfo: name(string), address(number), refered(Array)
2. LabelInfo에 대한 딕셔너리 객체 labelInfoDict
load 바깥에서 레이블 정보에 대한 형식을 정의한다.
Runner.js (LabelInfo) |
/** 레이블 정보를 표현하는 형식 LabelInfo를 정의합니다. @param {string} name */ function LabelInfo(name) { this.name = name; this.address = 0; this.refered = []; } |
그리고 레이블이 발견될 때마다 딕셔너리에 추가하면 되는데, 이게 보통 헷갈리는 것이 아니다. 아래처럼 반복문 내에서 먼저 레이블의 참조를 먼저 보관해놓는다.
Runner.js (load) |
... // 획득한 텍스트를 메모리에 기록합니다. var labelInfoDict = {}; // LabelInfo에 대한 딕셔너리 객체입니다. var segment = null; for (var i=0, len=lines.length; i<len; ++i) { try { // i번째 줄에서 양 옆 공백이 제거된 문자열을 가져옵니다. var line = lines[i].trim();
// 지시어 및 예외적 상황을 처리합니다. if (line == '') continue; // 빈 줄은 넘어갑니다. else if (line.charAt(0) == ';') continue; // 주석은 넘어갑니다. else if (line.charAt(0) == '.') { // 세그먼트를 처리합니다. // 세그먼트 정보를 보관합니다. // 이후에 나타나는 코드의 영역을 결정하기 위해 사용합니다. segment = line; continue; }
// 레이블에 대해 처리합니다. if (line.charAt(line.length-1) == ':') { // 레이블 이름을 획득합니다. var label = line.substr(0, line.length-1);
// 레이블이 딕셔너리에 없는 경우 생성합니다. if (labelInfoDict[label] == undefined) labelInfoDict[label] = new LabelInfo(label);
// 레이블 딕셔너리에 정보를 등록합니다. labelInfoDict[label].address = Memory.bytePtr; continue; }
// 코드를 메모리에 올립니다. if (segment == null) // 세그먼트가 정의되지 않았다면 예외 처리합니다. throw new RunnerException("segment is null");
/* 데이터 세그먼트는 나중에 처리합니다. */ // 논리가 복잡해졌으므로 획득한 행을 분석합니다. var info = decode(line);
// 메모리에 기록할 문자열을 생성합니다. var s = info.mnemonic; // 맨 처음은 니모닉입니다. if (info.left != null) { // 인자가 존재한다면 s += ' '; // 분석할 수 있도록 니모닉과 간격을 만듭니다.
// 레이블이라면 (맨 앞이 밑줄 기호라면) if (info.left.charAt(0) == '_') { s += '0x0000'; // 일단 0으로 대치합니다.
var label = info.left; // 레이블 이름을 획득합니다. // 레이블이 딕셔너리에 등록되지 않았다면 등록합니다. if (labelInfoDict[label] == undefined) labelInfoDict[label] = new LabelInfo(label);
// 참조하고 있는 위치를 보관합니다. labelInfoDict[label].refered.push(Memory.bytePtr); } // 레이블이 아니라면 그냥 피연산자로 취급합니다. else { s += info.left; }
if (info.right != null) { s += ',';
// 레이블이라면 (맨 앞이 밑줄 기호라면) if (info.right.charAt(0) == '_') { s += '0x0000'; // 일단 0으로 대치합니다.
var label = info.right; // 레이블 이름을 획득합니다. // 레이블이 딕셔너리에 등록되지 않았다면 등록합니다. if (labelInfoDict[label] == undefined) labelInfoDict[label] = new LabelInfo(label);
// 참조하고 있는 위치를 보관합니다. labelInfoDict[label].refered.push(Memory.bytePtr); } // 레이블이 아니라면 그냥 피연산자로 취급합니다. else { s += info.right; } } }
// 생성한 문자열을 메모리에 올립니다. for (var j=0, slen=s.length; j<slen; ++j) Memory.write_byte(s.charCodeAt(j)); // 명령을 구분하기 위해 널 문자를 삽입합니다. Memory.write_byte(0);
} catch (ex) { if (ex instanceof RunnerException) { log('Runner.load: '); log(i + ': [' + lines[i] + '] ' + ex.description); } else { throw ex; } } } ... |
소개한 코드는 일단 기존에 존재하던 레이블을 대치만 한 것이다. 그리고 이를 정의로 대치하는데, 여기서는 일단 레이블의 참조 위치가 제대로 보관되었는지 확인하는 코드를 작성했다.
Runner.js (load) |
// 모든 참조된 레이블을 정의로 대치합니다. // 실제로는 레이블의 정보를 로그 스트림에 출력해보기만 합니다. for (label in labelInfoDict) { // LabelInfo 정보를 가져옵니다. var info = labelInfoDict[label];
// 레이블의 정보를 형식적으로 출력합니다. var s = Handy.format('%s: %04x [ ', info.name, info.address); var arr = info.refered; for (var i=0, len=arr.length; i<len; ++i) s += Handy.format('%04x ', arr[i]); // 16진수로 레이블 참조 위치를 출력합니다.
// 획득한 문자열을 출력합니다. log(s + ']'); } |
그런데 이 코드를 실행한 후 메모리를 보자.
실행 결과 (Memory) |
0x0000 | 00 00 00 00 70 75 73 68 20 65 62 70 00 6d 6f 76 | ....push ebp.mov 0x0010 | 20 65 62 70 2c 65 73 70 00 6d 6f 76 20 65 61 78 | ebp,esp.mov eax 0x0020 | 2c 33 00 6a 6d 70 20 30 78 30 30 30 30 00 6d 6f | ,3.jmp 0x0000.mo 0x0030 | 76 20 65 61 78 2c 35 00 6d 6f 76 20 65 73 70 2c | v eax,5.mov esp, 0x0040 | 65 62 70 00 70 6f 70 20 65 62 70 00 65 78 69 74 | ebp.pop ebp.exit 0x0050 | 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 | ................ ... |
일단 메모리는 잘 올라갔음을 알 수 있다. 원래대로라면 “jmp _end.”과 같이 찍혔어야 할 부분이 0x0000 값으로 잘 바뀌었으니까. 그런데 레이블의 정보가 출력된 로그 스트림을 보자. 이 결과는 타당한 것일지 한 번 생각해보라.
실행 결과 (log) |
_main: 0004 [ ] _end: 0038 [ 0023 ] ... |
"load complete" 문장 이후는 볼 것 없다. 중요한 건 출력된 레이블이다. 여기서는 _end 레이블의 정의가 0x0038번지에 있고, 0x0023번지에서 이 레이블을 참조한다고 말하고 있다. 그럼 0x0038번지와 0x0023번지를 위 메모리에서 각각 찾아보자. 색깔로 표시하면 다음과 같다.
실행 결과 (Memory) |
0x0000 | 00 00 00 00 70 75 73 68 20 65 62 70 00 6d 6f 76 | ....push ebp.mov 0x0010 | 20 65 62 70 2c 65 73 70 00 6d 6f 76 20 65 61 78 | ebp,esp.mov eax 0x0020 | 2c 33 00 6a 6d 70 20 30 78 30 30 30 30 00 6d 6f | ,3.jmp 0x0000.mo 0x0030 | 76 20 65 61 78 2c 35 00 6d 6f 76 20 65 73 70 2c | v eax,5.mov esp, 0x0040 | 65 62 70 00 70 6f 70 20 65 62 70 00 65 78 69 74 | ebp.pop ebp.exit 0x0050 | 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 | ................ ... |
색을 이용해 해당 번지에서 시작하는 명령을 눈에 잘 띄도록 표시했다. 빨간색으로 표시한 부분은 그러한 메모리의 가장 앞을 말한다. 여기서 조심해야 하는 것은, 맨 왼쪽에 표시된 메모리는 0번지부터 시작하기 때문에, 왼쪽에서 읽을 때 0번지부터 세어야 한다는 것이다. 예를 들어 jmp 명령의 경우 가장 왼쪽에 0x0020이 있기 때문에, 왼쪽부터 차례로 0x0020번지, 0x0021번지, 0x0022번지를 지나서 마지막으로 0x0023번지를 얻는다.
중요한 단계다. 우리는 메모리를 이렇게 바꾸어야 한다.
예상 결과 (Memory) |
0x0000 | 00 00 00 00 70 75 73 68 20 65 62 70 00 6d 6f 76 | ....push ebp.mov 0x0010 | 20 65 62 70 2c 65 73 70 00 6d 6f 76 20 65 61 78 | ebp,esp.mov eax 0x0020 | 2c 33 00 6a 6d 70 20 30 78 30 30 33 38 00 6d 6f | ,3.jmp 0x0038.mo 0x0030 | 76 20 65 61 78 2c 35 00 6d 6f 76 20 65 73 70 2c | v eax,5.mov esp, 0x0040 | 65 62 70 00 70 6f 70 20 65 62 70 00 65 78 69 74 | ebp.pop ebp.exit 0x0050 | 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 | ................ ... |
그런데 우리가 가리키고 있는 부분은 참조하는 위치 0x0027이 아닌, 명령어의 맨 앞인 0x0023이다. 따라서 이것을 실제 참조 위치로 바꿔줘야 한다. 명령어부터 다시 레이블을 탐색하면 된다고 생각할 수 있지만, 그렇게 하면 다음과 같이 피연산자로 레이블만 두 개 있는 경우에 대응할 수 없다.
cmp _label1, _label2 ; _label1과 _label2를 비교하는 구문입니다.
cmp 0x0000, 0x0000 ; 메모리에 올라가면 이 상태가 되는데,
; 둘 중 어느 쪽을 수정해야 하는지 알 수 없습니다.
따라서 참조 위치를 저장할 때 다음과 같이 코드를 변경해야 한다.
Runner.js (load.loadLabel) |
... // 레이블이라면 (맨 앞이 밑줄 기호라면) if (info.left.charAt(0) == '_') {
// 참조 위치는 (현재 바이트 포인터 위치 + 니모닉의 길이 + 공백 한 칸)입니다. var refered_addr = Memory.bytePtr + s.length; s += '0x0000'; // 일단 0으로 대치합니다. ... // 참조하고 있는 위치를 보관합니다. labelInfoDict[label].refered.push(refered_addr); } ... /* right에 대해서도 같은 방식으로 수정합니다 */ |
그리고 방금 전에는 레이블 정보를 출력만 했던 구문을 다음과 같이 변경한다.
Runner.js (load.showLabelInfo) |
... // 획득한 문자열을 출력합니다. log(s + ']');
// 참조된 레이블을 정의로 대치합니다. for (var i=0, len=arr.length; i<len; ++i) { // 대치해야 할 메모리 위치로 바이트 포인터를 이동합니다. Memory.bytePtr = arr[i];
// 참조된 레이블의 정의를 16진수 문자열로 획득합니다. var refered_addr = Handy.format('0x%04x', info.address);
// 해당 참조 위치에 정의 값을 덮어씌웁니다. for (var j=0, slen=refered_addr.length; j<slen; ++j) Memory.write_byte(refered_addr.charCodeAt(j)); } ... |
그리고 프로그램을 실행하여 다음 결과를 얻는다.
실행 결과 |
0x0000 | 00 00 00 00 70 75 73 68 20 65 62 70 00 6d 6f 76 | ....push ebp.mov 0x0010 | 20 65 62 70 2c 65 73 70 00 6d 6f 76 20 65 61 78 | ebp,esp.mov eax 0x0020 | 2c 33 00 6a 6d 70 20 30 78 30 30 33 38 00 6d 6f | ,3.jmp 0x0038.mo 0x0030 | 76 20 65 61 78 2c 35 00 6d 6f 76 20 65 73 70 2c | v eax,5.mov esp, 0x0040 | 65 62 70 00 70 6f 70 20 65 62 70 00 65 78 69 74 | ebp.pop ebp.exit 0x0050 | 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 | ................ ... |
이로써 복잡한 레이블의 대치가 끝이 난다.
3.6) jmp 니모닉 완성
남은 건 실제로 jmp 니모닉을 동작하게 만드는 것 밖에 없다. 그리고 별로 어렵지도 않다.
Runner.js (jmp) |
... else if (mne == 'jmp') { // jmp 니모닉에 대한 처리 var dest_addr = 0; // 점프할 목적지입니다.
// 레지스터라면 해당 레지스터의 값으로 점프합니다. if (Register.is_register(left)) dest_addr = Register[left]; // 정수라면 해당 정수 값으로 점프합니다. else dest_addr = parseInt(left, 16);
// 획득한 목적지로 바이트 포인터를 옮깁니다. Memory.bytePtr = dest_addr; } ... |
그리고 이를 실행하여 다음 결과를 얻는다.
실행 결과 (expression) |
push ebp mov ebp,esp mov eax,3 jmp 0x0038 mov esp,ebp pop ebp |
mov eax, 5 문장을 무시하는 데 성공했다. 이와 같이 레이블을 처리하고 jmp를 구현할 수 있었다.
4. execute 정리: 최고로 멋진 코드 작성
jmp 니모닉을 완성하고 나서 execute 메서드를 보니, 너무 길다. 게다가 아직도 처리해야 할 니모닉은 산더미처럼 남아있다. 이것을 다른 파일에 분리할 수 있다면 보기 편할 것이다.
한 번 생각해보자. 각각의 상황에 따라 분기할 조건은 순수하게 mne에 의해 결정되는 것이다. mne가 mov 문자열이라면 mov 명령을, jmp 문자열이라면 jmp 명령을 실행한다. 이를 통해 우리가 분기하는 조건은 mne에 담긴 문자열에 의한 것이라는 결론이 나온다.
다른 것을 생각해보자. 객체의 멤버에 접근할 때 문자열을 사용할 수 있고, 객체의 멤버로 함수를 지정하는 것이 가능하다. 또한 원래 함수의 인자 수에 맞지 않게 함수를 호출하는 것이 크게 문제가 되지 않는다. 이는 log 함수의 정의에는 인자가 fmt밖에 없지만, 실제로는 가변 인자처럼 활용하는 것에서 알 수 있다.
이들을 종합해보자. 빈 객체 하나를 생성한다. 이 객체를 Operate라고 하자.
Operate.js |
function initOperate() { var operate = {}; window.Operate = operate; } |
그 다음 니모닉에 대한 처리 함수를 정의하고 Operate 객체의 멤버로 지정한다.
Operate.js (mnemonic_define) |
... function jmp(left) { var dest_addr = 0; // 점프할 목적지입니다.
// 레지스터라면 해당 레지스터의 값으로 점프합니다. if (Register.is_register(left)) dest_addr = Register[left]; // 정수라면 해당 정수 값으로 점프합니다. else dest_addr = parseInt(left, 16);
// 획득한 목적지로 바이트 포인터를 옮깁니다. Memory.bytePtr = dest_addr; } ... operate.jmp = jmp; ... |
그러면 execute 메서드는 다음 한 문장으로 정리가 된다.
Runner.js |
function execute(info) { Operate[info.mnemonic](info.left, info.right); } |
너무나 멋진 코드가 아닐 수 없다! 이 단 한 줄이 모든 니모닉을 처리할 수 있다. 다시 말해, 이 한 문장이 Runner 모듈의 핵심이다! Operate 객체에서 info.mnemonic 문자열에 해당하는 필드를 찾는다. 이는 함수로 정의했으므로 함수처럼 호출하는 것이 가능하다. JavaScript의 함수는 인자가 몇 개이건 상관없이 마음대로 인자를 넘길 수 있기 때문에, 위와 같이 모든 니모닉 처리 함수에 대해 일괄적으로 info.left, info.right를 인자로 넘기는 것이 전혀 문제가 되지 않는다. 또한 우리가 아직 처리하지 않은 니모닉을 나중에 추가하는 것도 Operate.js 파일을 수정하기만 하면 되고 execute 메서드는 하나도 건드리지 않는다. 이 정도면 크게 만족스럽다!
이와 같이 execute 메서드를 정리할 수 있었다.
4. 플래그 레지스터
레이블을 다루는데 조건 분기가 빠질 수 없다. 4장에서 cmp 니모닉을 설명하면서 어셈블리 언어의 조건문에 대해 설명한 적이 있다. 다음은 cmp를 사용하여 조건 분기하는 예제다.
condition.hda |
.code _main: push ebp mov ebp, esp
; eax = 5 mov eax, 5
; eax와 5를 비교하여 결과를 eflags에 저장합니다. cmp eax, 5
; eflags의 z 플래그가 0이 아니면 _else로 점프합니다. jnz _else
; ebx = 1 mov ebx, 1
; 조건문의 끝으로 이동합니다. jmp _endif
_else: ; ebx = 2 mov ebx, 2
; 조건문의 끝입니다. _endif: _end: mov esp, ebp pop ebp exit |
우리는 최종적으로 이런 결과를 얻을 것이다.
예상 결과 (expression) |
push ebp mov ebp,esp mov eax,5 cmp eax,5 jnz 0x004d mov ebx,2 mov esp,ebp pop ebp |
바로 시작해보자.
4.1) 비트 플래그
플래그(flag)란 프로그램 실행 중에 특정 상태가 성립했는지를 확인하기 위해 사용하는 데이터를 말한다. 프로그래밍을 많이 해본 우리는 사실 이미 플래그라는 것을 많이 써왔다. bool을 통해 특정 상태를 체크하는 행위는 모두 플래그를 다루는 것이다.
그런데 bool을 이용하여 플래그를 표시할 수도 있지만, 실제로 더 자주 쓰이는 방법은 비트(bit)를 이용하는 것이다. 1바이트는 8비트로 이루어져있다고 말할 때 쓰는 그 비트가 맞다. 예를 들어보자. 만약 우리가 C++ 프로그래밍 언어로 게임을 만들 때, 새 게임을 생성하기 위한 옵션이 4가지라고 하자.
bool enableItem; // 아이템 사용을 허가합니까? bool enableNPC; // NPC를 허용합니까? bool enableChat; // 채팅을 허용합니까? bool enableInfTime; // 시간 제한이 없는 게임입니까? |
그러면 게임을 생성할 때는 이렇게 함수를 호출할 것이다.
enableItem = true; enableNPC = false; enableChat = true; enableInfTime = false; Game *game = CreateNewGame(enableItem, enableNPC, enableChat, enableInfTime); |
뭐 각각의 변수를 만들 필요가 없으면 그냥 이렇게 호출해도 되는데 이름도 길고 알아보기 힘들다.
Game *game = CreateNewGame(true, false, true, false); |
여기서 좀 더 영리한 방법은 배열과 열거형을 쓰는 것인데, 호출 시에 어떤 옵션이 들어갔는지 안 보이는 것은 똑같다.
enum { ENABLE_ITEM, ENABLE_NPC, ENABLE_CHAT, ENABLE_INF }; bool option[4]; option[ENABLE_ITEM] = true; option[ENABLE_NPC] = false; option[ENABLE_CHAT] = true; option[ENABLE_INF] = false; Game *game = CreateNewGame(option); |
그런데 다음과 같이 각 수에 대해 비트를 설정해주면,
const int ENABLE_ITEM = 1; // 0000 0001 const int ENABLE_NPC = 1 << 1; // 0000 0010 const int ENABLE_CHAT = 1 << 2; // 0000 0100 const int ENABLE_INF = 1 << 3; // 0000 1000 |
다음과 같이 호출하는 것만으로 어떤 옵션을 주어 게임이 생성되는지 한 눈에 파악할 수 있다.
// ENABLE_ITEM, ENABLE_CHAT 옵션을 동시에 주고 싶다면 비트합 연산자 |를 이용합니다. // ENABLE_ITEM | ENABLE_CHAT = 0000 0101 // 따라서 CreateNewGame 함수 내에서 1번 비트와 3번 비트가 1임을 파악하고 // 해당 옵션에 맞게 게임을 생성하게 됩니다. Game *game = CreateNewGame(ENABLE_ITEM | ENABLE_CHAT); |
이 방식은 정수 하나를 사용하기 때문에 메모리 면에서도, 성능 면에서도 월등하다. 그래서 CPU에서 플래그 레지스터를 다룰 때도 이러한 방식을 사용한다. 이 정도면 플래그를 표현할 때 비트를 이용하는 이유는 납득할 수 있을 것이다.
4.2) 영 플래그
게임을 생성하기 위한 옵션이 여러 가지 있는 것처럼, 플래그도 종류가 여러 가지 있다. 처음에 이들을 일렬로 나열해서 보여줘도 되지만, 그냥 하나를 천천히 배우고 나머지도 이해하는 식으로 진행하자.
가장 많이 쓰이는 플래그 중의 하나는 영 플래그(Zero flag, ZF)다. 계산 후의 결과가 0인지 아닌지를 저장해놓는다. 우리가 분석할 condition.hda 예제에서 cmp 명령어를 통해 두 값이 서로 같은지 다른지를 판정하는데, 이 값이 서로 같으면 ZF=0이 된다. jnz는 jump if not zero의 줄임말로, 결과가 0이 아닌 경우 점프하겠다는 니모닉이다. 이 둘을 합치면 condition.hda는 결국 다음 코드가 된다.
condition.hda를 C로 작성한 코드 |
main() { eax = 5; // mov eax, 5 eflags = ((eax-5) == 0); // cmp eax, 5 if (eflags != 0) goto _endif; // jnz _else. eax == 5이므로 발동하지 않는다 ebx = 1; // mov ebx, 1 goto _endif; // jmp _endif _else: ebx = 2; // mov ebx, 2 _endif: } |
그럼 영 플래그에 대해서만 코드를 구현해보자. Register에 eflags 레지스터를 추가한다.
Register.js |
... register.eflags = 0; ... |
그 다음 Operate 객체에 jnz를 추가한다.
Operate.js (jnz) |
function jnz(left) { // 0이 아니면, 즉 영 플래그가 1이 아니면 점프합니다. if (Register.eflags != 1) jmp(left); } |
그리고 cmp도 추가해서 이렇게 만들어주면 끝난다.
Operate.js (cmp) |
function cmp(left, right) { // left가 레지스터라면 if (Register.is_register(left)) { // 두 값이 같은지를 보관하는 변수입니다. var same;
// right 또한 레지스터라면 둘을 비교합니다. if (Register.is_register(right)) { // 두 값의 차이가 0인지 확인합니다. 0이라면 true, 아니면 false입니다. same = ((Register[left] - Register[right]) == 0); } // right가 즉시 값이라면 둘을 비교합니다. else { same = ((Register[left] - right) == 0); }
// 두 값이 같은지에 대한 결과를 반영합니다. 같다면 1, 아니면 0입니다. Register.eflags = same ? 1 : 0; } } |
그리고 프로그램을 실행하여 다음을 얻는다.
실행 결과 (expression) |
push ebp mov ebp,esp mov eax,5 cmp eax,5 jnz 0x004d mov ebx,1 jmp 0x0057 mov esp,ebp pop ebp |
실행 결과 (register) |
eax = 00000000, ebx = 00000000, ecx = 00000000, edx = 00000000 ebp = 400, esp = 3fc eax = 00000000, ebx = 00000000, ecx = 00000000, edx = 00000000 ebp = 3fc, esp = 3fc eax = 00000005, ebx = 00000000, ecx = 00000000, edx = 00000000 ebp = 3fc, esp = 3fc eax = 00000005, ebx = 00000000, ecx = 00000000, edx = 00000000 ebp = 3fc, esp = 3fc eax = 00000005, ebx = 00000000, ecx = 00000000, edx = 00000000 ebp = 3fc, esp = 3fc eax = 00000005, ebx = 00000001, ecx = 00000000, edx = 00000000 ebp = 3fc, esp = 3fc eax = 00000005, ebx = 00000001, ecx = 00000000, edx = 00000000 ebp = 3fc, esp = 3fc eax = 00000005, ebx = 00000001, ecx = 00000000, edx = 00000000 ebp = 3fc, esp = 3fc eax = 00000005, ebx = 00000001, ecx = 00000000, edx = 00000000 ebp = 400, esp = 400 |
위에서 여섯 번째 명령이 진행되었을 때 ebx 레지스터의 값이 1로 변경되었음을 알 수 있다.
4.3) 플래그 종류
플래그는 영 플래그 이외에도 여러 가지가 있다. 상태 플래그의 목록은 다음과 같다.
Bit # | short | original | description |
0 | CF | Carry | 부호 없는 산술 연산 시에 오버플로가 발생한 경우 1로 설정 |
2 | PF | Parity | 결과의 하위 바이트에 존재하는 1이 짝수 개이면 1로 설정 |
4 | AF | Adjust | 산술 연산 시 비트 3과 4 사이에서 산술 캐리가 발생하면 1로 설정 |
6 | ZF | Zero | 산술 논리 연산의 결과가 0인 경우 1로 설정 |
7 | SF | Sign | 산술 논리 연산의 결과가 음수인 경우 1로 설정 |
11 | OF | Overflow | 부호 있는 산술 연산 시에 오버플로가 발생한 경우 1로 설정 |
PF, AF 말고 다른 플래그는 이해할 수 있을 것이다. 이외에도 몇 가지 플래그가 더 있으나 우리 프로젝트를 위해서는 이 정도면 충분하다. 모두 반영하자.
Register 모듈에 영 플래그에 접근하거나 설정하는 메서드를 추가한다. 나머지도 똑같이 처리한다.
Register.js (eflags) |
const BIT_ZF = 1 << 6; ... function getZF() { return (this.eflags & BIT_ZF) ? 1 : 0 }; function setZF(value) { if (value != 0) { this.eflags &= (~BIT_ZF); } else { this.eflags |= BIT_ZF; } } ... |
그리고 이전에 영 플래그에 대해 1로 처리했던 곳을 모두 수정한다.
Operate.js (jnz, cmp) |
... function jnz(left) { // 0이 아니면, 즉 영 플래그가 1이 아니면 점프합니다. if (Register.getZF() != 1) jmp(left); } function cmp(left, right) { if (Register.is_register(left)) { // left가 레지스터라면 var same; // 두 값이 같은지를 보관하는 변수입니다.
if (Register.is_register(right)) { // right 또한 레지스터라면 둘을 비교합니다. // 두 값의 차이가 0인지 확인합니다. 0이라면 true, 아니면 false입니다. same = ((Register[left] - Register[right]) == 0); } else { // right가 즉시 값이라면 둘을 비교합니다. same = ((Register[left] - right) == 0); }
// 두 값이 같은지에 대한 결과를 반영합니다. 같다면 1, 아니면 0입니다. var value = same ? 1 : 0; Register.setZF(value); } } ... |
그리고 레지스터의 변화를 보다 편리하게 관찰할 수 있도록 레지스터 출력 부분도 수정하자.
Runner.js (run) |
... Runner.reg_write('ebp = %02x, esp = %02x, eip = %08x, eflags = %08b', Register.ebp, Register.esp, Register.eip, Register.eflags); ... |
그리고 프로그램을 실행하여 다음 결과를 얻는다.
실행 결과 (register) |
eax = 00000000, ebx = 00000000, ecx = 00000000, edx = 00000000 ebp = 400, esp = 3fc, eip = 00000004, eflags = 00000000 eax = 00000000, ebx = 00000000, ecx = 00000000, edx = 00000000 ebp = 3fc, esp = 3fc, eip = 00000004, eflags = 00000000 eax = 00000005, ebx = 00000000, ecx = 00000000, edx = 00000000 ebp = 3fc, esp = 3fc, eip = 00000004, eflags = 00000000 eax = 00000005, ebx = 00000000, ecx = 00000000, edx = 00000000 ebp = 3fc, esp = 3fc, eip = 00000004, eflags = 01000000 eax = 00000005, ebx = 00000000, ecx = 00000000, edx = 00000000 ebp = 3fc, esp = 3fc, eip = 00000004, eflags = 01000000 eax = 00000005, ebx = 00000001, ecx = 00000000, edx = 00000000 ebp = 3fc, esp = 3fc, eip = 00000004, eflags = 01000000 eax = 00000005, ebx = 00000001, ecx = 00000000, edx = 00000000 ebp = 3fc, esp = 3fc, eip = 00000004, eflags = 01000000 eax = 00000005, ebx = 00000001, ecx = 00000000, edx = 00000000 ebp = 3fc, esp = 3fc, eip = 00000004, eflags = 01000000 eax = 00000005, ebx = 00000001, ecx = 00000000, edx = 00000000 ebp = 400, esp = 400, eip = 00000004, eflags = 01000000 |
이와 같이 플래그에 대해 처리할 수 있었다.
5. 특수 니모닉
꾸역꾸역 어셈블리 언어 실행기를 개발해냈다. 그런데 우리가 만든 어셈블리 실행기에는 사실 프로그램이라면 거의 필수적인 입출력 기능이 없다. 입력은 지금 단계에선 어려운 것이니 뒤로 제쳐두자. 그래도 출력은 해보고 싶다. 그러니까 적어도 우리가 HASM으로 덧셈 코드를 작성했다면 그 값이 출력은 되어야 하지 않겠는가 하는 문제다.
사실 이는 딱히 어려운 문제는 아니다. 우리는 run 메서드를 통해 프로그램을 실행할 때, 니모닉에 해당하는 메서드를 Operate 객체에 등록해놓은 다음 이를 호출한다. 그러니까, 실제 어셈블리 문법에 해당하지 않는 적당한 문자열을 골라서 코드에 삽입하고 그에 맞는 메서드를 정의만 하면 된다. 다음과 같이 말이다.
Operate.js |
... function print_number(left) { var value;
// 레지스터라면 레지스터의 값을 얻는다. if (Register.is_register(left)) { value = Register[left]; } // 레지스터가 아니라면 인자를 정수로 변환한다. else { value = parseInt(left); }
// 획득한 값을 출력한다. Runner.out_write(value); } function print_letter(left) { var value;
// 레지스터라면 레지스터의 값을 얻는다. if (Register.is_register(left)) { value = Register[left]; } // 레지스터가 아니라면 인자를 정수로 변환한다. else { value = parseInt(left); }
// 획득한 값으로부터 문자를 획득하고 이를 출력한다. Runner.out_write(String.fromCharCode(value)); } operate.print_number = print_number; operate.print_letter = print_letter; ... |
그러면 이런 HASM 코드도 원하는대로 잘 분석할 수 있다.
print.hda |
.code _main: push ebp mov ebp, esp
; 정수 10을 출력합니다. print_number 10
; 문자 A(==65)를 출력합니다. print_letter 65
_end: mov esp, ebp pop ebp exit |
실행 결과 (output) |
10 A |
그런데 원래 operate 함수의 섞이는 것은 아무래도 마음에 안 드니, 이들 또한 하나의 객체로 모으자는 것이 이번 절의 목표다. 그러면서 모듈을 좀 더 편리하게 수정하자.
5.1) get_token 확장
우리가 작성한 어셈블리에는 문자열을 직접 출력할 방법이 없다. 예를 들어 다음과 같은 식으로 어셈블리 코드를 작성하고 Operate 객체에 이를 출력하는 메서드를 구현한다고 해도,
; HASM 소스 코드 ... print_str "Hi, world!" ...
// Operate.js ... function print_string(left) { Runner.out_write(left); } operate.print_str = print_string; ... |
실행하면 제대로 동작하지 않는다.
실행 결과 |
Runner.load: 5: [print_str "Hello, world!"] syntax error; right operand must be after comma(,) _main: 0004 [ ] _end: 0019 [ ] load complete run complete |
일단 여기서 문제가 발생한 것은, 우리가 문자열이라는 토큰을 정의한 적이 없기 때문이다. 큰따옴표에 대해 별도로 처리한 적이 없기 때문에 문자열로 인식하지 못하고 분석을 진행했고, 반점을 그냥 왼쪽 피연산자와 오른쪽 피연산자를 나누는 용도로 해석해서 이런 일이 발생했다.
문자열을 바로 넘겨서 실행할 수 있다면 디버깅하기 편리할 것이다. 따라서 따옴표 기호를 만나면 이를 문자열로 인식하도록 get_token 메서드를 수정해보자.
StringBuffer.js (get_token) |
... else { if (ch == '"' || ch == "'") { // 문자열 기호의 시작이라면 result = ''; // 반환할 수 있도록 문자열을 초기화합니다. var quot = this.getc(); // 따옴표의 쌍을 맞출 수 있도록 따옴표를 보관합니다.
while (this.is_empty() == false) { // 버퍼에 문자가 있는 동안 var sch = this.peekc(); // 문자를 획득합니다.
if (sch == '\\') { // '\' 특수기호라면 이스케이프 시퀀스를 처리합니다. /* 개행 문자, 캐리지 리턴 등 특수 문자를 처리합니다. */ } else if (sch == quot) { // 같은 따옴표가 나왔다면 문자열 획득을 마칩니다. this.getc(); break; } else { // 나머지 문자는 result에 붙입니다. result += this.getc(); } } result = quot + result + quot; } else { // 아니라면 일단 연산자로 획득합니다. result = this.get_operator(); } } ... |
그러면 이제 프로그램이 잘 실행된다.
실행 결과 (output) |
"Hello, world!" |
그리고 이스케이프 시퀀스는 이렇게 처리한다.
StringBuffer.js (get_token.escase) |
... if (sch == '\\') { // '\' 특수기호라면 이스케이프 시퀀스를 처리합니다. this.getc(); // 이미 획득한 문자는 넘어갑니다. var next = this.getc(); // \ 다음의 문자를 획득합니다. var ech = null; // 획득할 이스케이프 시퀀스입니다.
switch (next) { // 문자에 맞게 조건 분기합니다. case 'n': ech = '\n'; break; case 'r': ech = '\r'; break; case 't': ech = '\t'; break; case '0': ech = '\0'; break; case '\\': ech = '\\'; break; default: throw new StringBufferException ("invalid escape sequence"); } result += ech; // 획득한 이스케이프 시퀀스를 붙입니다. } ... |
get_token 메서드의 확장은 이 정도로 마무리할 수 있다.
5.2) Memory.show 메서드 개선
다음과 같은 코드를 실행하면,
print_line.hda |
.code _main: push ebp mov ebp, esp
print_str "Hello, world!\n"
_end: mov esp, ebp pop ebp exit |
메모리는 이렇게 나온다.
실행 결과 (memory) |
0x0000 | 00 00 00 00 70 75 73 68 20 65 62 70 00 6d 6f 76 | ....push ebp.mov 0x0010 | 20 65 62 70 2c 65 73 70 00 70 72 69 6e 74 5f 73 | ebp,esp.print_s 0x0020 | 74 72 20 22 48 65 6c 6c 6f 2c 20 77 6f 72 6c 64 | tr "Hello, world 0x0030 | 21 0a 22 00 6d 6f 76 20 65 73 70 2c 65 62 70 00 | ! ".mov esp,ebp. 0x0040 | 70 6f 70 20 65 62 70 00 65 78 69 74 00 00 00 00 | pop ebp.exit.... ... |
뭔가 와장창이 되었는데, 이유는 단순히 Memory.show에 해당 바이트 코드에 대응하는 문자를 출력하는 기능이 있기 때문이다. 메모리에 기록한 바이트가 우연히 개행 문자의 코드 값과 같다면, 오른쪽에 문자를 출력할 때 개행 문자가 출력될 것이다. 여기서는 이를 공백으로 변경하고자 한다. 물론 이러면 문자 표만 보고 이게 공백인지 개행 문자인지 알 수 없겠지만 그냥 놔두는 것보다야 낫다.
이는 그렇게 어렵지 않다. 사실 우리가 맨 처음 Memory.show를 구현할 때, 널 문자를 걸러내기 위해 이 방법을 이미 사용해본 적이 있다. Memory.show 메서드를 수정한다.
Memory.js (show) |
... // 바이트 값으로부터 문자를 획득합니다. var ascii = String.fromCharCode(byte);
// 이스케이프 시퀀스인 경우에 대해 처리합니다. switch (ascii) { case '\0': ascii = '.'; break; case '\n': ascii = ' '; break; case '\t': ascii = ' '; break; case '\r': ascii = ' '; break; } ... |
이것만으로 show 메서드의 수정은 끝이 난다. 나머지 이스케이프 시퀀스는 필요할 때 추가하면 된다.
5.3) write 메서드 수정
Runner 모듈에 우리가 작성한 write 메서드는 자동으로 개행 문자를 마지막에 추가한다. 그런데 아무래도 개행이 없이 출력하는 방법도 필요는 할 것 같다. 또한 각 스트림을 비우는 함수도 있었으면 좋겠다. 그러니 각 메서드를 이렇게 수정할 수 있다.
Runner.js (write) |
function out_write(fmt) { ... } // 스트림에 문자열을 출력합니다. function out_writeln(fmt) { ... } // 스트림에 문자열을 출력하고 개행합니다. function out_clear() { ... } // 스트림을 비웁니다. |
그런데 이들을 모두 수정하려니, 아무래도 너무 길다. 그래서 이들 또한 다른 파일로 빼내는 것이 좋겠다. 이들 모두가 스트림이니, Stream이라는 객체를 만들고 여기에 출력하는 것으로 하자.
Stream.js |
/** 스트림을 관리하는 Stream 싱글톤 객체를 생성합니다. */ function initStream() { var _stream = {}; // 빈 객체 생성 ... window.Stream = _stream; } |
그리고 사실 우리가 생성한 모든 스트림은 형식이 일괄적이니, 클래스 개념으로 묶는 것이 좋겠다. 최상위 객체에 BaseStream 객체가 있고, 이 스트림 객체가 고유한 스트림을 갖는다면, 다음과 같이 코드를 한 번만 작성하는 것으로 모든 스트림을 처리할 수 있을 것이다.
Stream.js (BaseStream) |
... function BaseStream(streamName) { this.streamName = streamName; } BaseStream.prototype.write = function(fmt) { ... var stream = document.getElementById(this.streamName); stream.value += result; } ... |
BaseStream에 대한 구현은 다음과 같다.
Stream.js (BaseStream) |
... /** 스트림의 상위 형식인 BaseStream을 정의합니다. @param {string} streamName */ function BaseStream(streamName) { this.streamName = streamName; } function stream_write(fmt) { var result; // 로그 스트림에 출력할 문자열을 보관합니다. var args = stream_write.arguments; if (args.length == 0) { // 인자의 수가 0이라면 result = ''; // 개행만 합니다. } else if (args.length > 1) { // 인자의 수가 1보다 크면 format을 호출합니다. var params = Handy.toArray(args, 1); // 인자 목록 배열을 획득합니다. result = Handy.vformat(fmt, params); // 형식 문자열과 인자 목록을 같이 넘깁니다. } else { // 인자의 수가 1이면 그냥 출력합니다. result = fmt; } // 스트림에 출력합니다. var stream = document.getElementById(this.streamName); stream.value += (result); } function stream_writeln(fmt) { var result; // 로그 스트림에 출력할 문자열을 보관합니다. var args = stream_writeln.arguments; if (args.length == 0) { // 인자의 수가 0이라면 result = ''; // 개행만 합니다. } else if (args.length > 1) { // 인자의 수가 1보다 크면 format을 호출합니다. var params = Handy.toArray(args, 1); // 인자 목록 배열을 획득합니다. result = Handy.vformat(fmt, params); // 형식 문자열과 인자 목록을 같이 넘깁니다. } else { // 인자의 수가 1이면 그냥 출력합니다. result = fmt; } // 스트림에 출력합니다. var stream = document.getElementById(this.streamName); stream.value += (result + NEWLINE); } function stream_clear() { // 스트림을 비웁니다. var stream = document.getElementById(this.streamName); stream.value = ''; } BaseStream.prototype.write = stream_write; BaseStream.prototype.writeln = stream_writeln; BaseStream.prototype.clear = stream_clear;
... |
그리고 이들을 각각 다음과 같이 Stream 객체에 등록하자.
Stream.js (BaseStream) |
... // Stream 객체에 등록합니다. _stream.out = new BaseStream('outputStream'); _stream.mem = new BaseStream('memoryStream'); _stream.reg = new BaseStream('registerStream'); _stream.exp = new BaseStream('expressionStream'); _stream.log = new BaseStream('HandyLogStream'); ... |
마지막으로 사용 가능하도록 main.html 파일의 코드를 수정한다.
main.html <html.head> |
... <!-- 컴파일러를 위한 모듈 목록입니다. --> <script src="Stream.js"></script> ... <script> ... function init() { initHandyFileSystem(); initStyle(); initStream(); ... |
그러면 우리는 다음과 같이 스트림을 사용할 수 있게 된다.
Runner.js (run) |
... // 식을 분석하고 실행한 결과를 스트림에 출력합니다. Stream.exp.writeln(opcode); Stream.reg.writeln('eax = %08x, ebx = %08x, ecx = %08x, edx = %08x', Register.eax, Register.ebx, Register.ecx, Register.edx); Stream.reg.writeln('ebp = %02x, esp = %02x, eip = %08x, eflags = %08b', Register.ebp, Register.esp, Register.eip, Register.eflags); ... |
이와 같이 Stream 객체를 생성하고 스트림을 Runner와 분리할 수 있었다.
5.4) 특수 니모닉 handy
일단 print_number, print_letter와 같은 것들은 기본적으로 니모닉이 아니다. 하지만 여기에선 임시로 니모닉처럼 쓰고 있었는데, 이는 당연히 좋지 않은 행위고 따라서 여기서는 이들을 기존의 니모닉에서 분리할 방법을 찾는 것이 좋겠다. 필자는 그 방법으로 handy 니모닉이라는 것을 제안한다.
handy 니모닉은 어려운 것이 아니다. 그냥 handy를 니모닉처럼 쓰고 left 피연산자로 하려는 동작을 넘긴다. 그 다음 right 피연산자로 동작을 위해 필요한 인자를 넘기면 된다. 다음은 이를 설명하는 코드다.
print_line2.hda |
... handy print_number, 10 handy print_letter, 65 handy print_string, "Hello, world!\n" ... |
그리고 이는 다음과 같이, 어렵지 않게 구현이 된다.
Operate.js (handy) |
... function handy(left, right) { if (left == 'print_number') { var value;
// 레지스터라면 레지스터의 값을 얻는다. if (Register.is_register(right)) { value = Register[right]; } // 레지스터가 아니라면 인자를 정수로 변환한다. else { value = parseInt(right); }
// 획득한 값을 출력한다. Stream.out.write(value); } else if (left == 'print_letter') { var value;
// 레지스터라면 레지스터의 값을 얻는다. if (Register.is_register(right)) { value = Register[right]; } // 레지스터가 아니라면 인자를 정수로 변환한다. else { value = parseInt(right); }
// 획득한 값으로부터 문자를 획득하고 이를 출력한다. Stream.out.write(String.fromCharCode(value)); } else if (left == 'print_string') { Stream.out.write(right); } } ... |
이와 같이 handy를 특수 니모닉으로 설정하고 행동을 정의할 수 있었다.
6. 메모리
피연산자로 레지스터와 즉시 값이 들어오는 경우는 처리하는 방법을 배웠다. 그런데 메모리는 어떻게 접근해야 할까? 이 절에서는 이 내용을 다룬다. 메모리에 접근하는 방법을 다루기 전에 프로시저를 먼저 알아보자.
6.1) 프로시저
다음은 _sum 프로시저를 정의하고 호출하여 ebx, ecx 레지스터의 합을 구하는 코드다.
procedure.hda |
.code ;=========================== ; proc main ;=========================== _main: push ebp mov ebp, esp
mov ebx, 10 mov ecx, 20 call _sum handy print_number, eax
_end: mov esp, ebp pop ebp exit
;=========================== ; proc sum ; receives: ebx ; ecx ; returns: eax = ebx + ecx ;=========================== _sum: mov edx, ebx mov eax, ecx add eax, edx ret |
여기서 중요한 두 니모닉은 call과 ret이다. 그런데 이 둘의 구현은 4장에 나온 다음 그림을 참조하면 그리 어렵지 않게 구현할 수 있다.
4장을 참조하여 이 그림을 자세히 보라. 그러면 call 명령은 다음 명령의 위치를 푸시한 다음 인자 값에 해당하는 메모리로 점프하는 것이 끝이다. ret도 마찬가지다. 팝 연산을 수행하여 복귀할 명령 위치를 획득한 후 해당 위치로 점프하는 것이 끝이다. 따라서 이 두 니모닉의 구현은 다음과 같이 단순하게 처리할 수 있다.
Operate.js (call, ret) |
function call(left) { push(Memory.bytePtr); // 다음 명령의 위치를 푸시합니다. jmp(left); // 인자 값의 위치로 점프합니다. } function ret() { pop('push'); // 복귀할 명령 주소를 팝합니다. Memory.bytePtr = Register.eip; // 해당 주소로 명령 포인터를 맞춥니다. } |
아직 처리하지 않은 add 니모닉과 그 반대인 sub 니모닉도도 이 참에 다음과 같이 처리를 하자.
Operate.js (add, sub) |
function add(left, right) { if (Register.is_register(left)) { // 레지스터라면 var value;
if (Register.is_register(right)) // right도 레지스터라면 value = Register[right]; // 레지스터의 값을 가져옵니다. else // 레지스터가 아니면 정수로 간주하고 값만 가져옵니다. value = parseInt(right);
Register[left] += value; // 레지스터에 더합니다. } } function sub(left, right) { if (Register.is_register(left)) { // 레지스터라면 var value;
if (Register.is_register(right)) // right도 레지스터라면 value = Register[right]; // 레지스터의 값을 가져옵니다. else // 레지스터가 아니면 정수로 간주하고 값만 가져옵니다. value = parseInt(right);
Register[left] -= value; // 레지스터에서 뺍니다. } } |
우리의 예상대로라면 프로그램은 다음과 같이 실행될 것이다.
예상 결과 (expression) |
push ebp mov ebp,esp mov ebx,10 mov ecx,20 call 0x006b mov edx,ebx mov eax,ecx add eax,edx ret handy print_number,eax mov esp,ebp pop ebp |
그런데 실제로 프로그램을 실행하면 이런 결과를 얻는다.
실행 결과 (expression) |
push ebp mov ebp,esp mov ebx,10 mov ecx,20 call 0x006b mov edx,ebx mov eax,ecx add eax,edx ret |
어디가 문제일까? 사실 이건 필자가 이전 문서에서 구현을 실수한 것이다. 한 번 직접 찾아보지 않겠는가?
정답은, Operate.js에 정의한 push 메서드가 틀렸다는 것이다. 다음과 같이 수정하자.
Operate.js (push) |
function push(left) { // esp의 값이 4만큼 줄었다. Register.esp -= 4;
var value; if (Register.is_register(left)) // 레지스터라면 레지스터의 값을 획득합니다. value = Register[left]; else // 레지스터가 아니라면 정수로 간주하고 값을 획득합니다. value = parseInt(left);
// esp가 가리키는 위치의 메모리에서 4바이트만큼을 ebp로 채웠다. Memory.set_dword(value, Register.esp); } |
이 문제까지 수정하고 프로그램을 실행하여 다음 결과를 얻는다.
실행 결과 (expression) |
push ebp mov ebp,esp mov ebx,10 mov ecx,20 call 0x006b mov edx,ebx mov eax,ecx add eax,edx ret handy print_number,eax mov esp,ebp pop ebp |
이와 같이 메모리에 접근하지 않는 프로시저는 간단하게 구현할 수 있다.
6.2) 메모리에서 값을 획득하기
이제 메모리에 접근해야 한다. 메모리! 우리는 5장에서 NASM을 공부하면서, 메모리에 접근하려면 대괄호를 사용해야 함을 배웠다. HASM도 비슷한 방식으로 메모리에 접근한다.
memory.hda |
.code ;=========================== ; proc main ;=========================== _main: push ebp mov ebp, esp
push 10 push 20 call _sum add esp, 8 handy print_number, eax
_end: mov esp, ebp pop ebp exit
;=========================== ; proc sum ; receives: 2 Integers ; returns: eax which is ; sum of params ;=========================== _sum: push ebp mov ebp, esp mov edx, [ebp+0x8] mov eax, [ebp+0xc] add eax, edx mov esp, ebp pop ebp ret |
그럼 바로 시작해보자. 핵심은 가장 아래 메모리에 접근하는 부분이다.
mov edx, [ebp+0x8] mov eax, [ebp+0xc] |
일단 이것을 메모리에 제대로 올리는 것부터 시작해야 한다. load 함수를 보자. 획득한 행을 분석하기 위해 이전에 정의한 decode를 사용하고 있음을 알 수 있다.
Runner.js (load.decode) |
... // 논리가 복잡해졌으므로 획득한 행을 분석합니다. var info = decode(line); ... |
따라서 우리는 decode 함수도 참조해야 한다.
Runner.js (decode) |
... // 다음 토큰 획득을 시도합니다. var left = buffer.get_token(); var right = null; if (left != null) { // 다음 토큰이 있는 경우의 처리 // 피연산자가 두 개인지 확인하기 위해 토큰 획득을 시도합니다. right = buffer.get_token(); ... |
여기서는 토큰을 획득하기 위해 get_token 메서드를 사용하고 있으므로, 결국 get_token 메서드까지 수정해야 한다는 결론이 나온다. 여기서 대괄호로 감싼 부분을 모두 획득할 수 있는 방법은, 대괄호의 시작부터 끝이 나타날 때까지의 모든 토큰을 일단 획득해놓는 것이다. 코드를 수정한다.
StringBuffer.js (get_token) |
... else if (ch == '[') { // 메모리의 시작이라면 result = ''; // 반환할 수 있도록 문자열을 초기화합니다. this.getc(); // 이미 획득한 토큰이므로 넘어갑니다. while (this.is_empty() == false) { // 버퍼가 비어있는 동안 if (this.peekc() == ']') // 닫는 대괄호가 나타났다면 탈출합니다. break; result += this.getc(); // 문자를 추가합니다. } result = '[' + result + ']'; } ... |
메모리 토큰이 모두 대괄호로 둘러싸여있다는 점을 이용하면 문자열이 메모리인지 판정할 수 있다.
function is_memory(param) { return ((param.charAt(0) == '[') && (param.charAt(param.length-1) == ']'); } |
그런데 이 녀석이 어디에 있어야 적절할까? Runner인가? StringBuffer인가? is_register는 Register 모듈에 있는데, 그러면 이 녀석은 is_memory이니 Memory에 넣어야 하는가?
사실, is_register는 Register 모듈에 속하는 것이 구현할 때 편리한 경우가 있다. 하지만 인자로 주어진 문자열이 메모리인지 판정하는 이 함수는, 사실 문자열만 확인하면 되므로 반드시 Memory 모듈에 있어야 할 필요는 없다. 일단 지금 결정이 서지 않으므로, Memory 모듈에 넣고 프로젝트를 진행하자.
Memory.js (is_memory) |
/** 주어진 인자가 메모리인지 판정합니다. @param {string} param @return {boolean} */ function is_memory(param) { return ((param.charAt(0) == '[') && (param.charAt(param.length-1) == ']')); } |
이대로는 사용하기 불편하므로 메모리에서 값을 가져오는 메서드도 정의한다. 다음을 고려하자.
1. 기본 형식은 [reg], [reg+imm], [reg-imm]입니다.
2. [reg]는 레지스터의 값만 획득합니다.
3. [reg+imm]은 레지스터의 값에 imm 값을 더한 만큼의 주소에 있는 값을 획득합니다.
4. [reg-imm]은 레지스터의 값에 imm 값을 뺀 만큼의 주소에 있는 값을 획득합니다.
이 내용을 바탕으로 메모리에서 4바이트 정수 값을 획득하는 get_memory_value를 구현한다.
Memory.js (get_memory_value) |
... /** 주어진 메모리로부터 4바이트 값을 획득합니다. @param {string} param @return {number} */ function get_memory_value(param) { var addr; // 값을 가져올 메모리의 주소 값입니다.
// 대괄호를 제외한 문자열을 획득하고, 이를 이용해 버퍼를 생성합니다. var buffer = new StringBuffer(param.slice(1, param.length-1));
// 가장 앞은 언제나 레지스터입니다. var reg = buffer.get_token();
// 획득한 레지스터가 보관하고 있는 값을 획득합니다. var regval = Register[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 Memory.get_dword(addr); } ... |
그리고 이렇게 추가한 is_memory를 이용하여 메모리에 대한 규칙도 모두 추가해주면 된다.
Operate.js (mov) |
function mov(left, right) { if (Register.is_register(right) == true) { // 레지스터라면 // 해당 레지스터의 값을 대입합니다. Register[left] = Register[right]; } else if (Memory.is_memory(right)) { // 메모리라면 // 메모리의 값을 대입합니다. Register[left] = Memory.get_memory_value(right); } else { // 레지스터가 아니라면 // 정수로 간주하고, 정수로 변환하여 대입합니다. Register[left] = parseInt(right); } } |
그리고 프로그램을 실행하여 다음 결과를 얻는다.
실행 결과 (memory) |
0x0000 | 00 00 00 00 70 75 73 68 20 65 62 70 00 6d 6f 76 | ....push ebp.mov 0x0010 | 20 65 62 70 2c 65 73 70 00 70 75 73 68 20 31 30 | ebp,esp.push 10 0x0020 | 00 70 75 73 68 20 32 30 00 63 61 6c 6c 20 30 78 | .push 20.call 0x 0x0030 | 30 30 36 66 00 61 64 64 20 65 73 70 2c 38 00 68 | 006f.add esp,8.h 0x0040 | 61 6e 64 79 20 70 72 69 6e 74 5f 6e 75 6d 62 65 | andy print_numbe 0x0050 | 72 2c 65 61 78 00 6d 6f 76 20 65 73 70 2c 65 62 | r,eax.mov esp,eb 0x0060 | 70 00 70 6f 70 20 65 62 70 00 65 78 69 74 00 70 | p.pop ebp.exit.p 0x0070 | 75 73 68 20 65 62 70 00 6d 6f 76 20 65 62 70 2c | ush ebp.mov ebp, 0x0080 | 65 73 70 00 6d 6f 76 20 65 64 78 2c 5b 65 62 70 | esp.mov edx,[ebp 0x0090 | 2b 30 78 38 5d 00 6d 6f 76 20 65 61 78 2c 5b 65 | +0x8].mov eax,[e 0x00a0 | 62 70 2b 30 78 63 5d 00 61 64 64 20 65 61 78 2c | bp+0xc].add eax, 0x00b0 | 65 64 78 00 6d 6f 76 20 65 73 70 2c 65 62 70 00 | edx.mov esp,ebp. 0x00c0 | 70 6f 70 20 65 62 70 00 72 65 74 00 00 00 00 00 | pop ebp.ret..... 0x00d0 | 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 | ................ ... |
실행 결과 (expression) |
push ebp mov ebp,esp push 10 push 20 call 0x006f push ebp mov ebp,esp mov edx,[ebp+0x8] mov eax,[ebp+0xc] add eax,edx mov esp,ebp pop ebp ret add esp,8 handy print_number,eax mov esp,ebp pop ebp |
실행 결과 (register) |
... eax = 00000000, ebx = 00000000, ecx = 00000000, edx = 00000014 ebp = 3ec, esp = 3ec, eip = 00000004, eflags = 00000000 eax = 0000000a, ebx = 00000000, ecx = 00000000, edx = 00000014 ebp = 3ec, esp = 3ec, eip = 00000004, eflags = 00000000 eax = 0000001e, ebx = 00000000, ecx = 00000000, edx = 00000014 ebp = 3ec, esp = 3ec, eip = 00000004, eflags = 00000000 ... |
코드를 보면서 아직 처리하지 않은 부분이 많다는 사실을 알았을 수도 있다. 이는 예제를 진행하면서 생각이 날 때마다 수정하는 것으로 하겠다.
7. 데이터 세그먼트
이전까지는 코드 세그먼트에서 일어나는 일들을 다루었다. 데이터 세그먼트의 필요성 자체는 당장 설명할 수는 없지만 느끼고는 있었을 것이라 생각한다. 데이터 세그먼트를 이용하게 되면 다음과 같은 코드의 구현이 가능해진다.
puts.hda |
.data _sHelloWorld db 'Hello, world!', 0
.code ;=========================== ; proc main ;=========================== _main: push ebp mov ebp, esp
push _sHelloWorld call _puts add esp, 4
_end: mov esp, ebp pop ebp exit
;=========================== ; proc puts ;=========================== _puts: push ebp mov ebp, esp handy puts, [ebp+0x8] mov esp, ebp pop ebp ret |
이것이 이 문서의 마지막 주제다. 바로 시작하자. 참고로 *아주* 헷갈린다.
7.1) puts 구현
일단 handy 특수 니모닉의 인자로 puts가 들어가니, 이것을 먼저 구현하자.
Operate.js (handy.puts) |
... else if (left == 'puts') { var value = null;
// 레지스터라면 레지스터 값 획득 if (Register.is_register(right)) { value = Register[right]; } // 메모리라면 메모리 값 획득 else if (Memory.is_memory(right)) { value = Memory.get_memory_value(right); } // 모두 아니라면 즉시 값 획득 else { value = parseInt(right); }
// 현재 바이트 포인터는 보존합니다. var prevBytePtr = Memory.bytePtr;
// 문자열을 출력할 위치로 바이트 포인터를 맞춥니다. Memory.bytePtr = value;
// 널이 아닐 때까지 문자를 출력합니다. var s = ''; while (true) { var byte = Memory.read_byte(); // 바이트를 획득합니다. if (byte == 0) // 널 문자가 나타났다면 종료합니다. break; s += String.fromCharCode(byte); // 바이트로부터 문자를 획득합니다. }
// 획득한 문자열을 출력합니다. Stream.out.write(s);
// 이전 바이트 포인터를 복구합니다. Memory.bytePtr = prevBytePtr; } ... |
이미 프로그램이 어떻게 동작하고 있는지 아는 만큼, 이를 이해하는 것은 어렵지 않다.
7.2) 세그먼트 분리
혹시 load 메서드에서 segment라는 변수가 있었던 것을 기억하는가?
Runner.js (load) |
... // 획득한 텍스트를 메모리에 기록합니다. var labelInfoDict = {}; // LabelInfo에 대한 딕셔너리 객체입니다. var segment = null; for (var i=0, len=lines.length; i<len; ++i) { try { ... else if (line.charAt(0) == '.') { // 세그먼트를 처리합니다. // 세그먼트 정보를 보관합니다. // 이후에 나타나는 코드의 영역을 결정하기 위해 사용합니다. segment = line; continue; } ... |
바로 이것을 여기서 사용한다. 실제 segment는 다음과 같이 정의한다.
Runner.js (load) |
var segment = { data: new Array(), code: new Array() }; |
그리고 이전에는 코드를 분석하면서 메모리에 바로 기록하던 것을,
아래 그림과 같이 segment 객체에 먼저 보관해놓은 후,
이 두 세그먼트 정보를 종합하여 기록하는 식으로 작성한다.
이 과정 또한 아주 헷갈리는데, 이는 데이터 세그먼트가 코드 세그먼트의 앞에 추가되면서 레이블의 위치가 바뀌기 때문이다. 이에 대해서는 나중에 다루고, 일단 이들을 획득해보자.
먼저 StringBuffer의 get_number에 존재하는 버그 하나를 해결한다.
StringBuffer.js (get_number) |
... if (this.is_empty()) // 더 획득할 수가 없다면 0을 반환합니다. return '0'; // return 0; ... |
그리고 이제부터 레이블의 주소를, 세그먼트의 주소에 대한 오프셋으로 정의한다. LabelInfo 형식의 정의 중 address 필드를 offset으로 변경한다. 또한 레이블은 데이터와 코드 세그먼트에 따라 시작점이 다르기 때문에, 레이블이 어느 세그먼트 소속인지도 보관하고 있어야 한다. 또한 문자열이 레이블의 이름인지 판정하는 함수도 정의한다.
Runner.js (LabelInfo) |
/** 레이블 정보를 표현하는 형식 LabelInfo를 정의합니다. @param {string} segmentName @param {string} name */ function LabelInfo(segmentName, name) { this.segmentName = segmentName; this.name = name; this.offset = 0; this.refered = []; } /** 인자가 레이블 이름인지 판정합니다. @param {string} param @return {boolean} */ function is_label_name(param) { return (param.charAt(0) == '_'); } |
데이터 세그먼트의 데이터는 db, dw나 BYTE와 같이, 모두 각자의 크기를 가지고 있다. 따라서 db와 같이 형식 문자열을 획득했을 경우 이것이 1바이트임을 알리는 함수도 필요하다. 그러한 함수를 정의하겠다.
Runner.js (getDataTypeSize) |
/** 데이터 타입의 크기를 반환합니다. db이면 1을 반환하는 식입니다. @param {string} datatype @return {number} */ function getDataTypeSize(datatype) { switch (datatype.toLowerCase()) { // 소문자 문자열로 변경하고 확인합니다. case 'db': case 'byte': return 1; case 'dw': case 'word': return 2; case 'dd': case 'dword': return 4; } } |
일괄적으로 소문자 문자열로 변경한 다음 확인한다는 사실만 눈여겨보면 된다.
그리고 세그먼트를 분석하기 위해 다음 변수들을 준비한다.
Runner.js (load.segment_ready) |
... // 각각의 세그먼트에 들어갈 정보를 리스트 식으로 관리합니다. var segment = { data: [], code: [] }; // 순서대로 데이터나 코드를 추가합니다. var segment_target = null; // 현재 분석중인 세그먼트입니다.
var baseOfData = 0; // 데이터 세그먼트의 시작 지점입니다. var sizeOfData = 0; // 데이터 세그먼트의 전체 크기입니다. var baseOfCode = 0; // 코드 세그먼트의 시작 지점입니다. var sizeOfCode = 0; // 코드 세그먼트의 전체 크기입니다. ... |
그 다음 등장하는, 데이터 세그먼트에서 데이터를 획득하는 과정은 다음과 같다.
Runner.js (load.dataseg) |
... // 세그먼트에 정의되는 정보를 처리합니다. if (segment_target == null) // 세그먼트가 정의되지 않았다면 예외 처리합니다. throw new RunnerException("segment is null"); // 데이터 세그먼트를 처리합니다. else if (segment_target == 'data') { var label = null, type, value;
// 줄에 대한 버퍼를 생성합니다. var buffer = new StringBuffer(line);
// 첫 단어를 획득해봅니다. var token = buffer.get_token(); if (is_label_name(token)) { // 획득한 토큰이 레이블이라면 label = token; // 토큰은 레이블의 이름이 됩니다.
// 획득한 레이블 이름을 바탕으로 레이블 정보 객체를 생성합니다. var labelInfo = new LabelInfo(segment_target, label);
// 레이블의 주소는 세그먼트 시작점 + 현재까지 획득한 데이터 크기입니다. // 따라서 오프셋은 현재까지 획득한 데이터 크기가 됩니다. labelInfo.offset = sizeOfData;
// 생성한 정보를 딕셔너리에 등록합니다. labelInfoDict[label] = labelInfo;
token = buffer.get_token(); // 다음 토큰인 형식을 획득합니다. } type = token; // 형식을 보관합니다.
// 값은 토큰 배열로 보관합니다. var tokenArray = []; // 값을 보관할 토큰 배열입니다. var dataCount = 0; // 데이터 세그먼트에 올라갈 값의 개수입니다. do { // token, token, token, ..., token 형식의 문장을 분석합니다. token = buffer.get_token(); // 토큰을 획득합니다. if (token == null) // 획득에 실패하면 더 획득할 토큰이 없는 것입니다. break;
tokenArray.push(token); // 획득한 토큰을 토큰 배열에 넣습니다.
// 정수가 아니라면 문자열로 간주하고 따옴표를 제거한 크기를 더합니다. // 토큰의 길이는 곧 토큰 요소의 수입니다. if (is_digit(token.charAt(0)) == false) dataCount += token.length - 2; else dataCount += token.length;
token = buffer.get_token(); // 다시 토큰을 획득하여 반점인지 확인합니다. if (token == null) // 다음 토큰이 없다면 break; else if (token != ',') // 다음 토큰이 반점이 아니라면 예외 처리합니다. throw new RunnerException ('load: value in data segment must be separated with comma');
// 마지막에 반점이 있으나 없으나 상관없습니다. } while (buffer.is_empty() == false); value = tokenArray; // 값을 토큰 배열로 맞춥니다.
// 전체 데이터의 크기에 추가한 토큰의 크기를 더합니다. var typeSize = getDataTypeSize(type); // 토큰 요소 각각의 크기입니다.
// 토큰 크기 = 토큰 요소 크기 * 토큰 요소 개수 sizeOfData += typeSize * dataCount;
// 데이터 세그먼트에 형식과 값을 저장하는 객체를 보관합니다. segment.data.push({ typeSize:typeSize, value:value }); } ... |
데이터를 획득하는 과정이 끝나고 나면, 파일 분석이 완료된 후 이것을 메모리에 올린다.
Runner.js (load.up_data_to_mem) |
... // 데이터 세그먼트를 메모리에 올립니다. for (var i=0, dataCount=segment.data.length; i<dataCount; ++i) { // 데이터 세그먼트에는 {typeSize, value} 형식으로 저장했습니다. var dataInfo = segment.data[i]; if (dataInfo == null) { continue; }
// 저장한 정보를 바탕으로 메모리에 올립니다. var dataTypeSize = dataInfo.typeSize; var writeMethod = null; // 메모리에 값을 기록하는 방법을 결정합니다. switch (dataTypeSize) { // 데이터 형식의 크기를 기준으로 case 1: writeMethod = 'write_byte'; break; case 2: writeMethod = 'write_word'; break; case 4: writeMethod = 'write_dword'; break; default: throw new RunnerException("invalid data typw size"); }
var tokenArray = dataInfo.value; // 토큰을 메모리에 기록합니다. for (var j=0, tokenCount=tokenArray.length; j<tokenCount; ++j) { var token = tokenArray[j];
// 숫자라면 정수로 변환한 다음 이를 기록합니다. if (is_digit(token.charAt(0))) { var value = parseInt(token); Memory[writeMethod](value); } // 문자열이라면 각각의 요소에 대해 반복문을 실행합니다. else { for (var k=1, len=token.length-1; k<len; ++k) { var value = token.charCodeAt(k); Memory[writeMethod](value); } } } // 토큰 기록 종료 } ... |
이제 코드 세그먼트의 처리가 남았다. 코드 세그먼트의 처리가 어려운 것은 레이블을 실제로 대치하는 구문이 코드 세그먼트에만 존재하기 때문이다. 그런데 레이블을 대치하려면 레이블의 정의와, 레이블의 참조 위치를 이용한다. 결국 레이블의 정의와 참조의 값만 잘 계산하면 이 문제는 끝이 난다. 하지만 이는 꽤 헷갈리니 주의해야 한다.
이전에는 참조 위치를 결정하기 위해 Memory.bytePtr를 이용했다. 하지만 아까도 설명했듯 이제는 메모리에 코드 바이트를 직접 기록하지 않고 다른 위치에 보관했다가 한 번에 옮기기 때문에, 이 방법은 다시 한 번 생각해보아야 한다. 일단 레이블에 대한 처리 구문에서 아직 address 필드를 사용하고 있으므로, 이를 offset으로 바꾸자.
Runner.js (load.codeseg.label) |
... // 레이블 딕셔너리에 정보를 등록합니다. labelInfoDict[label].offset = Memory.bytePtr; continue; ... |
일단 정의는 상대적으로 쉽다. 어차피 우리는 오프셋을 사용하기 때문에, 획득한 코드의 전체 길이만 알고 있으면 이것이 바로 레이블의 오프셋이 된다. 따라서 코드의 크기를 지속적으로 갱신하는 코드만 추가해주면, 정의는 다음과 같이 처리하는 것으로 끝난다.
Runner.js (load.codeseg.label) |
... // 레이블 딕셔너리에 정보를 등록합니다. labelInfoDict[label].offset = sizeOfCode; continue; ... sizeOfCode += ...; ... |
그럼 참조 위치는? 참조 위치라고 해봐야 현재 분석 중인 명령에서 (획득한 니모닉의 길이+1)만큼만 더해주면 된다. 1을 더한 건 공백 한 칸이 있기 때문이다. 이는 우리가 이전에 구현했던 코드를 통해서도 확인할 수 있다.
Runner.js (load.codeseg.label) |
... // 참조 위치는 (현재 바이트 포인터 위치 + 니모닉의 길이 + 공백 한 칸)입니다. var refered_addr = Memory.bytePtr + s.length; ... |
따라서 참조 위치도 그냥 현재까지 획득한 코드의 전체 길이로 Memory.bytePtr를 대체하면 된다.
Runner.js (load.codeseg) |
... // 참조 위치는 (현재 바이트 포인터 위치 + 니모닉의 길이 + 공백 한 칸)입니다. var refered_addr = sizeOfCode + s.length; ... |
총 코드 길이는 획득한 줄 텍스트를 보관하는 문장에서 계산하면 된다. 널 문자까지 세어야 하므로 보관하는 문자열의 길이에 1을 더한 값을 더한다.
Runner.js (load.codeseg) |
... // 획득한 줄 텍스트를 보관합니다. segment[segment_target].push(s); // 널 문자까지 세서 현재까지 획득한 코드 길이를 구합니다. sizeOfCode += (s.length + 1); ... |
그런데 이제 이를 이용해 레이블을 대치하려면, 지금까지 획득한 코드의 길이가 아니라 데이터 세그먼트의 시작점과 데이터 세그먼트의 전체 크기가 필요하다. 데이터 세그먼트의 끝은 바로 코드 세그먼트의 시작점이 되기 때문이다. 따라서 이들을 이용해 코드 세그먼트의 시작점을 구하면, 레이블을 정의로 대치하는 과정은 다음 코드로 정리가 된다.
Runner.js (load.up_code_to_mem) |
... // 코드 세그먼트를 메모리에 올립니다. for (var i=0, codeCount=segment.code.length; i<codeCount; ++i) { var line = segment.code[i]; for (var j=0, len=line.length; j<len; ++j) Memory.write_byte(line.charCodeAt(j)); Memory.write_byte(0); }
// 모든 참조된 레이블을 정의로 대치합니다. baseOfData = 4; baseOfCode = baseOfData + sizeOfData; // 코드 세그먼트의 시작점을 획득합니다. for (label in labelInfoDict) { // LabelInfo 정보를 가져옵니다. var info = labelInfoDict[label]; var base = (info.segmentName == 'data') ? baseOfData : baseOfCode;
// 참조된 레이블을 정의로 대치합니다. var arr = info.refered; for (var i=0, len=arr.length; i<len; ++i) { // 대치해야 할 메모리 위치로 바이트 포인터를 이동합니다. Memory.bytePtr = baseOfCode + arr[i];
// 참조된 레이블의 정의를 16진수 문자열로 획득합니다. var address = base + info.offset; var refered_addr = Handy.format('0x%04x', address);
// 해당 참조 위치에 정의 값을 덮어씌웁니다. for (var j=0, slen=refered_addr.length; j<slen; ++j) Memory.write_byte(refered_addr.charCodeAt(j)); } } log('load complete'); ... |
마지막으로 프로그램 시작 위치가 바뀌었으니, 이 지점에서 프로그램이 실행되도록 맞춘다.
Runner.js (run) |
function run() { // 코드 영역의 시작 지점으로 바이트 포인터를 옮깁니다. Memory.bytePtr = this.baseOfCode; ... |
그리고 프로그램을 실행하여 다음 결과를 얻는다.
실행 결과 (memory) |
0x0000 | 00 00 00 00 48 65 6c 6c 6f 2c 20 77 6f 72 6c 64 | ....Hello, world 0x0010 | 21 00 70 75 73 68 20 65 62 70 00 6d 6f 76 20 65 | !.push ebp.mov e 0x0020 | 62 70 2c 65 73 70 00 70 75 73 68 20 30 78 30 30 | bp,esp.push 0x00 0x0030 | 30 34 00 63 61 6c 6c 20 30 78 30 30 36 32 00 61 | 04.call 0x0062.a 0x0040 | 64 64 20 65 73 70 2c 34 00 6d 6f 76 20 65 73 70 | dd esp,4.mov esp 0x0050 | 2c 65 62 70 00 70 6f 70 20 65 62 70 00 65 78 69 | ,ebp.pop ebp.exi 0x0060 | 74 00 70 75 73 68 20 65 62 70 00 6d 6f 76 20 65 | t.push ebp.mov e 0x0070 | 62 70 2c 65 73 70 00 68 61 6e 64 79 20 70 75 74 | bp,esp.handy put 0x0080 | 73 2c 5b 65 62 70 2b 30 78 38 5d 00 6d 6f 76 20 | s,[ebp+0x8].mov 0x0090 | 65 73 70 2c 65 62 70 00 70 6f 70 20 65 62 70 00 | esp,ebp.pop ebp. 0x00a0 | 72 65 74 00 00 00 00 00 00 00 00 00 00 00 00 00 | ret............. ... |
실행 결과 (output) |
Hello, world! |
이와 같이 데이터 세그먼트를 처리할 수 있었다.
8. 단원 마무리
처음에는 가상 머신 개발이라는 이 단원을, 한 문서에 정리할 수 있을 거라고 생각했다. 몇 페이지만 더 쓰면 될 줄 알고 합치지 못한 것을 아쉬워했는데, 이제 보니 잘 한 거라는 생각이 든다.
다음은 링커고, 링커 다음은 컴파일러다. 열심히 하자.
'알려주기' 카테고리의 다른 글
[JSCC] 11. C 컴파일러 개발 (기본편) (6) | 2015.07.17 |
---|---|
[JSCC] 10. 링커 개발 (0) | 2015.07.10 |
[JSCC] 8. 가상 머신 개발 1 (2) | 2015.06.26 |
[JSCC] 7. JSCC 준비 (0) | 2015.06.19 |
Ubuntu Tips (0) | 2015.06.18 |