링커 개발

HandyPost는 한 도영(HDNua)이 작성하는 포스트 문서입니다.


소스: 

10_Linker.zip


문서: 

10. 링커 개발.pdf

 

1. 개요

파일을 분리하여 코드를 작성할 수 있도록 링커를 개발한다.

 

2. 전략

이 문서의 목표는 다음과 같은 두 HASM 소스 코드가 존재하면,

puts.hda

.code

;===========================

; proc puts

;===========================

_puts:

push ebp

mov ebp, esp

handy puts, [ebp+0x8]

mov esp, ebp

pop ebp

ret

main.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

이를 다음과 같은 하나의 코드로 합치는 것이다.

a.out

.data

_sHelloWorld db 'Hello, world!', 0

 

.code

;===========================

; proc puts

;===========================

_puts:

push ebp

mov ebp, esp

handy puts, [ebp+0x8]

mov esp, ebp

pop ebp

ret

 

;===========================

; proc main

;===========================

_main:

push ebp

mov ebp, esp

 

push _sHelloWorld

call _puts

add esp, 4

 

_end:

mov esp, ebp

pop ebp

exit

그러면 자연히 Runner가 프로그램을 잘 해석할 수 있게 될 것이다. 이것을 어떻게 구현할지 먼저 생각하는 시간을 가져보자.

 

2.1) 기본

각각의 HASM 소스 코드는 다음과 같은 형태를 띠고 있을 것이다.



 



이들을 하나의 소스에 묶는다면, 결국 이런 식으로 묶어야지 않겠는가?



우리는 정확히 이대로 프로그램을 구현할 것이다.

 

2.2) 순서 문제

좀만 더 깊이 생각해보자. 코드가 다음과 같이 구현되어있는 경우를 생각한다.

src1.hda

.data

_sHello db 'hello', 0

 

.code

_sayHello:

handy puts, _sHello

ret

src2.hda

.data

_sHi db 'hi', 0

 

.code

_sayHi:

handy puts, _sHi

ret

 

_main:

exit

이들을 링크한 결과를 그림으로 표현하면 다음과 같이 될 것이다.





그런데 사실, 파일을 링크한 결과는 다음과도 같아야 한다.





중요한 내용이다. 목적 파일을 링크한 순서가 프로그램 실행 결과에 영향을 미쳐서는 안 된다. 더 일반적인 예를 들어보자. 다음과 같은 식으로 소스 코드가 작성되어있다면,



이들을 바탕으로 생성된 다음의 링크 결과물은 모두 실행 결과가 완전히 같아야 한다.





이 문제는 아주 혼란스럽다. 프로그램을 구현하면서 생각하자.

 

2.3) 진입점 문제

우리가 지금까지 작성한 코드는 모두 main 프로시저가 코드 영역의 시작점에 있었다. 따라서 프로그램의 시작점이 곧 코드 영역의 시작점이었기 때문에, 진입점에 대해 별도로 처리를 할 필요가 없었다. 하지만 방금 링크 순서에 상관없이 프로그램이 동작해야 한다고 했다. 이는 main 프로시저 또한 해당한다. main 프로시저의 위치가 고정되어있지 않기 때문에, 우리는 프로그램을 링크할 때 main 프로시저를 찾아내야 한다. 이 문제 또한 구현하면서 생각하자.

 

3. 리팩터링

3.1) 점검

3장에서 우리는 링커와 컴파일러의 역할에 대해 배웠다.

- 소스 코드를 작성하고 파일로 저장한다.

- 저장한 소스 파일을 컴파일러를 이용하여 컴파일 한다. 목적 파일이 생성된다.

- 컴파일러가 생성한 목적 파일들을 링커를 이용하여 링크 한다. 실행 가능한 목적 파일이 생성된다.

이를 구현의 관점에서 다시 설명하면 이렇게 된다.

- C 프로그래밍 언어를 이용해 *.c 파일을 작성한다.

- Compiler 모듈이 *.c 파일을 컴파일 하여 *.hdo 목적 파일을 생성한다.

- Linker 모듈이 *.hdo 파일을 링크 하여 *.hdx 프로그램 파일을 생성한다.

- Runner 모듈이 *.hdx 프로그램 파일을 메모리에 불러온 다음 실행한다.

그런데 생각해보자. 우리가 만든 Runner 모듈은 *.hda 소스 파일을 바로 실행하는 도구였다. 하지만 위에서 보인 과정은 hda 소스 파일이 등장하지 않는다. 그럼 지금까지 만든 것은 무엇일까? 어셈블리 언어를 해석하는 도구를 만들면, 작성한 코드를 컴파일러를 이용하여 어셈블리 코드를 만들고 프로그램을 실행하려고 했던 것인데?

여기서 필자는 지금까지 작성하던 모듈이 근본적으로 문제가 있다는 생각을 한다. 이것이 문제가 있다고 생각한 이유는 필자가 RunnerLinker, Compiler를 번갈아 작업하면서 깨달은 것인데, 아무래도 여기에 압축해서 설명하는 것은 무리가 있다고 생각하여 연재가 끝난 다음에 기회가 있다면 포스트를 작성하려고 한다. 일단 jscc는 다음과 같은 식으로 작성할 것이다,

- C 프로그래밍 언어를 이용해 *.c 파일을 작성한다.

- Compiler 모듈이 *.c 파일을 컴파일 하여 *.hdo 목적 파일을 생성한다.

> *.c 파일은 HASM 언어로 번역된다.

- Linker 모듈이 *.hdo 파일을 링크 하여 *.hdx 프로그램 파일을 생성한다.

> *.hdx 파일은 Low Level Assembly 언어로 번역된다.

- Runner 모듈이 *.hdx 프로그램 파일을 메모리에 불러온 다음 실행한다.

> Runner 모듈은 Low Level Assembly에 대한 인터프리터가 된다.

따라서 우리는 작성한 모듈을 리팩터링 하는 과정을 먼저 거칠 것이다.

 

3.2) 설계

JSCC는 최종적으로 이러한 형태로 구현한다.



이 그림을 말로 풀어서 설명하겠다.

- 모듈을 크게 두 부분으로 나눈다. 하나는 컴파일’, ‘링크’, ‘실행 요청을 담당하는 JSCC 모듈이다. 하나는 프로그램을 실행하기 위해 준비하는 가상 머신이다.

- Memory 모듈은 Machine의 소속이 된다는 점을 제외하면 변하지 않는다.

- Register, Operate 모듈은 Processor라는 새 모듈의 소속이 된다. ProcessorLow Level Assembly를 직접 해석하여 실행하는 인터프리터다. 이 과정에서 각 모듈의 멤버 중 일부가 Processor 모듈로 이동할 수 있다.

- System 모듈은 실행 환경에 대한 설정 정보를 보관한다. 개행 문자와 같은 정의를 여기에 넣는다.

- Compiler 모듈은 작성한 *.c 파일을 컴파일 하여 *.hdo 목적 파일로 변환한다. *.hdo 목적 파일은 HASM 언어로 기록되어있다.

- Linker 모듈은 HASM 언어로 작성된 *.hdo 파일을 분석하고 묶어서, Machine이 이해할 수 있는 Low Level Assembly 언어로 변환한다. 원래는 CompilerLinker 사이에, HASM으로 번역된 코드를 Low Level Assembly 언어로 번역하는 Assembler 모듈이 있었지만, 여기서는 이를 생략하고 Linker가 이 번역도 같이 수행하는 것으로 하겠다.

- Runner 모듈은 어셈블리 언어를 직접 해석하는 인터프리터에서, Machine.Processor 모듈에 프로그램 실행을 요청하는 메신저의 역할을 하는 것으로 바뀐다. 이 과정에서 Runner 모듈의 멤버 중 일부가 Machine.Processor와 같은 모듈로 이동할 수 있다.

그럼 이제 링커를 개발하기 위한 토대를 마련해보자.

 

3.3) 반영

먼저 Machine, Program의 두 모듈을 위한 초기화 메서드를 만든다.

Machine.js

/**

JSCC의 실행을 담당하는 가상 기계를 생성합니다.

*/

function initMachine() {

var machine = {};

initMachineSystem(machine);

initMachineMemory(machine);

initMachineProcessor(machine);

initMachineOperation(machine);

window.Machine = machine;

}

Program.js

/**

JSCC 및 그 테스트 모듈을 관리하는 Program을 생성합니다.

*/

function initProgram() {

var program = {};

initProgramStream(program);

initProgramRunner(program);

window.Program = program;

}

Machine에 대해 먼저 처리하자. Machine의 부속 모듈에 대한 초기화 함수를 작성한다.

MachineSystem.js

/**

가상 머신의 공통적 요소를 관리합니다.

*/

function initMachineSystem(machine) {

}

같은 방식으로 다음 메서드를 모두 준비하면 된다.

function initMachineMemory(machine) { } // MachineMemory.js

function initMachineProcessor(machine) { } // MachineProcessor.js

function initMachineOperation(machine) { } // MachineOperation.js

Program에 대해서도 최소한의 준비만 해두자.

function initProgramRunner(program) { } // ProgramRunner.js

이제 작성했던 Memory 모듈의 initMemory() 메서드 내부에 있는 모든 코드를 Machine.Memory 모듈의 initMachineMemory() 메서드로 옮긴 다음 마지막 줄을 수정한다.

MachineMemory.js

function initMachineMemory(machine) {

var memory = {}; // 싱글톤 객체 생성

...

// 전역에 싱글톤 등록

window.MemoryException = MemoryException;

machine.Memory = memory; // machine 모듈의 멤버로 넣습니다.

}

다만, Memory 모듈이 전역에서 Machine 모듈의 내부로 옮겨갔기 때문에 Memory를 직접 참조하는 부분을 다음과 같이 수정해야 한다.

var addr_end = Memory.MaxSize() - 1; -> var addr_end = Machine.Memory.MaxSize() - 1;

var byte = Memory.get_byte(addr); -> var byte = Machine.Memory.get_byte(addr);

Memory.get_dword(addr) -> Machine.Memory.get_dword(addr);

이는 Operate 모듈도 다르지 않다.

MachineOperation.js

function initMachineOperation(machine) {

var operate = {};

...

machine.Operate = operate; // machine 모듈의 멤버로 넣습니다.

}

하지만 Runner 모듈의 경우는, 그 내용이 바뀌기 때문에 그대로 적용할 수 없다. 다음과 같이 빈 메서드만 준비해놓자.

ProgramRunner.js

/**

Machine에 프로그램 실행을 요청합니다.

*/

function initProgramRunner(program) {

var runner = {};

function load(filename) { }

function run() { }

runner.load = load;

runner.run = run;

program.Runner = runner;

}

Stream 또한 Program의 소속이므로 모듈을 추가한다.

ProgramStream.js

function initProgramStream(program) {

var _stream = {}; // 빈 객체 생성

...

program.Stream = _stream;

}

이때 StreamProgram의 속성으로 옮겨가면서 Stream을 참조하는 코드 또한 변경된다.

Stream.mem.writeln(mem_str); -> Program.Stream.mem.writeln(mem_str);

그리고 main.html 파일을 다음과 같이 수정한다.

main.html <html.head>

...

<!-- 컴파일러를 위한 모듈 목록입니다. -->

<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="ProgramRunner.js"></script>

...

main.html <html.head.script>

// Memory 테스트 예제입니다.

function main() {

Program.Runner.load('main.hda');

Program.Runner.run();

Machine.Memory.show();

}

function init() {

initHandyFileSystem();

initStyle();

initMachine();

initProgram();

}

...

그리고 이를 테스트를 위해 실행하면 이런 결과가 나온다.

실행 결과 (memory)

0x0000 | 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 | ................

...

이렇게 링커를 개발하기 위한 토대는 일단 마련이 된다.

 

3.4) Low Level Assembly

위에서 Machine.ProcessorLow Level Assembly라는 어셈블리 언어에 대한 인터프리터라고 말했다. 또한 Linker 모듈은 HASM 언어로 작성된 파일을 Low Level Assembly로 변환한다고 되어있다. 이것을 하려면 우리가 HASMLow Level Assembly로 변환하는 방법은 알고 있어야 한다.

사실 이건 말이 어렵지 실제로는 전혀 어렵지 않다. 예제를 통해 설명하겠다. 다음과 같은 HASM 코드가 있다면,

putn.hdo

.data

_sHelloWorld db 'hello, world!', 0

 

.code

; proc main

_main:

push ebp

mov ebp, esp

handy puts, _sHelloWorld

mov esp, ebp

pop ebp

exit

Linker는 이를 다음과 같이 변환하는 것이 전부인 모듈이다.

Program.hdx

.data

db 68, 65, 6, 6, 6, 2, 20, 77, 6, 72, 6, 64, 21, 0

 

.code

push ebp

mov ebp, esp

handy puts, 0x0004

mov esp, ebp

pop ebp

exit

문자열과 레이블이 즉시 값으로 바뀐다. 그거 말고도, 비슷한 방식으로 해석하기 쉽게 변환하는 것이 끝이다. 사실 이 예제에서는 이미 구현한 어셈블리 언어 해석기를 거의 그대로 사용할 것이기 때문에, 이 부분에서 그렇게 심각하게 고민할 필요는 없다.

 

4. 실행기 재작성

4.1) Machine.Processor.Register

실행기의 개발은 이미 해봤다. 몇 가지 바뀌는 건 있지만 기본은 같으므로 코드를 이해하는 것은 어렵지 않다. 먼저 이전에 Runnner 역할을 하던 모듈이 Processor 모듈로 옮겨갔기 때문에, Processor 모듈을 먼저 완성해야 한다. 이때 Register 모듈이 Processor 모듈의 부속 모듈이 된다.

MachineProcessor.js (initMachineProcessorRegister)

function initMachineProcessorRegister(processor) {

// Register 모듈을 정의합니다.

var register = {};

...

processor.Register = register;

}

그리고 다음과 같은 식으로 is_register를 정의하자.

MachineProcessor.js (is_register)

/**

주어진 인자가 레지스터라면 true, 아니면 false를 반환합니다.

@param {string} param

@return {boolean}

*/

function is_register(param) {

return (is_reg32(param) || is_reg16(param) || is_reg8(param));

}

레지스터는 32비트 레지스터도 있고, 16비트, 8비트 레지스터도 있다. is_register는 이 세 함수를 각각 호출해본 다음, 어느 것 하나라도 true를 반환하면 레지스터로 간주하도록 작성했다. 그럼 이제 이들의 구현을 볼 차례다. 32비트 레지스터인지 판정하는 is_reg32 메서드는 이렇게 생겼다.

MachineProcessor.js (is_reg32)

/**

주어진 인자가 32비트 레지스터라면 true, 아니면 false를 반환합니다.

@param {string} param

@return {boolean}

*/

function is_reg32(param) {

return Machine.Processor.Register._reg32[param] ? true : false;

}

이 구문만 보고는 이것이 어떻게 동작하는지 파악하기 어려울 수 있다. 일단 여기선 _reg32라는 객체에 접근하는데, 이 객체가 어떻게 생겼는지부터 확인해보자.

MachineProcessor.js (_reg32)

// 32비트 레지스터 목록입니다.

var _reg32 = {

eax: true, ebx: true, ecx: true, edx: true,

ebp: true, esp: true, eip: true, eflags: true

};

...

register._reg32 = _reg32;

_reg32 객체는 아주 단순하게 생겼다. 그냥 멤버로 레지스터의 이름이 있을 뿐이다. 그런데 여기에, 객체의 멤버에 접근하기 위해 문자열을 사용할 수 있다는 점을 이용하면, 다음과 같이 객체에 접근한 경우,

_reg32[param]; // param is string

_reg32 객체의 멤버로 param이 존재하는지 확인해본 다음, 이것이 있다면 정의된 값인 true, 정의되어있지 않다면 undefined가 반환되고, 이것이 삼항 연산자의 조건에 걸려서 true 또는 false를 반환하게 된다. 결국 이는 Machine.Processor.Register 모듈의 _reg32 객체에 접근한 다음, 그 필드로 param이 있다면 true, 아니면 false임을 나타는 것이다. 나머지도 모두 같은 식으로 구현되어있으니, 자세한 내용은 코드를 참조하기 바란다.

넘어가기 전에 16비트 레지스터와 8비트 레지스터에 대해 한 번 더 짚고 넘어가겠다.

MachineProcessor.js (_reg16, _reg8)

// 16비트 레지스터 목록입니다.

var _reg16 = {

ax: true, bx: true, cx: true, dx: true,

ds: true, ss: true, cs: true, es: true

};

// 8비트 레지스터 목록입니다.

var _reg8 = {

ah: true, al: true, bh: true, bl: true,

ch: true, cl: true, dh: true, dl: true

};

이에 대해서는 5장에서 그림을 통해 설명한 적이 있다. 그림을 다시 가져오겠다.



이 그림만 보고도 왜 이러한 멤버들이 추가되었는지 이해할 수 있을 것이다. dscs 등은 세그먼트에 대한 값을 보관하는 레지스터다.

 

4.2) Program.Runner

Program.Runner 모듈은 Processor가 메모리에 올린 코드를 실행하도록 요청하는 모듈이라고 했다. 이때 이 작업을 준비하는 과정을, 그러니까 메모리에 올린 코드를 실행하는 작업을 위한 준비 과정은 Runner가 담당한다. 그래서 이전에 initRunner에서 정의한 메서드 중 load를 포함한 여러 메서드와 필드를 여기로 옮긴다.

runner.imageBase = 4;

runner.baseOfData = 0;

runner.sizeOfData = 0;

runner.baseOfCode = 0;

runner.sizeOfCode = 0;

...

function RunnerException(...) { ... }

function decode(...) { ... }

function execute(...) { ... }

function LabelInfo(...) { ... }

function is_label_name(...) { ... }

function getDataTypeSize(...) { ... }

function load(...) { ... }

그리고 load 메서드의 맨 위에 다음 한 문장을 추가한다.

ProgramRunner.js (load)

function load(filename) {

var Memory = Machine.Memory;

...

아주 영리한 방법이다. 이전에는 메모리 모듈에 접근하기 위해 그냥 Memory를 썼다. 그런데 이는 사실 window.Memory에 접근하는 것과 같다. 그런데 우리는 Memory 모듈을 Machine의 소속으로 옮겼다. 따라서 이 한 문장을 추가하여 load 메서드 내에서 변수 MemoryMachine.Memory를 가리키게 만들면 이후의 Memory에 대한 접근은 모두 Machine.Memory가 되는 것이다. 이 방법은 리팩토링을 할 때 아주 유용하니 잘 기억하고 있기 바란다.

 

4.3) Machine.Processor

이제 이를 실행하는 run 메서드를 Processor 모듈로 옮기자. 다음을 복사하여 Processor로 옮긴다.

function RunnerException(...) { ... }

function fetch(...) { ... }

function decode(...) { ... }

function execute(...) { ... }

function run(...) { ... }

그리고 RunnerException의 이름을 ProcessorException으로 바꾼다.

MachineProcessor.js (ProcessorException)

...

ProcessorException.prototype.toString = function() {

return 'Processor' + Exception.prototype.toString.call(this);

};

...

리팩토링으로 발생하는 모든 오류에 대해 다음 코드를 추가하여 해결한다.

var Memory = Machine.Memory;

var Runner = Program.Runner;

var Stream = Program.Stream;

var Register = Machine.Processor.Register;

한편, 새로 작성한 Register 모듈은 현재 eax, ebx와 같은 레지스터를 필드로 포함하고 있지 않다. 따라서 이들에 대한 변수를 추가로 정의해야 하는데, 여기서는 Register 모듈의 필드로 직접 넣는 대신 실제 레지스터 변수를 보관하는 객체를 따로 만들자.

MachineProcessor.js (Register.reg)

// 실제 레지스터를 보관하는 reg 객체입니다.

var _reg = {

eax: 0, ebx: 0, ecx: 0, edx: 0,

ebp: 0, esp: 0, eip: 0, eflags: 0,

ds: 0, cs: 0, ss: 0, es: 0

}

...

register._reg = _reg;

그리고 레지스터에 접근하기 위한 메서드를 Register 모듈에 추가한다.

MachineProcessor.js (Register.get_and_set)

/**

레지스터의 값을 획득하여 4바이트 정수로 반환합니다.

@param {string} regName

@return {number}

*/

function get(regName) {

return Machine.Processor.Register._reg[regName];

}

/**

레지스터의 값을 4바이트 정수로 설정합니다.

@param {string} regName

@param {number} value

*/

function set(regName, value) {

Machine.Processor.Register._reg[regName] = value;

}

...

register.get = get;

register.set = set;

이는 당장 쓸모가 있어 보이지는 않지만, 후에 ax, ah와 같은 레지스터에 접근할 때 유용하다.

이전에는 레지스터의 값을 수정할 때 Register 모듈의 멤버에 직접 접근하였지만, 이제는 getset 메서드를 사용해야 한다. 이전에 이를 사용하고 있던 메서드를 모두 수정하자. mov의 경우를 예로 들면 다음과 같이 코드가 바뀌는 것이다.

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));

}

}

이와 같이 모든 메서드에 대해 처리해주면 된다. 첨부한 코드를 참조하라. 구현이 거의 같으니 스스로 작성할 수 있지만, eflags에 대한 처리만 여기서 설명하겠다. jnz, cmp와 같은 니모닉은 eflags의 정보를 얻거나 설정하기 위해 getZF, setZF 메서드를 사용하기 때문에, 이 메서드 또한 Register 모듈로 모두 옮겨줘야 한다.

MachineProcessor.js (Register)

// 플래그 레지스터에 대한 처리 메서드입니다.

const BIT_CF = 1;

function getCF() { return (this.eflags & BIT_CF) ? 1 : 0 };

function setCF(value) {

if (value == 0) { // 0으로 설정

this.eflags &= (~BIT_CF);

} else { // 1로 설정

this.eflags |= BIT_CF;

}

}

...

또한 MachineProcessor.js에 작성했던 fetch 메서드에 다음 줄이 추가된다.

MachineProcessor.js (fetch)

...

// 현재 메모리 바이트 포인터의 값을 eip 레지스터에 복사합니다.

Register.set('eip', Memory.bytePtr);

...

이 과정은 이전에 사용하지 않던 eip 레지스터를 실제로 사용하기 위한 것이다. Operate 모듈을 작성할 때 메모리의 바이트 포인터 대신 앞으로는 eip 레지스터를 이용할 것이다.

이제 출력을 보다 편리하게 하기 위해 다음 두 메서드를 추가한다.

MachineProcessor.js (exp_writeln, reg_writeln)

...

/**

식 스트림에 문자열을 출력하고 개행합니다.

@param {string} param

*/

function exp_writeln(param) {

Program.Stream.exp.writeln(param);

}

/**

레지스터 스트림에 현재 레지스터의 상태를 출력합니다.

*/

function reg_writeln() {

var Stream = Program.Stream;

var Reg = Machine.Processor.Register;

Stream.reg.writeln('eax = %08x, ebx = %08x, ecx = %08x, edx = %08x',

Reg.get('eax'), Reg.get('ebx'), Reg.get('ecx'), Reg.get('edx'));

Stream.reg.writeln('ebp = %04x, esp = %04x, eip = %08x, eflags = %08b',

Reg.get('ebp'), Reg.get('esp'), Reg.get('eip'), Reg.get('eflags'));

}

...

마지막으로 run 메서드를 다음과 같이 수정한다.

MachineProcessor.js (MachineProcessor.run)

/**

명령 포인터를 주소로 옮깁니다.

@param {number} addr

*/

function setip(addr) {

Machine.Processor.Register.set('eip', addr);

Machine.Memory.bytePtr = addr;

}

 

/**

진입점을 기준으로 프로그램을 실행합니다.

*/

function run() {

var Memory = Machine.Memory;

var Runner = Program.Runner;

var Stream = Program.Stream;

var Register = Machine.Processor.Register;

 

// 코드 영역의 시작 지점으로 바이트 포인터를 옮깁니다.

var MEMORY_END = Memory.MaxSize();

Register.set('ebp', MEMORY_END);

Register.set('esp', MEMORY_END);

setip(Runner.baseOfCode);

 

while (true) {

// 메모리로부터 명령 코드를 획득합니다.

var opcode = fetch();

if (opcode == 'exit') // 명령 코드가 exit이면 프로그램을 종료합니다.

break;

 

// fetch를 통해 가져온 정보를 분석합니다.

var info = decode(opcode);

 

// decode를 통해 분석된 정보를 바탕으로 명령을 실행합니다.

execute(info);

// 식을 분석하고 실행한 결과를 스트림에 출력합니다.

exp_writeln(opcode);

reg_writeln();

}

log('run complete');

}

그러면 프로그램을 실행하여 다음 결과를 얻는다.

main.hda

.data

_sHello db 'hello', 0

_sWorld db 'world', 0

 

.code

_main:

push ebp

mov ebp, esp

 

_cond1_if:

cmp 1, 1

jnz _cond1_else

 

handy puts, _sHello

jmp _cond1_endif

 

_cond1_else:

handy puts, _sWorld

 

_cond1_endif:

mov esp, ebp

pop ebp

exit

실행 결과 (register)

eax = 00000000, ebx = 00000000, ecx = 00000000, edx = 00000000

ebp = 0400, esp = 03fc, eip = 00000019, eflags = 00000000

eax = 00000000, ebx = 00000000, ecx = 00000000, edx = 00000000

ebp = 03fc, esp = 03fc, eip = 00000025, eflags = 00000000

eax = 00000000, ebx = 00000000, ecx = 00000000, edx = 00000000

ebp = 03fc, esp = 03fc, eip = 0000002d, eflags = 01000000

eax = 00000000, ebx = 00000000, ecx = 00000000, edx = 00000000

ebp = 03fc, esp = 03fc, eip = 00000038, eflags = 01000000

eax = 00000000, ebx = 00000000, ecx = 00000000, edx = 00000000

ebp = 03fc, esp = 03fc, eip = 0000004a, eflags = 01000000

eax = 00000000, ebx = 00000000, ecx = 00000000, edx = 00000000

ebp = 03fc, esp = 03fc, eip = 00000055, eflags = 01000000

eax = 00000000, ebx = 00000000, ecx = 00000000, edx = 00000000

ebp = 03fc, esp = 03fc, eip = 00000073, eflags = 01000000

eax = 00000000, ebx = 00000000, ecx = 00000000, edx = 00000000

ebp = 0400, esp = 0400, eip = 0000007b, eflags = 01000000

이로써 리팩터링은 마무리가 된다.

 

5. 준비

Runner를 리팩터링하는 데 한참 시간을 들였다. 링커 개발에 앞서, 그림을 다시 보자.





핵심은, 각각의 파일을 분석하여 메모리에 올리면 레이블이 올바르게 대치되어야 한다는 것이다. 그럼 코드를 작성하기 전에 고려해야 될 것을 다시 한 번 생각해보자.

 

5.1) 고려해야 할 것

사실 링커를 만드는 것이 어려운 이유는 레이블의 범위가 하나가 아니라는 점 때문이다. 예를 들어보자. C에서는 다른 파일에서 특정 함수를 참조할 수 없도록 static이라는 키워드가 제공된다.

static void func() { ... } // 이 함수가 정의된 파일 외부에서는 이 함수를 찾을 수 없다

이는 어셈블리 언어에서는 정반대로 일어난다. C에서 이런 일이 일어나는 것은 기본적으로 C의 함수가 외부 선언을 가지기 때문인데, 어셈블리 언어는 특별히 레이블의 범위를 지정하지 않으면 기본적으로 local이 된다. 다시 말해, 레이블은 전역 특성을 정해주지 않으면 외부에서 참조할 수 없다는 것이다.

NASM 어셈블리에서는 이를 위해 global이라는 지시어(directive)를 제공한다. 이는 HASM에도 반영되었다. HASM에서는 기본적으로 레이블은 local이지만, global 지시어를 통해 레이블을 외부에서 참조 가능하도록 만들 수 있다.

여기서 우리가 무엇을 고려해야 하는지에 대한 답이 나온다. 우리는 레이블이 지역에서만 참조 가능한 것인가, 아니면 전역에서 참조 가능한 것인가를 결정해야 한다. 이것은 아주 중요한 문제다. 예를 들어 sum이라는 프로시저가 src1src2에 다음과 같이 정의되어있다고 하자.

src1.hda

.code

_sum:

push ebp

mov ebp, esp

mov edx, [ebp+0x8]

mov eax, [ebp+0xc]

add eax, edx

mov esp, ebp

pop ebp

ret

src2.hda

.code

_sum:

push ebp

mov ebp, esp

mov eax, ecx

add eax, edx

mov esp, ebp

pop ebp

ret

이 둘을 링크한 결과가 이것이면 안 된다.

out.hdx

.code

_sum:

push ebp

mov ebp, esp

mov edx, [ebp+0x8]

mov eax, [ebp+0xc]

add eax, edx

mov esp, ebp

pop ebp

ret

_sum:

push ebp

mov ebp, esp

mov eax, ecx

add eax, edx

mov esp, ebp

pop ebp

ret

한 눈에 그 이유를 알 수 있다. 레이블을 중복 정의하고 있기 때문이다. _sum의 정의가 중복이 되면 레이블을 대치할 때 둘 중 어느 주소 값으로 대치해야 할 지 판정할 수 없게 된다. 그래서 우리는 지역에 정의한 레이블을, 링크 이전에 따로 처리하는 등의 방법을 써야 한다. 여기서는 이 문제를 해결하기 위해 이런 방법을 쓴다.

1. 목적 파일의 정보를 보관하는 리스트를 생성한다.

2. 목적 파일 정보 객체는 데이터/코드 세그먼트의 정보, 지역 레이블 정보를 갖는다.

3. 모든 목적 파일이 공통으로 접근할 수 있는 전역 레이블 관리 객체를 정의한다.

4. 목적 파일을 Linker 모듈이 불러와서 분석한 후 리스트에 보관한다.

5. 불러오기가 완료되면 링크 메서드를 실행하여 프로그램을 생성한다.

다음 절에서 이에 대해 알아볼 것이다.

 

5.2) StringStream

앞으로는 파일에 텍스트를 기록하는데, 아무래도 이런 식으로 문자열을 만드는 것은 읽기도 어렵고 번거롭다.

var ss = '';

ss += Handy.format('.code\n');

ss += Handy.format('push ebp\n');

ss += Handy.format('mov ebp, esp\n');

ss += Handy.format('mov esp, ebp\n');

ss += Handy.format('pop ebp\n');

ss += Handy.format('exit\n');

HandyFileSystem.save(filename, ss);

따라서 우리는 BaseStream 형식을 모방한 StringStream을 다음과 같이 작성하여 사용한다.

ProgramStream.js (StringStream)

/**

문자열을 스트림처럼 사용하기 위해 형식을 정의합니다.

*/

function StringStream() {

this.str = '';

}

...

StringStream.prototype.write = ss_write;

StringStream.prototype.writeln = ss_writeln;

StringStream.prototype.clear = ss_clear;

...

_stream.StringStream = StringStream;

그러면 이렇게 문자열 스트림을 사용할 수 있다.

var ss = new Program.Stream.StringStream();

ss.writeln('.code');

ss.writeln('push ebp');

ss.writeln('mov ebp, esp');

ss.writeln('mov esp, ebp');

ss.writeln('pop ebp');

ss.writeln('exit');

HandyFileSystem.save(filename, ss.str);

그럼 이제 링커를 개발해보자.

 

6. Linker

먼저 Linker 모듈을 준비한다.

ProgramLinker.js

/**

실행 가능한 목적 파일을 생성합니다.

*/

function initProgramLinker(program) {

var linker = {};

... // LinkerException 정의를 포함합니다.

program.Linker = linker;

}

그 후 다음 두 메서드를 준비한다.

ProgramLinker.js (load, link)

// 메서드 정의

/**

파일 시스템으로부터 목적 파일을 불러옵니다.

@param {string} filename

*/

function load(filename) {

/* ... */

}

/**

불러온 목적 파일을 링크합니다.

@param {string} filename

*/

function link(filename) {

/* ... */

}

그리고 목적 파일 정보를 보관하기 위한 객체 형식을 정의한다.

ProgramLinker.js (ObjectInfo)

/**

목적 파일의 정보를 보관하는 객체 형식을 정의합니다.

*/

function ObjectInfo(segment, labelInfoDict, sizeOfData, sizeOfCode) {

this.segment = segment;

this.labelInfoDict = labelInfoDict;

this.sizeOfData = sizeOfData;

this.sizeOfCode = sizeOfCode;

}

이제 이를 바탕으로 목적 파일로부터 정보를 획득하는 load 메서드를 작성하자.

 

6.1) load

사실 Linker, 여기서는 어셈블리를 쓰기 때문에 decode를 그대로 사용할 수 있다(fetchexecute야 실행기가 쓰는 거니까 필요가 없지만). 그러니 이 메서드를 포함하여, 기존의 load 메서드가 사용하던 코드를 일부 복사한다.

ProgramLinker.js (load_ready)

function LabelInfo(...) { ... }

function is_label_name(...) { ... }

function getDataTypeSize(...) { ... }

function decode(...) { ... }

이제 필드를 정의한 다음,

ProgramLinker.js (fields)

// 필드 정의

linker.entrypoint = null;

linker.baseOfData = 0;

linker.sizeOfData = 0;

linker.baseOfCode = 0;

linker.sizeOfCode = 0;

linker.ObjectInfoList = []; // ObjectInfo 객체에 대한 리스트입니다.

linker.GlobalLabelDict = {}; // 전역 레이블 객체에 대한 딕셔너리입니다.

Program.Runner에 정의된 load 메서드를 Linker로 모두 복사한다.

ProgramLinker.js (load)

function load(...) { ... }

바뀌는 건 레이블에 대한 처리뿐이다. Runner는 모든 목적 파일을 하나로 모은 목적 파일이므로 외부 레이블을 참조할 필요가 없다. 반면, Linker는 따로 떨어진 목적 파일을 하나로 합치는 것이 목적이므로 지역 레이블과 전역 레이블을 구분해야 한다.

여기서 Runnerload와 차이가 발생한다. NASM에서는 어떤 레이블이 전역임을 알리기 위해 global 지시어를 사용한다고 했다. HASM에서도 이를 반영한다. 만약 분석을 진행하다가 다음과 같은 구문을 만나면,

global _main

우리는 _main이라는 레이블을 전역 레이블로 간주해야 한다. 이를 위해 링커의 멤버로 전역 레이블을 보관하는 GlobalLabelDict를 추가한 것이다.

global에 대해 처리하기 위해 세그먼트 처리 구문 이전에 다음 코드가 추가된다.

ProgramLinker.js (load.global)

...

// 행을 분석하기 위해 문자열 버퍼를 생성합니다.

var lineBuffer = new StringBuffer(line);

var lineToken = lineBuffer.get_token(); // 첫 번째 토큰을 획득합니다.

if (lineToken == 'global') { // 전역 레이블을 지정하는 구문이라면

label = lineBuffer.get_token(); // 레이블을 획득합니다.

 

// 레이블 획득에 실패하면 예외 처리합니다.

if (label == null)

throw new LinkerException('directive global got empty param');

else if (is_label_name(label) == false)

throw new LinkerException('directive global got invalid param', label);

 

// 전역 레이블 딕셔너리에 레이블 정보를 추가합니다.

if (labelInfoDict[label] != null) {

// 지역 레이블 딕셔너리에 이미 정보가 있다면

// 전역 레이블 딕셔너리로 옮깁니다.

Linker.GlobalLabelDict[label] = labelInfoDict[label];

 

// 더 이상 지역 레이블이 아니므로 딕셔너리에서 제거합니다.

labelInfoDict[label] = null;

}

else if (is_global(label) == false) {

// 딕셔너리에 이미 정보가 있다면 새로 생성할 필요가 없습니다.

Linker.GlobalLabelDict[label] = new LabelInfo(segment_target, label);

}

continue; // 전역 레이블 지정자에 대한 처리를 종료합니다.

}

 

// 데이터 세그먼트를 처리합니다.

if (segment_target == 'data') {

...

토큰을 획득한 다음, 이것이 전역 지정자인지 확인하는 구문이 추가되었다. 이 코드는 레이블을 획득한 후, 전역 레이블 딕셔너리에 레이블 정보를 추가한다. 만약 지역 레이블 딕셔너리에 이미 정보가 있다면, 전역 레이블 딕셔너리에 레이블 정보를 옮기고 지역 딕셔너리에서 레이블 정보를 빼낸다. 여기에는 load 메서드 이전에, 어떤 레이블이, 전역 레이블인지 확인하는 is_global 메서드가 정의되어있다.

ProgramLinker.js (is_global)

/**

전역 레이블이라면 true, 아니면 false입니다.

@param {string} label

@return {boolean}

*/

function is_global(label) {

return (Program.Linker.GlobalLabelDict[label] != undefined);

}

그리고 load 메서드의 마지막에 다음을 추가하여, 목적 파일 불러오기가 완료되었음을 알린다.

ProgramLinker.js (load.last)

...

// 목적 파일 정보를 보관하는 새 객체를 생성합니다.

var objectInfo = new ObjectInfo(segment, labelInfoDict, sizeOfData, sizeOfCode);

Linker.ObjectInfoList.push(objectInfo);

}

일단 꽤 많이 처리한 것 같으니 중간 점검을 한 번 하자.

 

6.2) 중간 점검

link는 아직 구현하지 않았지만, 일단 점검을 위한 용도로 사용하자. 다음과 같이 코드를 작성한다.

ProgramLinker.js (link)

/**

불러온 목적 파일을 링크합니다.

@param {string} filename

*/

function link(filename) {

var Linker = Program.Linker;

var objectList = Linker.ObjectInfoList;

 

for (var j=0, objectCount=objectList.length; j<objectCount; ++j) {

var objectInfo = objectList[j]; // 목적 파일 정보를 획득합니다.

log('objectInfo %d: ', j);

 

// ObjectInfo 객체의 정보를 문자열로 반환하기 위해

// 문자열 스트림을 생성합니다.

var ss = new Program.Stream.StringStream();

// 지역 레이블 정보를 출력합니다.

ss.writeln('> local labels');

for (label in objectInfo.labelInfoDict) {

var info = objectInfo.labelInfoDict[label];

ss.write('%s %s: %04x [ ', info.segmentName, info.name, info.offset);

for (var i=0, len=info.refered.length; i<len; ++i) {

ss.write('%04x ', info.refered[i]);

}

ss.writeln(']');

}

// 데이터 세그먼트의 정보를 출력합니다.

var sizeOfData = objectInfo.sizeOfData,

sizeOfCode = objectInfo.sizeOfCode;

ss.writeln('> data segment [%d(%04x)]', sizeOfData, sizeOfData);

for (var i=0, len=objectInfo.segment.data.length; i<len; ++i) {

var data = objectInfo.segment.data[i];

ss.writeln('%d: %d, [%04x]', i, data.typeSize, data.value);

}

// 코드 세그먼트의 정보를 출력합니다.

ss.writeln('> code segment [%d(%04x)]', sizeOfCode, sizeOfCode);

for (var i=0, len=objectInfo.segment.code.length; i<len; ++i) {

var code = objectInfo.segment.code[i];

ss.writeln('%s', code);

}

// 목적 파일 정보 문자열을 로그 스트림에 출력합니다.

log(ss.str);

}

 

// 전역 레이블 정보를 출력합니다.

log('===== global labels =====');

var globalList = Linker.GlobalLabelDict;

for (label in globalList) {

var info = globalList[label];

// 문자열 스트림을 생성합니다.

var ss = new Program.Stream.StringStream();

ss.write('%s %s: %04x [ ', info.segmentName, info.name, info.offset);

for (var i=0, len=info.refered.length; i<len; ++i) {

ss.write('%04x ', info.refered[i]);

}

ss.write(']');

log(ss.str);

}

 

log('link complete');

}

코드가 길지만 전혀 어려운 코드가 아니다. 그냥 정보가 어떻게 들어갔는지를 확인하기 위해 문자열 스트림을 생성하고 그 스트림에 정보를 출력한 것이 전부다.

출력이 어떤 식으로 이루어지는지 정리를 하고 가자. 먼저 목적 파일의 인덱스가 출력된다.

objectInfo 0: // 목적 파일의 인덱스는 0번입니다.

그리고 현재 목적 파일에서 획득한 지역 레이블 딕셔너리를 출력한다.

> local labels

// 기본 구문: <segment> <label_name>: <definition_offset_hex> [ <reference_offset_hex> ]

data _sHelloWorld: 0000 [ 0077 ] // data 세그먼트 소속 _sHelloWorld 레이블입니다.

// 아직 정의가 발견되지 않았으면 0x0000입니다.

// 레이블을 참조한 위치가 출력됩니다. 이 경우 0x0077입니다.

code _sum: 0000 [ 00a7 ] // code 세그먼트 소속 _sum 레이블입니다.

// 0x0000이므로, 아직 정의가 발견되지 않았음을 알 수 있습니다.

// 0x00a7 메모리에서 이 레이블을 참조합니다.

code _main: 005d [ 00f3 ] // code 세그먼트 소속 _main 레이블입니다.

// 정의가 0x005d 오프셋에 존재합니다.

// 0x00f3 메모리에서 이 레이블을 참조합니다.

모든 지역 레이블을 출력하고 나면, 데이터 세그먼트, 코드 세그먼트의 레이블을 차례로 출력한다.

> data segment [14(000e)] // 이 목적 파일의 데이터의 총 크기는 14입니다.

0: 1, ['hello, world!',0] // 0번 데이터는 요소의 크기가 1입니다. 나머지는 데이터입니다.

 

> code segment [250(00fa)] // 이 목적 파일의 코드의 총 크기는 250입니다.

push ebp // 이하로는 모두 코드입니다.

mov ebp,esp

...

결국 입력 파일이 다음과 같이 들어가면,

main.hdo

.data

_sHelloWorld db 'hello, world!', 0

.code

; ===========

_sum:

push ebp

...

ret

; ===========

_main:

push ebp

...

call _sum

...

ret

; 프로그램의 시작 지점이 _main 레이블임을 알립니다.

end _main

다음과 같이 출력이 된다.

예상 결과

objectInfo 0:

> local labels

data _sHelloWorld: 0000 [ 0077 ]

code _sum: 0000 [ 00a7 ]

code _main: 005d [ 00f3 ]

> data segment [14(000e)]

0: 1, ['hello, world!',0]

> code segment [250(00fa)]

push ebp

mov ebp,esp

...

이제 테스트를 위해 main.html 파일을 다음과 같이 수정한다.

main.html <html.head>

...

<!-- 컴파일러를 위한 모듈 목록입니다. -->

<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>

...

main.html <html.head.script>

// Memory 테스트 예제입니다.

function main() {

Program.Linker.load('main.hdo');

Program.Linker.load('print.hdo');

Program.Linker.link('program.hdx');

}

...

그러면 프로그램을 실행하여 다음 결과를 얻는다.

main.hdo

print.hdo

.data

_sHelloWorld db 'hello, world!', 0

 

.code

;====================================

; proc sum

; receives: 2 integers

; returns: sum of 2 parameters

;====================================

_sum:

push ebp

mov ebp, esp

mov edx, [ebp+0x8]

mov eax, [ebp+0xc]

add eax, edx

mov esp, ebp

pop ebp

ret

 

;====================================

; proc main

;====================================

_main:

push ebp

mov ebp, esp

; hello, world!를 출력합니다.

push _sHelloWorld

call _prints

add esp, 4

; 3+4의 계산 결과를 출력합니다.

push 4

push 3

call _sum

add esp, 8

push eax

call _printn

add esp, 4

mov esp, ebp

pop ebp

ret

; 프로그램 시작 지점은 _main 레이블입니다.

end _main

.data

_sEnteredString db 'Entered String: ', 0

_sEnteredNumber db 'Entered Number: ', 0

 

.code

;====================================

; proc prints

; receives: start address of string

; returns: none

;====================================

global _prints

_prints:

push ebp

mov ebp, esp

handy puts, _sEnteredString

handy puts, [ebp+0x8]

mov esp, ebp

pop ebp

ret

 

;====================================

; proc prints

; receives: number to print

; returns: none

;====================================

global _printn

_printn:

push ebp

mov ebp, esp

handy puts, _sEnteredNumber

handy putn, [ebp+0x8]

mov esp, ebp

pop ebp

ret

실행 결과 (log)

main.hdo 분석 결과

print.hdo 분석 결과

objectInfo 0:

> local labels

data _sHelloWorld: 0000 [ 0077 ]

code _sum: 0000 [ 00a7 ]

code _main: 005d [ 00f3 ]

code _prints: 0000 [ 0083 ]

code _printn: 0000 [ 00c6 ]

> data segment [14(000e)]

0: 1, ['hello, world!',0]

> code segment [250(00fa)]

push ebp

mov ebp,esp

mov edx,[ebp+0x8]

mov eax,[ebp+0xc]

add eax,edx

mov esp,ebp

pop ebp

ret

push ebp

mov ebp,esp

push 0x0000

call 0x0000

add esp,4

push 4

push 3

call 0x0000

add esp,8

push eax

call 0x0000

add esp,4

mov esp,ebp

pop ebp

ret

end 0x0000

objectInfo 1:

> local labels

data _sEnteredStringIs: 0000 [ 0020 ]

data _sEnteredNumberIs: 0014 [ 0074 ]

code _prints: 0000 [ ]

code _printn: 0054 [ ]

> data segment [40(0028)]

0: 1, ['Entered String is: ',0]

1: 1, ['Entered Number is: ',0]

> code segment [168(00a8)]

push ebp

mov ebp,esp

handy puts,0x0000

handy puts,[ebp+0x8]

mov esp,ebp

pop ebp

ret

push ebp

mov ebp,esp

handy puts,0x0000

handy putn,[ebp+0x8]

mov esp,ebp

pop ebp

ret

===== global labels =====

code _prints: 0000 [ ]

code _printn: 0000 [ ]

link complete

일단 코드 영역은, 레이블이었던 위치가 성공적으로 0x0000으로 대치되었음을 알 수 있다. 각각의 목적 파일에서 찾아낸 레이블의 정의나 참조 위치가 타당한지는, 직접 위치를 세거나 이전에 작성한 모듈을 응용하여 확인할 수 있다. 이것이 올바른지 확인하자. 다음은 main.hdo 파일을 메모리에 올린 상태를 표현한 것이다. 주황색은 데이터 세그먼트, 빨간색은 _sum 프로시저, 파란색은 _main 프로시저를 표현한 것이다.

실행 결과 (log)

0x0000 | 00 00 00 00 68 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 6d 6f 76 20 65 64 78 2c 5b | bp,esp.mov edx,[

0x0030 | 65 62 70 2b 30 78 38 5d 00 6d 6f 76 20 65 61 78 | ebp+0x8].mov eax

0x0040 | 2c 5b 65 62 70 2b 30 78 63 5d 00 61 64 64 20 65 | ,[ebp+0xc].add e

0x0050 | 61 78 2c 65 64 78 00 6d 6f 76 20 65 73 70 2c 65 | ax,edx.mov esp,e

0x0060 | 62 70 00 70 6f 70 20 65 62 70 00 72 65 74 00 70 | bp.pop ebp.ret.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 70 75 73 68 20 30 78 30 30 30 34 00 | esp.push 0x0000.

0x0090 | 63 61 6c 6c 20 30 78 30 30 31 32 00 61 64 64 20 | call 0x0000.add

0x00a0 | 65 73 70 2c 34 00 70 75 73 68 20 34 00 70 75 73 | esp,4.push 4.pus

0x00b0 | 68 20 33 00 63 61 6c 6c 20 30 78 30 30 31 32 00 | h 3.call 0x0000.

0x00c0 | 61 64 64 20 65 73 70 2c 38 00 70 75 73 68 20 65 | add esp,8.push e

0x00d0 | 61 78 00 63 61 6c 6c 20 30 78 30 30 31 32 00 61 | ax.call 0x0000.a

0x00e0 | 64 64 20 65 73 70 2c 34 00 6d 6f 76 20 65 73 70 | dd esp,4.mov esp

0x00f0 | 2c 65 62 70 00 70 6f 70 20 65 62 70 00 72 65 74 | ,ebp.pop ebp.ret

0x0100 | 00 65 6e 64 20 30 78 30 30 36 66 00 00 00 00 00 | .end 0x0000.....

...

main.hdo의 레이블 상태를 다시 가져오면 다음과 같다.

> labels

data _sHelloWorld: 0000 [ 0077 ]

code _sum: 0000 [ 00a7 ]

code _main: 005d [ 00f3 ]

code _prints: 0000 [ 0083 ]

code _printn: 0000 [ 00c6 ]

> data segment [14(000e)]

0: 1, ['hello, world!',0]

> code segment [250(00fa)]

push ebp

...

그럼 이것이 맞는지 검증해보자. _main 프로시저의 시작은 이 결과대로라면 0x005d가 되어야 한다. 그런데 실제 _main 프로시저의 시작은 파란색이 처음 나타나는 0x006f. 결국 0x12만큼, 18만큼의 차이가 발생한 것이다. 왜 이러한 차이가 나타나는지 알겠는가?

답은 imageBasesizeOfData 때문이다. 각각의 레이블은, 세그먼트의 시작점을 기준으로 오프셋으로 설정되어있다. 우리는 imageBase를 처음에 4로 정의했고, sizeOfData14임을 다음을 통해 알 수 있다.

> data segment [14(000e)] // data 세그먼트의 크기는 14입니다. 16진수로는 0x000e입니다.

그래서 코드 세그먼트의 시작점이 18이 되었기 때문에 18 + 0x005d = 0x006f라는 결과가 나온 것이다. 바로 이러한 점이 링커를 구현하기 어렵게 만든다.

사실 문제는 이것만이 아니다. print.hdo 파일에는 _prints, _printn 프로시저는 분명하기 전역으로 정의되어있다. 하지만 어째선지 지역 레이블을 출력해야 하는 부분에서 전역 레이블까지 출력하고 있다. 이는 우리가 레이블의 참조를 찾아냈을 때 지역 레이블 딕셔너리에만 추가하는 Runnerload 메서드를 그대로 사용하고 있기 때문이다. 즉 이 부분 또한 수정해야 한다.

하나만 더 생각하자. end는 여기서 새롭게 추가된 지시어(directive). end _main과 같이 쓰면 우리의 프로그램이 _main 레이블부터 시작함을 알리는 것이다. 이전에도 말했지만, _main의 위치는 프로그램을 링크하는 순서에 따라 바뀔 수 있다. 전역 레이블을 대치할 때는 이 점을 잘 생각해야 한다.

이들을 모두 고려하여 프로그램 작성을 다시 시작하자. 일단 load가 잘못되어있으니 이것을 먼저 수정한 다음, link 메서드를 수정하도록 한다.

 

6.3) load 수정

지금 레이블의 정의와 참조가 문제가 되는 부분은 코드 세그먼트다. 데이터 세그먼트의 경우는 코드 영역에서 참조만 하고, 데이터 세그먼트 내에서 서로를 참조하는 일이 없으니 우리는 코드 세그먼트만 고려하면 된다. 레이블의 정의를 발견한 경우에 대한 처리를 수정하자.

ProgramLinker.js (load.codeseg)

...

// 레이블에 대해 처리합니다.

if (line.charAt(line.length-1) == ':') {

// 레이블 이름을 획득합니다.

var label = line.substr(0, line.length-1);

// 전역 레이블에 등록되어있다면

if (is_global(label)) {

/* 전역 레이블에 대해 처리합니다. */

}

...

이제 전역 레이블에 대해 처리해야 하는데, 여기서 전역 레이블 정보 형식을 새롭게 정의한다.

ProgramLinker.js (GlobalLabelInfo)

/**

전역 레이블 정보를 표현하는 형식 GlobalLabelInfo를 정의합니다.

@param {number} objectIndex

@param {string} segmentName

@param {string} name

*/

function GlobalLabelInfo(objectIndex, segmentName, name) {

this.segmentName = segmentName;

this.name = name;

this.index = objectIndex; // 레이블이 정의된 목적 파일의 인덱스를 보관합니다.

this.offset = 0; // 목적 파일의 인덱스로부터 세그먼트의 시작 위치를 계산합니다.

this.refered = []; // 이 레이블을 참조하는 목적 파일의 인덱스도 보관해야 합니다.

}

그리고 이 형식을 이용하여 이전의 GlobalLabelDict 구문을 모두 수정한 다음, 코드 세그먼트에서의 전역 레이블 처리 코드를 작성해야 한다.

ProgramLinker.js (load.global)

function load(filename) {

var Linker = Program.Linker;

// 현재 목적 파일의 위치를 획득합니다.

// 리스트의 가장 마지막에 추가할 원소이므로

// 아직 원소가 추가되지 않았을 때의 리스트의 길이와 같습니다.

var objectIndex = Linker.ObjectInfoList.length;

...

if (lineToken == 'global') { // 전역 레이블을 지정하는 구문이라면

label = lineBuffer.get_token(); // 레이블을 획득합니다.

// 레이블 획득에 실패하면 예외 처리합니다.

if (label == null)

throw new LinkerException('directive global got empty param');

else if (is_label_name(label) == false)

throw new LinkerException('directive global got invalid param', label);

 

// 전역 레이블 딕셔너리에 레이블 정보를 추가합니다.

// 지역 레이블 딕셔너리에 이미 정보가 있다면

if (labelInfoDict[label] != null) {

// 전역 레이블 딕셔너리에 추가할 객체를 생성하고 기존 정보를 복사합니다.

var gLabelInfo = new GlobalLabelInfo

(objectIndex, segment_target, label);

gLabelInfo.offset = labelInfoDict[label].offset;

 

// 기존에 존재하던 참조 위치를 GlobalLabelInfo에 맞게 변환합니다.

// 참조 위치에 목적 파일의 인덱스를 멤버로 추가하는 식으로 변환합니다.

var ref = labelInfoDict[label].refered;

for (var j=0, refCount=ref.length; j<refCount; ++j)

gLabelInfo.refered.push({ objectIndex:objectIndex, offset:ref[j]});

 

// 전역 레이블 딕셔너리로 옮깁니다.

Linker.GlobalLabelDict[label] = gLabelInfo;

// 더 이상 지역 레이블이 아니므로 딕셔너리에서 제거합니다.

labelInfoDict[label] = null;

}

else if (is_global(label) == false) {

// 딕셔너리에 이미 정보가 있다면 새로 생성할 필요가 없습니다.

Linker.GlobalLabelDict[label] = new GlobalLabelInfo

(objectIndex, segment_target, label);

}

continue; // 전역 레이블 지정자에 대한 처리를 종료합니다.

}

...

코드 세그먼트에서 레이블의 정의를 찾은 경우를 보자.

ProgramLinker.js (load.codeseg.global_label)

...

// 레이블의 정의를 발견했다면

if (line.charAt(line.length-1) == ':') {

// 레이블 이름을 획득합니다.

var label = line.substr(0, line.length-1);

 

// 전역 레이블에 등록되어있다면

if (is_global(label)) {

// 전역 레이블 정보 객체를 획득합니다.

var gLabelInfo = Linker.GlobalLabelDict[label];

 

// 현재 목적 파일의 인덱스와 발견한 레이블 정의를 기록합니다.

gLabelInfo.index = objectIndex;

gLabelInfo.offset = sizeOfCode;

 

}

// 레이블이 딕셔너리에 없는 경우 생성합니다.

else if (labelInfoDict[label] == undefined) {

labelInfoDict[label] = new LabelInfo(segment_target, label);

 

// 레이블 딕셔너리에 정보를 등록합니다.

labelInfoDict[label].offset = sizeOfCode;

}

continue;

}

...

정의에서 중요한 것은, 현재 목적 파일의 인덱스를 반드시 넘겨야 한다는 것이다. 이제 레이블의 참조 문제를 해결하자.

ProgramLinker.js (load.codeseg.label_reference)

...

// 레이블이라면 (맨 앞이 밑줄 기호라면)

if (info.left.charAt(0) == '_') {

// 참조 위치는 (현재 바이트 포인터 위치 + 니모닉의 길이 + 공백 한 칸)입니다.

var refered_addr = sizeOfCode + s.length;

s += '0x0000'; // 일단 0으로 대치합니다.

 

var label = info.left; // 레이블 이름을 획득합니다.

 

// 전역 레이블이라면

if (is_global(label)) {

// 전역 레이블 정보 객체를 획득합니다.

var gLabelInfo = Linker.GlobalLabelDict[label];

 

// 참조하고 있는 위치를 보관합니다.

labelInfoDict[label].refered.push({

objectIndex:objectIndex, offset:refered_addr

});

}

// 레이블이 딕셔너리에 등록되지 않았다면 등록합니다.

else if (labelInfoDict[label] == undefined) {

labelInfoDict[label] = new LabelInfo(segment_target, label);

 

// 참조하고 있는 위치를 보관합니다.

labelInfoDict[label].refered.push(refered_addr);

}

else {

// 참조하고 있는 위치를 보관합니다.

labelInfoDict[label].refered.push(refered_addr);

}

}

...

이제 테스트를 위해 link 메서드를 다음과 같이 변경한다.

ProgramLinker.js (link.global_labels)

...

// 전역 레이블 정보를 출력합니다.

log('===== global labels =====');

var globalList = Linker.GlobalLabelDict;

for (label in globalList) {

var info = globalList[label];

// 문자열 스트림을 생성합니다.

var ss = new Program.Stream.StringStream();

ss.write('%s %s: [%d] %04x [ ',

info.segmentName, info.name, info.index, info.offset);

for (var i=0, len=info.refered.length; i<len; ++i) {

ss.write('%04x ', info.refered[i]);

}

ss.write(']');

log(ss.str);

}

...

그리고 프로그램을 실행하여 다음 결과를 얻는다.

실행 결과 (log)

main.hdo 분석 결과

print.hdo 분석 결과

objectInfo 0:

> local labels

data _sHelloWorld: 0000 [ 0077 ]

code _sum: 0000 [ 00a7 ]

code _main: 005d [ 00f3 ]

code _prints: 0000 [ 0083 ]

code _printn: 0000 [ 00c6 ]

> data segment [14(000e)]

0: 1, ['hello, world!',0]

> code segment [250(00fa)]

push ebp

mov ebp,esp

mov edx,[ebp+0x8]

mov eax,[ebp+0xc]

add eax,edx

mov esp,ebp

pop ebp

ret

push ebp

mov ebp,esp

push 0x0000

call 0x0000

add esp,4

push 4

push 3

call 0x0000

add esp,8

push eax

call 0x0000

add esp,4

mov esp,ebp

pop ebp

ret

end 0x0000

objectInfo 1:

> local labels

data _sEnteredString: 0000 [ 0020 ]

data _sEnteredNumber: 0011 [ 0074 ]

> data segment [34(0022)]

0: 1, ['Entered String: ',0]

1: 1, ['Entered Number: ',0]

> code segment [168(00a8)]

push ebp

mov ebp,esp

handy puts,0x0000

handy puts,[ebp+0x8]

mov esp,ebp

pop ebp

ret

push ebp

mov ebp,esp

handy puts,0x0000

handy putn,[ebp+0x8]

mov esp,ebp

pop ebp

ret

===== global labels =====

code _prints: [1] 0000 [ ]

code _printn: [1] 0054 [ ]

link complete

이 결과는 아주 중요하다. print.hdo 파일의 경우는, 지역 레이블에 _printn, _prints 레이블이 뜨지 않는다. 이는 우리가 정확히 원하는 결과다. 다만 문제는 main.hdo와 전역 레이블의 출력에 있다. main 오브젝트 파일에서 _prints, _printn을 참조하고 있는데도 전역 레이블 출력 부분에서 참조가 리스트에 들어가지 않았다. 그리고 main.hdo의 지역 레이블에 이들의 참조가 들어갔다. 왜 이런 일이 발생했을까?

문제는 목적 파일을 링크한 순서에 있다. _prints, _printn은 모두 전역 레이블이지만, main.hdo에는 이들이 전역 레이블임을 알 수 있는 장치가 없다. global _prints, global _printn과 같은 구문이 없다는 뜻이다. 만약 우리가 main.hdoprint.hdo의 순서를 바꿔서 load 메서드를 호출한다면, print.hdo를 먼저 load한 다음 main.hdo를 로드한다면 위와 다른 결과를 얻는다.

실행 결과 (log)

print.hdo 분석 결과

main.hdo 분석 결과

objectInfo 0:

> local labels

data _sEnteredString: 0000 [ 0020 ]

data _sEnteredNumber: 0011 [ 0074 ]

code puts: 0000 [ 0020 ]

> data segment [34(0022)]

0: 1, ['Entered String: ',0]

1: 1, ['Entered Number: ',0]

> code segment [168(00a8)]

push ebp

mov ebp,esp

...

objectInfo 1:

> local labels

data _sHelloWorld: 0000 [ 0077 ]

code _sum: 0000 [ 00a7 ]

code _main: 005d [ 00f3 ]

> data segment [14(000e)]

0: 1, ['hello, world!',0]

> code segment [250(00fa)]

push ebp

mov ebp,esp

mov edx,[ebp+0x8]

...

===== global labels =====

code _prints: [0] 0000 [ [object Object] ]

code _printn: [0] 0054 [ [object Object] ]

link complete

main.hdo에서 지역 레이블의 목록에 _prints, _printn이 없어지고, 전역 레이블에 이상한 객체가 들어갔다. 사실 이 객체는 우리가 전역 레이블에 대한 참조 위치를 넘길 때 생성한 객체다. toString 메서드를 오버라이딩 하지 않았기 때문에 기본 문자열이 출력된 것뿐이다.

gLabelInfo.refered.push({ objectIndex:objectIndex, offset:refered_addr });

따라서 이를 확인하기 위해 link 메서드를 다음과 같이 수정하면,

ProgramLinker.js (link.show_global_labels)

...

for (var i=0, len=info.refered.length; i<len; ++i) {

var refered = info.refered[i];

ss.write('[%d:%04x] ', refered.objectIndex, refered.offset);

}

...

프로그램을 실행했을 때 올바른 결과를 얻을 수 있다.

실행 결과 (log.global_labels)

===== global labels =====

code _prints: [0] 0000 [ [1:0083] ]

code _printn: [0] 0054 [ [1:00c6] ]

link complete

이 결과는 이렇게 해석하면 된다.

_prints

> _prints는 코드 영역에 정의되어있다.

> _prints0번째로 load한 목적 파일의 시작점에서 0만큼 떨어진 곳에 정의되어있다.

> _prints1번째로 load한 목적 파일이 0x0083 오프셋에서 참조한다.

_printn

> _printn은 코드 영역에 정의되어있다.> _printn0번째로 load한 목적 파일의 시작점에서 0x0054만큼 떨어진 곳에 정의되어있다.

> _printn1번째로 load한 목적 파일이 0x00c6 오프셋에서 참조한다.

결국 문제는 링크 순서에 따라 어떤 레이블이 전역 레이블임을 인지하지 못할 수도 있다는 것이다. 이 문제를 어떻게 해결해야 할까? 필자는 그 답으로 extern 지시어(directive)를 제안한다.

 

6.4) extern

extern은 특정 레이블이 파일의 외부에 정의되어있음을 링커에게 알리는 지시어다. 따라서 레이블을 사용하기 전에, extern 지시어로 해당 레이블이 전역에 정의되어있음을 알리면 된다. 다만 extern 키워드는 파일 내에서만 유효하기 때문에, extern 키워드로 지정된 레이블을 그냥 Linker.GlobalLabelDict에 넣으면 원래 내부 레이블로만 사용하려고 했던 것을 전역 레이블로 오인할 수 있다. 따라서 externLabelDict라는 딕셔너리를 새로 생성하고, load가 끝날 때 Linker.GlobalLabelDict에 참조 위치를 추가하는 식으로 구현한다. 여기서 global 또한 globalLabelDict라는 지역 변수를 만들고, 그 다음 Linker 모듈의 GlobalLabelDict에 추가하는 식으로 구현하도록 코드가 바뀐다.

코딩을 시작하자. 어떤 레이블이 전역 레이블인지 판정하는 is_global 메서드의 이름을 바꾼다.

ProgramLinker.js (load.is_global_label)

...

function is_public(label) { // is_global -> is_public으로 이름을 바꿉니다.

return (Program.Linker.GlobalLabelDict[label] != undefined);

}

...

디버깅을 편하게 하기 위해 전역 레이블 정보 형식 GlobalLabelInfo를 약간 바꾼다.

ProgramLinker.js (GlobalLabelInfo)

...

this.offset = -1; // 목적 파일의 인덱스로부터 세그먼트의 시작 위치를 계산합니다.

...

extern 레이블 정보, global 레이블 정보를 보관하는 딕셔너리를 생성한다.

ProgramLinker.js (load.prepare_externDict)

...

// extern 레이블에 대한 정보를 보관합니다.

var externLabelDict = {}; // 목적 파일 별로 다르기 때문에 매번 새로 생성합니다.

function is_external(label) {

return (externLabelDict[label] != undefined) ? true : false;

}

// global 레이블에 대한 정보를 보관합니다.

var globalLabelDict = {};

function is_global(label) {

return (globalLabelDict[label] != undefined) ? true : false;

}

...

그리고 global의 처리 밑에 extern에 대한 처리를 다음과 같이 추가한다.

ProgramLinker.js (load.extern)

...

else if (lineToken == 'extern') { // 레이블이 외부에 있음을 알리는 구문이라면

label = lineBuffer.get_token(); // 레이블을 획득합니다.

 

// 레이블 획득에 실패하면 예외 처리합니다.

if (label == null)

throw new LinkerException('directive extern got empty param');

else if (is_label_name(label) == false)

throw new LinkerException('directive extern got invalid param', label);

 

// 등록된 외부 레이블이 아니라면 딕셔너리에 새로 생성합니다.

// 이미 등록되어있다면 무시합니다.

if (is_external(label) == false) {

externLabelDict[label] = new GlobalLabelInfo

(objectIndex, segment_target, label);

}

continue; // 전역 레이블 지정자에 대한 처리를 종료합니다.

}

...

이제 외부 레이블을 참조하는 코드 세그먼트의 처리를 수정한다. 정의는 예외 처리하면 된다.

ProgramLinker.js (load.codeseg.define_label)

...

// 외부 레이블 딕셔너리에 등록되어있다면

if (is_external(label)) {

// 레이블 중복 정의로 간주하고 예외 처리합니다.

throw new LinkerException('label name is same with included external label');

}

// 전역 레이블 딕셔너리에 등록되어있다면

else if (is_global(label)) {

// 전역 레이블 정보 객체를 획득합니다.

var gLabelInfo = globalLabelDict[label];

 

// 현재 목적 파일의 인덱스와 발견한 레이블 정의를 기록합니다.

gLabelInfo.index = objectIndex;

gLabelInfo.offset = sizeOfCode;

}

...

여기서, 전역 레이블 정보를 Linker.Global에 바로 넣지 않고 globalLabelDict 지역 딕셔너리에 넣었다는 점을 눈여겨봐야 한다.

레이블의 참조는 다음과 같이 작성한다.

ProgramLinker.js (load.codeseg.ref_label)

...

// 외부 레이블이라면

if (is_external(label)) {

// 외부 레이블 정보 객체를 획득합니다.

var eLabelInfo = externLabelDict[label];

 

// 참조하고 있는 위치를 보관합니다.

eLabelInfo.refered.push({

objectIndex:objectIndex, offset:refered_addr

});

}

// 전역 레이블이라면

else if (is_global(label)) {

...

마지막으로, 획득한 외부 레이블 정보를 모두 전역 레이블 딕셔너리로 옮긴다.

ProgramLinker.js (load.end)

...

// 외부 레이블 정보를 모두 공용 전역 레이블 딕셔너리로 옮깁니다.

for (label in externLabelDict) {

// 외부 레이블 정보를 획득합니다.

var info = externLabelDict[label];

 

// 전역 레이블 딕셔너리에 정보가 있다면

if (is_public(label)) {

// 전역 레이블 정보를 획득합니다.

var gLabelInfo = Linker.GlobalLabelDict[label];

 

// 참조 위치 배열에 참조 위치를 추가합니다.

var ref = info.refered;

for (var i=0, len=ref.length; i<len; ++i) {

gLabelInfo.refered.push(ref[i]);

}

 

}

// 전역 레이블 딕셔너리에 정보가 없다면 바로 대치합니다.

else {

Linker.GlobalLabelDict[label] = info;

}

}

// 전역 레이블 정보를 모두 공용 전역 레이블 딕셔너리로 옮깁니다.

for (label in globalLabelDict) {

// 외부 레이블 정보를 획득합니다.

var info = globalLabelDict[label];

 

// 전역 레이블 딕셔너리에 정보가 있다면

if (is_public(label)) {

// 전역 레이블 정보를 획득합니다.

var gLabelInfo = Linker.GlobalLabelDict[label];

 

if (info.offset >= 0) {

gLabelInfo.index = info.index;

gLabelInfo.offset = info.offset;

}

 

// 참조 위치 배열에 참조 위치를 추가합니다.

var ref = info.refered;

for (var i=0, len=ref.length; i<len; ++i) {

gLabelInfo.refered.push(ref[i]);

}

 

}

// 전역 레이블 딕셔너리에 정보가 없다면 바로 대치합니다.

else {

Linker.GlobalLabelDict[label] = info;

}

}

 

// 목적 파일 정보를 보관하는 새 객체를 생성합니다.

...

그리고 테스트를 위해 main.hdo의 코드 영역 가장 위에 이 두 문장을 추가한다.

main.hdo (extern)

...

.code

extern _prints

extern _printn

;====================================

; proc sum

...

그러면 프로그램을 실행하여 다음 결과를 얻는다.

실행 결과 (log.show_global_labels)

===== global labels =====

code _prints: [1] 0000 [ [0:0083] ]

code _printn: [1] 0054 [ [0:00c6] ]

link complete

이제 모든 결과가 정상적으로 나온다. 우리에게 남은 건 이들을 실제로 묶는 과정뿐이다.

 

6.5) 레이블 위치 확인

링크 문제를 해결하기 위해 지금까지 획득한 모든 레이블 정보를 펼쳐보자.

실행 결과 (log)

main.hdo 분석 결과

print.hdo 분석 결과

objectInfo 0:

> local labels

data _sHelloWorld: 0000 [ 0077 ]

code _sum: 0000 [ 00a7 ]

code _main: 005d [ 00f3 ]

> data segment [14(000e)]

0: 1, ['hello, world!',0]

> code segment [250(00fa)]

push ebp

mov ebp,esp

mov edx,[ebp+0x8]

mov eax,[ebp+0xc]

add eax,edx

mov esp,ebp

pop ebp

ret

push ebp

mov ebp,esp

push 0x0000 ; _sHelloWorld

call 0x0000 ; _prints

add esp,4

push 4

push 3

call 0x0000 ; _sum

add esp,8

push eax

call 0x0000 ; _printn

add esp,4

mov esp,ebp

pop ebp

ret

end 0x0000 ; _main

objectInfo 1:

> local labels

data _sEnteredString: 0000 [ 0020 ]

data _sEnteredNumber: 0011 [ 0074 ]

code puts: 0000 [ 0020 ]

> data segment [34(0022)]

0: 1, ['Entered String: ',0]

1: 1, ['Entered Number: ',0]

> code segment [168(00a8)]

push ebp

mov ebp,esp

handy puts,0x0000 ; _sEnteredString

handy puts,[ebp+0x8]

mov esp,ebp

pop ebp

ret

push ebp

mov ebp,esp

handy puts,0x0000 ; _sEnteredNumber

handy putn,[ebp+0x8]

mov esp,ebp

pop ebp

ret

===== global labels =====

code _prints: [1] 0000 [ [0:0083] ]

code _printn: [1] 0054 [ [0:00c6] ]

link complete

우리가 값을 변경해야 하는 위치에 주석을 표시했다. 코드를 작성하여 레이블을 대치하기 전에 각각에 어떤 값이 들어가야 하는지를 계산해보는 것도 검증하기 좋을 것이다.

이제 값을 대치해보자. 전략은 이렇다.

1. 전역 레이블 딕셔너리의 목적 파일 인덱스와 오프셋을 이용하여, 전역 레이블의 정의를 계산한다.

우리는 각각의 목적 파일의 세그먼트 크기를 알고 있고, 세그먼트의 시작 지점부터 전역 레이블까지의 오프셋을 알고 있다. 따라서 데이터 세그먼트의 전체 크기를 획득하고, 현재 목적 파일까지의 코드 세그먼트의 크기를 모두 더한 다음 여기서 오프셋을 더하면 된다. 그림으로 보자. 다음과 같이 소스 코드 세 개가 나란히 있고, 전역 레이블 _labels2의 코드 영역에 정의되어있다고 하면,



이것을 링크했을 때 _label은 다음 위치에 있게 된다는 것이다.



일단 이 방식으로 모든 전역 레이블의 위치가 어디에 있는지를 계산해야 한다.

2. 목적 파일을 순서대로 해석하면서 지역 레이블과 전역 레이블을 대치한다.

3. 대치가 끝나면 파일에 출력한다.

load 메서드의 마지막에 다음 코드를 추가하는 것으로 시작하자.

ProgramLinker.js (load.end)

...

// 목적 파일 리스트의 데이터 세그먼트, 코드 세그먼트의 크기를 구합니다.

Linker.sizeOfData += sizeOfData;

Linker.sizeOfCode += sizeOfCode;

}

프로그램의 시작 지점을 설정하는 end 지시어에 대해 처리되지 않았으므로, 이에 대해 처리하자.

ProgramLinker.js (load.end_directive)

...

else if (lineToken == 'end') { // 프로그램 시작 지점이 발견되었다면

label = lineBuffer.get_token(); // 다음 토큰을 획득합니다.

Linker.entrypoint = label; // 시작 지점을 보관합니다.

continue;

}

...

한편, 링크를 목적으로 load한 파일을 사용하지 않게 될 경우를 위해 clear라는 메서드를 만든다.

ProgramLinker.js (clear)

/**

목적 파일 리스트를 비웁니다.

*/

function clear() {

var Linker = Program.Linker;

Linker.ObjectInfoList = [];

Linker.GlobalLabelDict = {};

Linker.sizeOfData = 0;

Linker.sizeOfCode = 0;

}

그리고 imageBase라는 필드가 Linker에 추가된다.

ProgramLinker.js (fields)

...

linker.imageBase = 4; // 메모리에 올라갈 바이트 배열의 시작점입니다.

...

우리는 Runner를 개발할 때 imageBase를 고려했기 때문에, Linker에서 이것이 고려되지 않은 상태로 레이블을 대치하면 imageBase만큼의 차이가 발생하게 된다.

이제 link 메서드를 수정할 차례다. 작성한 코드가 아까우니 이전 link의 이름을 test로 바꾼다.

ProgramLinker.js (load.end)

/**

테스트 메서드입니다.

*/

function test(filename) { // link -> test로 이름을 변경합니다.

...

그리고 위 전략을 토대로 link 메서드를 작성해보자.

ProgramLinker.js (link)

/**

불러온 목적 파일을 링크합니다.

@param {string} filename

*/

function link(filename) {

var Linker = Program.Linker;

var ObjectList = Linker.ObjectInfoList;

var GlobalDict = Linker.GlobalLabelDict;

 

/* ... */

}

link가 하는 일은 단순하다. load한 목적 파일을 모두 하나의 문자열 스트림에 기록한 다음, 이 스트림의 문자열을 파일로 출력한다.

이제 전역 레이블의 정의를 구하는 코드를 작성해야 하지만, 필자는 이들을 링크한 결과를 먼저 얻고자 한다. 레이블이 대치되지 않더라도 코드 자체는 0x0000으로 치환되어있어서, 이들을 메모리에 올린 다음 show 메서드를 실행하면 레이블이 어느 위치로 치환되어야 하는지 알기 쉽기 때문이다. 그러니 이에 대한 코드를 먼저 작성하자.

ProgramLinker.js (link)

function link(filename) {

var Linker = Program.Linker;

var ObjectList = Linker.ObjectInfoList;

var GlobalDict = Linker.GlobalLabelDict;

 

// 실행 가능한 목적 파일의 코드를 기록할 문자열 스트림입니다.

var dataStream = new Program.Stream.StringStream();

var codeStream = new Program.Stream.StringStream();

 

// 세그먼트 스트림에 맞게 정보를 출력합니다.

for (var i=0, len=ObjectList.length; i<len; ++i) {

// 목적 파일 정보를 획득합니다.

var objectInfo = ObjectList[i];

// 데이터 세그먼트 스트림에 데이터를 출력합니다.

var segmentData = objectInfo.segment.data;

for (var j=0, size=segmentData.length; j<size; ++j) {

var data = segmentData[j];

 

var typeString = '';

switch (data.typeSize) {

case 1: typeString = 'db'; break;

case 2: typeString = 'dw'; break;

case 4: typeString = 'dd'; break;

}

 

dataStream.writeln('%s %s', typeString, data.value);

}

// 코드 세그먼트 스트림에 데이터를 출력합니다.

var segmentCode = objectInfo.segment.code;

for (var j=0, size=segmentCode.length; j<size; ++j) {

var code = segmentCode[j];

codeStream.writeln(code);

}

}

}

그리고 main.html 파일을 다음과 같이 바꾼다.

main.html <html.head.script>

...

function main() {

Program.Linker.load('main.hdo');

Program.Linker.load('print.hdo');

Program.Linker.link('program.hdx');

 

Program.Runner.load('program.hdx');

Machine.Memory.show();

}

...

그러면 프로그램을 실행하여 다음 결과를 얻는다.

실행 결과 (program.hdx)

.data

db 'hello, world!',0

db 'Entered String: ',0

db 'Entered Number: ',0

 

.code

push ebp

mov ebp,esp

mov edx,[ebp+0x8]

mov eax,[ebp+0xc]

add eax,edx

mov esp,ebp

pop ebp

ret

push ebp

mov ebp,esp

push 0x0000

call 0x0000

add esp,4

push 4

push 3

call 0x0000

add esp,8

push eax

call 0x0000

add esp,4

mov esp,ebp

pop ebp

ret

push ebp

mov ebp,esp

handy puts,0x0000

handy puts,[ebp+0x8]

mov esp,ebp

pop ebp

ret

push ebp

mov ebp,esp

handy puts,0x0000

handy putn,[ebp+0x8]

mov esp,ebp

pop ebp

ret

이제 Low Level Assembly의 의미가 이해되는가? 우리가 Linker로 생성한 hdx 코드는 분명 어셈블리 언어로 작성되어있지만 HASM보다는 읽는 것이 아주 불편하다. 레이블도 눈에 보이지 않고, 조건 분기나 레이블 참조는 모두 레이블의 이름을 사용하는 것이 아니라 즉시 값을 사용한다. 그래서 HASM보다 수준이 낮다는 뜻으로 Low Level Assembly라고 이를 이름 붙인 것이다.

이 파일을 분석한 결과가 메모리에 출력되도록 코드를 작성했으니, 이를 한 번 보자.

실행 결과 (memory)

0x0000 | 00 00 00 00 68 65 6c 6c 6f 2c 20 77 6f 72 6c 64 | ....hello, world

0x0010 | 21 00 45 6e 74 65 72 65 64 20 53 74 72 69 6e 67 | !.Entered String

0x0020 | 3a 20 00 45 6e 74 65 72 65 64 20 4e 75 6d 62 65 | : .Entered Numbe

0x0030 | 72 3a 20 00 70 75 73 68 20 65 62 70 00 6d 6f 76 | r: .push ebp.mov

0x0040 | 20 65 62 70 2c 65 73 70 00 6d 6f 76 20 65 64 78 | ebp,esp.mov edx

0x0050 | 2c 5b 65 62 70 2b 30 78 38 5d 00 6d 6f 76 20 65 | ,[ebp+0x8].mov e

0x0060 | 61 78 2c 5b 65 62 70 2b 30 78 63 5d 00 61 64 64 | ax,[ebp+0xc].add

0x0070 | 20 65 61 78 2c 65 64 78 00 6d 6f 76 20 65 73 70 | eax,edx.mov esp

0x0080 | 2c 65 62 70 00 70 6f 70 20 65 62 70 00 72 65 74 | ,ebp.pop ebp.ret

0x0090 | 00 70 75 73 68 20 65 62 70 00 6d 6f 76 20 65 62 | .push ebp.mov eb

0x00a0 | 70 2c 65 73 70 00 70 75 73 68 20 30 78 30 30 30 | p,esp.push 0x000

0x00b0 | 30 00 63 61 6c 6c 20 30 78 30 30 30 30 00 61 64 | 0.call 0x0000.ad

0x00c0 | 64 20 65 73 70 2c 34 00 70 75 73 68 20 34 00 70 | d esp,4.push 4.p

0x00d0 | 75 73 68 20 33 00 63 61 6c 6c 20 30 78 30 30 30 | ush 3.call 0x000

0x00e0 | 30 00 61 64 64 20 65 73 70 2c 38 00 70 75 73 68 | 0.add esp,8.push

0x00f0 | 20 65 61 78 00 63 61 6c 6c 20 30 78 30 30 30 30 | eax.call 0x0000

0x0100 | 00 61 64 64 20 65 73 70 2c 34 00 6d 6f 76 20 65 | .add esp,4.mov e

0x0110 | 73 70 2c 65 62 70 00 70 6f 70 20 65 62 70 00 72 | sp,ebp.pop ebp.r

0x0120 | 65 74 00 70 75 73 68 20 65 62 70 00 6d 6f 76 20 | et.push ebp.mov

0x0130 | 65 62 70 2c 65 73 70 00 68 61 6e 64 79 20 70 75 | ebp,esp.handy pu

0x0140 | 74 73 2c 30 78 30 30 30 30 00 68 61 6e 64 79 20 | ts,0x0000.handy

0x0150 | 70 75 74 73 2c 5b 65 62 70 2b 30 78 38 5d 00 6d | puts,[ebp+0x8].m

0x0160 | 6f 76 20 65 73 70 2c 65 62 70 00 70 6f 70 20 65 | ov esp,ebp.pop e

0x0170 | 62 70 00 72 65 74 00 70 75 73 68 20 65 62 70 00 | bp.ret.push ebp.

0x0180 | 6d 6f 76 20 65 62 70 2c 65 73 70 00 68 61 6e 64 | mov ebp,esp.hand

0x0190 | 79 20 70 75 74 73 2c 30 78 30 30 30 30 00 68 61 | y puts,0x0000.ha

0x01a0 | 6e 64 79 20 70 75 74 6e 2c 5b 65 62 70 2b 30 78 | ndy putn,[ebp+0x

0x01b0 | 38 5d 00 6d 6f 76 20 65 73 70 2c 65 62 70 00 70 | 8].mov esp,ebp.p

0x01c0 | 6f 70 20 65 62 70 00 72 65 74 00 00 00 00 00 00 | op ebp.ret......

...

굉장히 눈 아픈 결과지만 한 번 보자. 일단 알아보기 쉽게 프로시저 단위로 색을 구분했다. 코드를 자세히 보면, 주황색은 데이터 세그먼트, 파란색은 _sum이고, 빨간색, 옥색, 갈색은 _main, _prints, _printn임을 파악할 수 있다. 이 내용을 바탕으로 레이블의 위치를 파악하면 다음과 같다.

_sHelloWorld 0x0004, _sEnteredString 0x0012, _sEnteredNumber 0x0023,

_sum 0x0034, _main 0x0091, _prints 0x0123,

_printn 0x0177

따라서 이 내용에 맞게 레이블을 대치할 수 있으면 링크를 성공하는 것이다.

 

6.6) 전역 레이블 위치 계산

지금 우리가 작성한 코드를 다시 한 번 보자. 코드 세그먼트에서 가져온 코드를 별다른 변환 없이 바로 스트림에 기록을 하고 있다.

ProgramLinker.js (link.write_stream)

...

// 코드 세그먼트로부터 코드를 가져옵니다.

var code = segmentCode[j];

// 가져온 코드를 스트림에 출력합니다.

codeStream.writeln(code);

...

그런데 여기에 왼쪽 인자와 오른쪽 인자가 무엇이었는지를 별도로 보관하면, 레이블을 대치할 때 아주 편리할 것 같다. 그러니 코드를 보관하는 부분을 다음과 같이 변경하자.

ProgramLinker.js (load.codeseg.push)

...

// 획득한 줄 텍스트를 보관합니다.

segment[segment_target].push({

text:s, mnemonic:info.mnemonic, left:info.left, right:info.right

});

...

그리고 일단 dataStream, codeStream 이하는 무시하기로 하자. 이제 비로소 링크를 시작할 준비가 되었다. 먼저 전역 레이블의 정의를 계산해보자.

ProgramLinker.js (link.showGlobalLabelDef)

...

// 1. 전역 레이블의 정의를 계산합니다.

var imageBase = Linker.imageBase;

var dataBases = [];

var codeBases = [];

var baseOfData = 0;

var baseOfCode = 0;

var sizeOfData = Linker.sizeOfData;

// 목적 파일 정보를 순회하면서 세그먼트의 시작점을 찾아 보관합니다.

for (var i=0, len=ObjectList.length; i<len; ++i) {

// 목적 파일 정보를 획득합니다.

var objectInfo = ObjectList[i];

 

// i번째 목적 파일의 시작점을 보관합니다.

dataBases.push(baseOfData);

codeBases.push(baseOfCode);

 

// i+1번째 목적 파일의 시작점을 획득합니다.

baseOfData += objectInfo.sizeOfData;

baseOfCode += objectInfo.sizeOfCode;

}

// 획득한 세그먼트의 시작점을 바탕으로 전역 레이블의 정의를 계산합니다.

for (gLabel in GlobalDict) {

// 전역 레이블 정보를 획득합니다.

var globalInfo = GlobalDict[gLabel];

 

// 전역 레이블의 위치는

// 1. 세그먼트의 시작점(imageBase)

// 2. 코드 세그먼트의 시작점(sizeOfData)

// 3. 코드 세그먼트에서 목적 파일의 코드 세그먼트 시작점(codeBases)

// 4. 세그먼트 시작점으로부터의 오프셋(offset)

// 의 합입니다.

globalInfo.offset +=

(imageBase + sizeOfData + codeBases[globalInfo.index]);

// 이후부터 목적 파일의 인덱스를 신경쓰지 않습니다.

globalInfo.index = -1;

}

 

test();

return;

...

반복문이 총 두 개가 있다. 프로그램을 실행하면 먼저 나오는 반복문에서, 목적 파일 리스트에서 요소들을 순회하면서 각각의 목적 파일의 세그먼트 시작 위치를 보관해놓는다. 그 다음 반복문이 바로 정의를 대치하는 구문이다. 전역 레이블의 위치는 imageBase + sizeOfData + codeBase + offset이 되는데, 이는 좀 오래 고민해야 이해할 수 있으니 생각해보기 바란다.

마지막으로 test를 호출하여 전역 레이블의 정의가 제대로 들어갔는지 확인할 필요가 있다.

실행 결과 (GlobalLabelOffset)

===== global labels =====

code _prints: [-1] 0123 [ [0:0083] ]

code _printn: [-1] 0177 [ [0:00c6] ]

link complete

load complete

이는 우리가 6.5절 마지막에서 구한 결과와 일치한다. 따라서 정의 문제는 해결되었다. 그런데 우리는 이미 각 세그먼트의 시작점과 전체 데이터 세그먼트의 크기를 구했기 때문에, 사실 참조 문제도 이 반복문에서 해결할 수가 있다. 이것도 진행하자.

ProgramLinker.js (link.GlobalLabelReferenceOffset)

...

// 전역 레이블의 참조 위치는

// 1. 세그먼트의 시작점(imageBase)

// 2. 코드 세그먼트의 시작점(sizeOfData)

// 3. 코드 세그먼트에서 참조 목적 파일의 코드 세그먼트 시작점(codeBases)

// 4. 세그먼트 시작점으로부터의 오프셋(offset)

// 의 합입니다.

var ref = globalInfo.refered;

for (var j=0, gCount=ref.length; j<gCount; ++j) {

ref[j].offset +=

(imageBase + sizeOfData + codeBases[ref[j].objectIndex]);

}

 

// 이후부터 목적 파일의 인덱스를 신경쓰지 않습니다.

globalInfo.index = -1;

...

이 과정까지 진행한 후 프로그램을 실행하면 다음 결과를 얻는다.

실행 결과 (GlobalLabelOffset)

===== global labels =====

code _prints: [-1] 0123 [ [0:00b7] ]

code _printn: [-1] 0177 [ [0:00fa] ]

link complete

load complete

이 결론이 타당한가는 다음 메모리 상태를 통해 확인할 수 있다.

실행 결과 (memory)

...

0x00b0 | 30 00 63 61 6c 6c 20 30 78 30 30 30 30 00 61 64 | 0.call 0x0000.ad

...

0x00f0 | 20 65 61 78 00 63 61 6c 6c 20 30 78 30 30 30 30 | eax.call 0x0000

...

이를 통해 참조 위치가 정확하계 계산되었음을 확인할 수 있다.

 

6.7) 지역 레이블 위치 계산

지역 레이블의 위치 계산은 전역의 경우보다 쉽다. 레이블이 언제나 목적 파일에 존재해서, 목적 파일 인덱스를 고려하지 않아도 되기 때문이다. 또한 이러한 특성을 이용하면, 지역 레이블의 위치를 계산하면서 코드 세그먼트 스트림에 문자열을 출력할 수도 있다. 바로 진행하자.

6.6절에서 segment.code에 푸시하는 것이 문자열에서 객체로 바뀌었기 때문에, 이를 반영하여 이전에 작성한 코드를 다음과 같이 수정해야 한다.

ProgramLinker.js (link.write_code)

...

for (var j=0, size=segmentCode.length; j<size; ++j) {

// 코드 세그먼트로부터 코드를 가져옵니다.

var code = segmentCode[j];

 

// 피연산자가 비어있지 않은 경우

if (code.left != undefined) {

var left = '', right = '';

var leftLabel = null, rightLabel = null;

 

// 피연산자가 레이블이라면 레이블 이름을 획득합니다.

if (is_label_name(code.left)) { // 레이블이라면

var leftLabel = code.left;

left = ' 0x0000';

}

else { // 레이블이 아니라면 그냥 추가합니다.

left = ' ' + code.left;

}

if (code.right != undefined) { // 오른쪽 피연산자가 있다면

if (is_label_name(code.right)) { // 레이블이라면

var rightLabel = code.right;

right = ',0x0000';

}

else { // 레이블이 아니라면 그냥 추가합니다.

right = ',' + code.right;

}

}

 

// 코드 세그먼트의 코드를 형식에 맞춰 출력합니다.

var text = Handy.format

('%s%s%s', code.mnemonic, left, right);

 

// 형식화된 문자열을 코드 세그먼트 스트림에 출력합니다.

codeStream.writeln(text);

}

else {

// 가져온 코드를 스트림에 출력합니다.

codeStream.writeln(code.text);

}

}

...

지역 레이블의 정의와 참조 문제가 아직 해결되지 않았기 때문에, 레이블에 대한 처리는 일단 0으로 채우기로 했다. 그럼 지역 레이블의 정의와 참조 문제를 해결해보자.

ProgramLinker.js (link.calc_local_label)

...

// 구하려는 레이블의 소속에 따라 시작점을 결정합니다.

var segmentBase = 0;

if (labelInfo.segmentName == 'data') {

segmentBase = dataBases[i];

// 지역 레이블의 위치는

// 1. 세그먼트의 시작점(imageBase)

// 2. 세그먼트에서 목적 파일의 세그먼트 시작점(segmentBase)

// 3. 세그먼트 시작점으로부터의 오프셋(offset)

// 의 합입니다.

labelInfo.offset += (imageBase + segmentBase);

// 지역 레이블의 참조 위치는

// 1. 세그먼트의 시작점(imageBase)

// 2. 세그먼트에서 목적 파일의 세그먼트 시작점(segmentBase)

// 3. 세그먼트 시작점으로부터의 오프셋(offset)

// 의 합입니다.

var ref = labelInfo.refered;

for (var j=0, gCount=ref.length; j<gCount; ++j) {

ref[j] += (imageBase + segmentBase);

}

}

else {

segmentBase = codeBases[i];

// 지역 레이블의 위치는

// 1. 세그먼트의 시작점(imageBase)

// 2. 코드 세그먼트의 시작점(sizeOfData)

// 3. 세그먼트에서 목적 파일의 세그먼트 시작점(segmentBase)

// 4. 세그먼트 시작점으로부터의 오프셋(offset)

// 의 합입니다.

labelInfo.offset += (imageBase + sizeOfData + segmentBase);

// 지역 레이블의 참조 위치는

// 1. 세그먼트의 시작점(imageBase)

// 2. 코드 세그먼트의 시작점(sizeOfData)

// 3. 세그먼트에서 목적 파일의 세그먼트 시작점(segmentBase)

// 4. 세그먼트 시작점으로부터의 오프셋(offset)

// 의 합입니다.

var ref = labelInfo.refered;

for (var j=0, gCount=ref.length; j<gCount; ++j) {

ref[j] += (imageBase + sizeOfData + segmentBase);

}

}

...

링커 문제에서 가장 헷갈리는 부분이 등장했다.

 

이것을 실행하면 다음 결과를 얻는다.

실행 결과 (GlobalLabelOffset)

main.hdo 분석 결과

print.hdo 분석 결과

objectInfo 0:

> local labels

data _sHelloWorld: 0004 [ 007b ]

code _sum: 0034 [ 00db ]

code _main: 0091 [ ]

> data segment [14(000e)]

0: 1, ['hello, world!',0]

> code segment [239(00ef)]

...

objectInfo 1:

> local labels

data _sEnteredString: 0012 [ 0032 ]

data _sEnteredNumber: 0023 [ 0086 ]

> data segment [34(0022)]

0: 1, ['Entered String: ',0]

1: 1, ['Entered Number: ',0]

> code segment [168(00a8)]

...

===== global labels =====

code _prints: [-1] 0123 [ [0:00b7] ]

code _printn: [-1] 0177 [ [0:00fa] ]

link complete

load complete

이 결과가 타당하다는 것은 다음을 보면서 비교하면 명백하다.

_sHelloWorld 0x0004, _sEnteredString 0x0012, _sEnteredNumber 0x0023,

_sum 0x0034, _main 0x0091, _prints 0x0123,

_printn 0x0177

이로써 데이터 세그먼트의 정의와 참조의 대치가 끝난다. 나머지는 이를 적용하는 과정뿐이다.

 

6.8) link

코드 세그먼트 스트림에 데이터를 출력하는 반복문을 다음과 같이 변경한다.

ProgramLinker.js (link.codeseg.write)

...

// 코드 세그먼트 스트림에 데이터를 출력합니다.

var segmentCode = objectInfo.segment.code;

for (var j=0, size=segmentCode.length; j<size; ++j) {

// 코드 세그먼트로부터 코드를 가져옵니다.

var code = segmentCode[j];

 

// 피연산자가 비어있지 않은 경우

if (code.left != undefined) {

var left = '', right = '';

var leftLabel = null, rightLabel = null;

 

// 피연산자가 레이블이라면 레이블 이름을 획득합니다.

if (is_label_name(code.left)) { // 레이블이라면

var leftLabel = code.left;

 

var leftInfo = null;

if (is_public(leftLabel)) {

leftInfo = Linker.GlobalLabelDict[leftLabel];

}

else {

leftInfo = labelDict[leftLabel];

}

 

// 획득한 레이블의 정의로 대치합니다.

left = Handy.format(' 0x%04x', leftInfo.offset);

}

else { // 레이블이 아니라면 그냥 추가합니다.

left = ' ' + code.left;

}

if (code.right != undefined) { // 오른쪽 피연산자가 있다면

if (is_label_name(code.right)) { // 레이블이라면

var rightLabel = code.right;

 

var rightInfo = labelDict[rightLabel];

if (is_public(rightLabel)) {

rightInfo = Linker.GlobalLabelDict[rightLabel];

}

else {

rightInfo = labelDict[rightLabel];

}

 

// 획득한 레이블의 정의로 대치합니다.

right = Handy.format(',0x%04x', rightInfo.offset);

}

else { // 레이블이 아니라면 그냥 추가합니다.

right = ',' + code.right;

}

}

 

// 코드 세그먼트의 코드를 형식에 맞춰 출력합니다.

var text = Handy.format

('%s%s%s', code.mnemonic, left, right);

 

// 형식화된 문자열을 코드 세그먼트 스트림에 출력합니다.

codeStream.writeln(text);

}

else {

// 가져온 코드를 스트림에 출력합니다.

codeStream.writeln(code.text);

}

}

...

그리고 마지막에 파일을 출력하는 구문을 넣으면,

ProgramLinker.js (link.write_to_file)

...

// 파일에 출력합니다.

var sstream = new Program.Stream.StringStream();

sstream.writeln('.data');

sstream.writeln(dataStream.str);

sstream.writeln('.code');

sstream.writeln(codeStream.str);

HandyFileSystem.save(filename, sstream.str);

...

프로그램을 실행하여 다음 결과를 얻는다.

실행 결과 (program.hdx)

.data

db 'hello, world!',0

db 'Entered String: ',0

db 'Entered Number: ',0

 

.code

push ebp

mov ebp,esp

mov edx,[ebp+0x8]

mov eax,[ebp+0xc]

add eax,edx

mov esp,ebp

pop ebp

ret

push ebp

mov ebp,esp

push 0x0004

call 0x0123

add esp,4

push 4

push 3

call 0x0034

add esp,8

push eax

call 0x0177

add esp,4

mov esp,ebp

pop ebp

ret

push ebp

mov ebp,esp

handy puts,0x0012

handy puts,[ebp+0x8]

mov esp,ebp

pop ebp

ret

push ebp

mov ebp,esp

handy puts,0x0023

handy putn,[ebp+0x8]

mov esp,ebp

pop ebp

ret

실행 결과 (memory)

0x0000 | 00 00 00 00 68 65 6c 6c 6f 2c 20 77 6f 72 6c 64 | ....hello, world

0x0010 | 21 00 45 6e 74 65 72 65 64 20 53 74 72 69 6e 67 | !.Entered String

0x0020 | 3a 20 00 45 6e 74 65 72 65 64 20 4e 75 6d 62 65 | : .Entered Numbe

0x0030 | 72 3a 20 00 70 75 73 68 20 65 62 70 00 6d 6f 76 | r: .push ebp.mov

0x0040 | 20 65 62 70 2c 65 73 70 00 6d 6f 76 20 65 64 78 | ebp,esp.mov edx

0x0050 | 2c 5b 65 62 70 2b 30 78 38 5d 00 6d 6f 76 20 65 | ,[ebp+0x8].mov e

0x0060 | 61 78 2c 5b 65 62 70 2b 30 78 63 5d 00 61 64 64 | ax,[ebp+0xc].add

0x0070 | 20 65 61 78 2c 65 64 78 00 6d 6f 76 20 65 73 70 | eax,edx.mov esp

0x0080 | 2c 65 62 70 00 70 6f 70 20 65 62 70 00 72 65 74 | ,ebp.pop ebp.ret

0x0090 | 00 70 75 73 68 20 65 62 70 00 6d 6f 76 20 65 62 | .push ebp.mov eb

0x00a0 | 70 2c 65 73 70 00 70 75 73 68 20 30 78 30 30 30 | p,esp.push 0x000

0x00b0 | 34 00 63 61 6c 6c 20 30 78 30 31 32 33 00 61 64 | 4.call 0x0123.ad

0x00c0 | 64 20 65 73 70 2c 34 00 70 75 73 68 20 34 00 70 | d esp,4.push 4.p

0x00d0 | 75 73 68 20 33 00 63 61 6c 6c 20 30 78 30 30 33 | ush 3.call 0x003

0x00e0 | 34 00 61 64 64 20 65 73 70 2c 38 00 70 75 73 68 | 4.add esp,8.push

0x00f0 | 20 65 61 78 00 63 61 6c 6c 20 30 78 30 31 37 37 | eax.call 0x0177

0x0100 | 00 61 64 64 20 65 73 70 2c 34 00 6d 6f 76 20 65 | .add esp,4.mov e

0x0110 | 73 70 2c 65 62 70 00 70 6f 70 20 65 62 70 00 72 | sp,ebp.pop ebp.r

0x0120 | 65 74 00 70 75 73 68 20 65 62 70 00 6d 6f 76 20 | et.push ebp.mov

0x0130 | 65 62 70 2c 65 73 70 00 68 61 6e 64 79 20 70 75 | ebp,esp.handy pu

0x0140 | 74 73 2c 30 78 30 30 31 32 00 68 61 6e 64 79 20 | ts,0x0012.handy

0x0150 | 70 75 74 73 2c 5b 65 62 70 2b 30 78 38 5d 00 6d | puts,[ebp+0x8].m

0x0160 | 6f 76 20 65 73 70 2c 65 62 70 00 70 6f 70 20 65 | ov esp,ebp.pop e

0x0170 | 62 70 00 72 65 74 00 70 75 73 68 20 65 62 70 00 | bp.ret.push ebp.

0x0180 | 6d 6f 76 20 65 62 70 2c 65 73 70 00 68 61 6e 64 | mov ebp,esp.hand

0x0190 | 79 20 70 75 74 73 2c 30 78 30 30 32 33 00 68 61 | y puts,0x0023.ha

0x01a0 | 6e 64 79 20 70 75 74 6e 2c 5b 65 62 70 2b 30 78 | ndy putn,[ebp+0x

0x01b0 | 38 5d 00 6d 6f 76 20 65 73 70 2c 65 62 70 00 70 | 8].mov esp,ebp.p

0x01c0 | 6f 70 20 65 62 70 00 72 65 74 00 00 00 00 00 00 | op ebp.ret......

...

이제 진입점 문제만 남았는데, 이는 어렵지 않다. 다만 실행기 Runner의 행동이 약간 바뀐다.

먼저 end 지시어를 Linker에서 처리해보자. 어떤 프로시저가 프로그램의 시작점을 알리려면 이렇게 하는 것도 하나의 방법이다.

...

.code

call _main

exit

...

_main:

코드 영역의 시작점에 call _main을 등장시키는 것으로 프로시저의 시작점을 _main으로 맞출 수 있는 것이다. 다만 우리가 작성한 예제에서는 이것을 고려하려면 대규모의 오프셋 조정이 일어난다. 그래서 여기에서는 이런 방법을 쓴다.

...

.code

...

_main:

...

call 0x00ab ; _main = 0x00ab

exit

작성된 코드의 가장 마지막에 프로시저의 시작 지점을 알리는 코드를 넣는다. 이렇게 하면, 코드의 가장 마지막 지점에서 ‘call 0x00ab\nexit' 문자열의 길이만큼만 뺀 위치를 프로그램의 시작점으로 고정하는 것으로 문제를 해결할 수 있다. 그래서 우리 예제도 이런 방식으로 작성할 것이다.

먼저 end 지시어로 설정한 프로시저 시작 레이블의 위치를 획득하는 것으로 시작하자.

ProgramLinker.js (link.write.end)

...

// 파일에 출력합니다.

var sstream = new Program.Stream.StringStream();

sstream.writeln('.data');

sstream.writeln(dataStream.str);

sstream.writeln('.code');

sstream.writeln(codeStream.str);

 

// 진입점의 레이블을 획득합니다.

var entryLabel = Linker.entrypoint;

 

// 진입점의 절대 위치를 획득합니다.

var entrypoint = Linker.GlobalLabelDict[entryLabel].offset;

 

// 파일의 끝에 기록합니다.

sstream.writeln('call 0x%04x', entrypoint);

sstream.write('exit');

 

HandyFileSystem.save(filename, sstream.str);

...

그리고 여기서 진입점 프로시저가 반드시 global 형태로 정의되어야 함을 추론할 수 있다. main.hdo 파일을 다음과 같이 수정하자.

main.hdo

...

;====================================

; proc main

;====================================

global _main

_main:

...

Machine.Processor 모듈로 이동하여 run 메서드를 수정한다.

MachineProcessor.js (run)

...

setip(Runner.entrypoint);

...

이제 Program.Runner 모듈로 이동하여 load 메서드를 수정한다.

ProgramRunner.js (load)

...

setip(Runner.entrypoint);

...

Machine.Memory 모듈에 존재하는 오류를 하나 잡는다.

MachineMemory.js (get_memory_value)

function get_memory_value(param) {

var Register = Machine.Processor.Register; // 추가

...

// 획득한 레지스터가 보관하고 있는 값을 획득합니다.

var regval = Register.get(reg); // Register[reg]에서 수정

...

}

그리고 MachineOperate 모듈에 handy putn에 대한 처리를 추가하자.

MachineMemory.js (handy.putn)

...

else if (left == 'putn') {

var value = null;

 

// 레지스터라면 레지스터 값 획득

if (Register.is_register(right)) {

value = Register.get(right); // Register[right];

}

// 메모리라면 메모리 값 획득

else if (Memory.is_memory(right)) {

value = Memory.get_memory_value(right);

}

// 모두 아니라면 즉시 값 획득

else {

value = parseInt(right);

}

 

Stream.out.write(value);

}

...

마지막으로 main.html 파일에서 이 프로그램을 실행하는 구문을 추가하면,

main.html <html.head.script>

function main() {

Program.Linker.load('main.hdo');

Program.Linker.load('print.hdo');

Program.Linker.link('program.hdx');

Program.Runner.load('program.hdx');

Program.Runner.run();

Machine.Memory.show();

}

...

다음과 같이 성공적으로 동작하는 것을 확인할 수 있다.

실행 결과 (output)

Entered String: hello, world!Entered Number: 7

실행 결과 (expression)

call 0x0091

push ebp

mov ebp,esp

push 0x0004

call 0x0123

push ebp

mov ebp,esp

handy puts,0x0012

handy puts,[ebp+0x8]

mov esp,ebp

pop ebp

ret

add esp,4

push 4

push 3

call 0x0034

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

push eax

call 0x0177

push ebp

mov ebp,esp

handy puts,0x0023

handy putn,[ebp+0x8]

mov esp,ebp

pop ebp

ret

add esp,4

mov esp,ebp

pop ebp

ret

이로써 길었던 링커 개발이 끝이 난다.

 

7. 단원 마무리

사실 이 프로젝트는 발전시키면 dll같은 동적 라이브러리도 동작할 수 있게 되지만, 이것까지는 내 연구에 포함되어있지 않았다. (이 경우는 Linker뿐만 아니라 Runner도 수정해야 할 것 같다)

링커를 만드는 것이 재미있는 건, 정말 사소한 이유로 위치가 틀려서 해당 위치로 값을 맞춰주어야 하고, 이것이 성공했을 때의 쾌감이 크기 때문이다. 만약 우리가 실제 주소 모드로 프로그래밍을 한다고 하면, 우리는 각각의 메모리 위치를 이런 식으로 직접 관리해야 한다. 가상 메모리를 통해 변수를 간단하게 선언할 수 있는 건, 생각보다 정말 굉장한 축복인 것이다.

다음은 최종장인 C 컴파일러를 다룬다. 단언컨대 이 이후가 JSCC 프로젝트에서 가장 재미있다. 우리는 RunnerCompiler를 오가면서 코딩을 할 것이고, 그렇게 하나하나 필요한 구문을 완성해나갈 것이다. 다음 문서를 읽고 나면 C언어가 무엇인지, 나아가서는 고급 프로그래밍 언어가 어떤 것인지에 대해 감을 잡을 수 있으리라 확신한다.

'알려주기' 카테고리의 다른 글

Git Usages  (0) 2015.07.18
[JSCC] 11. C 컴파일러 개발 (기본편)  (4) 2015.07.17
[JSCC] 9. 가상 머신 개발 2  (0) 2015.07.03
[JSCC] 8. 가상 머신 개발 1  (2) 2015.06.26
[JSCC] 7. JSCC 준비  (0) 2015.06.19
Posted by 누아니
,

가상 머신 개발 2

HandyPost는 한 도영(HDNua)이 작성하는 포스트 문서입니다.


소스: 

09_Runner2.zip


문서: 

09. 가상 머신 개발 2.pdf

 

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, decodeexecute는 이전 문서에서 Runner 모듈을 작성할 때 멤버로 추가한 메서드의 이름이다. 지금 이 얘기를 왜 다시 하는 걸까? 그 이유는 우리가 명령 포인터의 존재를 이전 절을 통해 알았기 때문이다.

처음으로 돌아가보자. 프로세스가 다음과 같은 상태로 실행되었다.



그러면 먼저 메모리에 올라간 명령을 획득하는 fetch 과정이 수행된다.



그 다음 획득한 명령을 분석하여 실행 가능한 상태로 만드는 decode 작업이 수행된다.



이때는 메모리가 변하지 않는다. 마지막으로 명령을 실행한다.



이렇게 push ebp 명령 하나를 분석하는 과정이 끝이 난다.

 

2.3) 전략

이제 eip 레지스터의 필요성을 이해했으니, 이를 Runner 모듈에 반영해보자. 그 전에 프로그램과 프로세스가 어떻게 표현되는지를 한 번 정리하고 가는 것이 좋겠다.

프로세스가 되면 크게 네 개의 세그먼트로 구분할 수 있다고 하였다. 데이터 세그먼트, 코드 세그먼트, 스택 세그먼트, 힙 세그먼트가 그것이다. 각각의 역할에 대해서는 3장을 참조하라.

중요한 내용인데, 힙과 스택 메모리는 보통 다음과 같은 식으로 구현이 된다.

 


위 그림은 프로세스가 메모리에 올라갔을 때, 메모리의 상태를 그림으로 표현한 것이다. 여기서 주목해야 할 점은, 힙 메모리는 그 시작점이 왼쪽에, 스택 메모리의 시작점은 오른쪽에 있어서, 이 둘에 접근하는 포인터가 가운데로 모이도록 되어있다는 것이다. 여기서 우리가 push 니모닉을 구현할 때 esp 레지스터의 값을 뺀 이유가 나타난다. 스택에 값을 푸시하거나 지역 변수를 생성할 때는 esp 레지스터의 값을 확보하려는 영역의 크기만큼 뺀다! 지면 낭비를 막기 위해 앞으로는 색깔에 대해 별도로 그림에 표시하지 않을 것이니, 이들 색깔에 대해 잘 기억하기 바란다.

일단 힙은 지금의 수준으로는 구현할 수 없다. 스택의 경우는 사실 이미 구현이 되었다. push 니모닉을 이용해 메모리에 값을 푸시할 수 있지 않았는가! 따라서 여기서는 코드 세그먼트와 스택 세그먼트에 대해서만 우선 처리한 다음 데이터 세그먼트와 힙에 대해서도 차근차근 작업을 진행하도록 하겠다.

한 가지 더 고려해야 할 것이 있다. 코드 세그먼트에 코드를 어떻게 올릴 것인가? 컴퓨터가 처음 나왔을 시점에는 기계어밖에 없었으므로, 1000push, 1001eax 등으로 정의한 다음 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)



레지스터 스트림만 그렇게 마음에 들지 않는 결과가 나왔다. eaxundefined가 들어간 것이 보이는가? 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)

결국 leftright가 모두 문자열이다. 먼저 left를 정수로 바꿀 수 있는지 확인한다. JavaScript에는 문자열을 정수로 바꿀 수 있다면 값을 바꿔서 돌려주는 parseInt라는 메서드가 제공된다.

var value = parseInt(left); // parseInt('eax')를 실행. 숫자가 아니면 NaN이라는 값을 반환

그리고 'eax'는 숫자가 아니므로 NaN이라는 괴상한 것이 반환된다. NaNNot a Number의 줄임말이다. 따라서 이때 valueNaN과 같으므로, 조건문에 다음과 같이 작성함으로써 정수가 아니라고 판정할 수 있다.

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 소스 파일을 분석해보자. 이 소스를 바로 실행하면 프로그램이 정상적으로 실행되지 않는다. 그 이유는 우리가 loadrun 메서드를 새로 구현하면서 레이블에 대한 처리를 해주지 않았기 때문이다. 그럼 고민해보자. 레이블을 스스로 처리할 수 있겠는가?

 

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의 위치를 그렸다면 내용을 정확하게 이해하고 있는 것이다.



그리고 이를 통해 명령 포인터가 어디를 가리켜야 하는지도 답이 나왔다. ip0x39번지를 가리켜야 한다. 이는 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에 의해 결정되는 것이다. mnemov 문자열이라면 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

 

; eax5를 비교하여 결과를 eflags에 저장합니다.

cmp eax, 5

 

; eflagsz 플래그가 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이 된다. jnzjump if not zero의 줄임말로, 결과가 0이 아닌 경우 점프하겠다는 니모닉이다. 이 둘을 합치면 condition.hda는 결국 다음 코드가 된다.

condition.hdaC로 작성한 코드

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:

}

그럼 영 플래그에 대해서만 코드를 구현해보자. Registereflags 레지스터를 추가한다.

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

산술 연산 시 비트 34 사이에서 산술 캐리가 발생하면 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

여기서 중요한 두 니모닉은 callret이다. 그런데 이 둘의 구현은 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_registerRegister 모듈에 있는데, 그러면 이 녀석은 is_memory이니 Memory에 넣어야 하는가?

사실, is_registerRegister 모듈에 속하는 것이 구현할 때 편리한 경우가 있다. 하지만 인자로 주어진 문자열이 메모리인지 판정하는 이 함수는, 사실 문자열만 확인하면 되므로 반드시 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 객체에 먼저 보관해놓은 후,



이 두 세그먼트 정보를 종합하여 기록하는 식으로 작성한다.



이 과정 또한 아주 헷갈리는데, 이는 데이터 세그먼트가 코드 세그먼트의 앞에 추가되면서 레이블의 위치가 바뀌기 때문이다. 이에 대해서는 나중에 다루고, 일단 이들을 획득해보자.

먼저 StringBufferget_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, dwBYTE와 같이, 모두 각자의 크기를 가지고 있다. 따라서 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 컴파일러 개발 (기본편)  (4) 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
Posted by 누아니
,

가상 머신 개발 1

HandyPost는 한 도영(HDNua)이 작성하는 포스트 문서입니다.


소스: 

08_Runner1.zip


문서: 

08. 가상 머신 개발 1.pdf



1. 개요

JSCC, 우리의 JavaScript C Compiler를 위한 가상 머신을 개발한다.

 

2. Runner: 작성 시작

Runner는 실행기다따라서 Runner는 7장의 4.2절에서 보인 코드를 실행할 수 있어야 한다그런데 코드를 실행하려면일단 코드가 어떤 내용인지 분석해야 하는 것이 당연하다이 코드를 어떻게 분석해야 할까이 절에서는 코드를 분석하는 방법을 다루고 분석한 코드를 실행하는 코드까지 작성할 것이다. (당연히 HelloHASM.hda는 이 문서에 있으니그냥 읽으면 된다)


2.1) 중요: 규칙은 우리 마음대로 정해도 되는 거에요!

더 이야기를 진행하기 전에 한 번 정리해야겠다. 우리가 지금 어셈블리 언어를 해석하는 방법을 공부하려고 하지만, 실제로 실행기가 분석할 코드의 문법은 우리가 마음대로 정해도 되는 것이다. 여기서 어셈블리 언어와 비슷한 HASM을 선택한 것은, HASM이 고급 언어에 비해 분석이 상대적으로 쉽다는 점, 문법을 우리 마음대로 정의해보면서 정말 컴파일러를 만드는 느낌을 얻을 수 있다는 점, 실제로 어셈블리로 대치하기 쉽다는 점 때문이지, 그렇게 해야 하는 것이 전혀 아니다. 예를 들면, 분석할 수만 있다면 우리는 다음처럼 코드를 작성해도 전혀 상관없다.

++++++++++

[>+++++++>++++++++++>+++>+<<<<-]

>++.

>+.

+++++++.

.

+++.

>++.

<<+++++++++++++++.

>.

+++.

------.

--------.

>+.

>.

참고로 이거 실제로 존재하는 코드다! 관심이 있는 사람이라면 브레인퍽(Brainfuck) 프로그래밍 언어에 대해 찾아보면 재미있을 것이다. (참고로 이 코드는 무려 “Hello World!”를 출력하는 코드라고 한다)

 

2.2) 전략

분석에 앞서, 보기 불편하니 이전에 보인 HASM 뼈대 파일을 가져와서 보자.

HelloHASM.hda

; 데이터 세그먼트의 시작을 나타냅니다.

; nasm과 다르게 segment 지시어를 기록하지 않습니다.

.data

 

; 코드 세그먼트의 시작을 나타냅니다.

.code

_main:

push ebp

mov ebp, esp

 

; put your code here

 

mov esp, ebp

pop ebp

ret

일단 HASM이 어셈블리를 모태로 만들었다니 어셈블리처럼 분석한다고 치자. 이때 고려해야 하는 내용을 나열해보자.

- 세미콜론(;) 기호는 주석을 의미한다.

> 세미콜론 기호는 HASM에서도 마찬가지로 주석을 의미하는 것으로 하자. 그런데 이 코드를 보면 주석은 모두 앞에 있고 코드 뒤에 주석이 붙지는 않았다. 잠깐 생각해보니 일단 주석이야 어느 위치에 써도 보기 좀 불편할 뿐이고 크게 상관이 없을 것 같다. 그러면 규칙을 단순하게 하기 위해, 첫 글자가 세미콜론으로 시작하는 줄은 주석으로 간주하자. (정말 규칙을 우리 맘대로 정하고 있는 것이다)

- ‘.data’, ‘.code’는 세그먼트의 시작을 나타낸다.

> 이것도 공통점이 있다. (.)으로 시작한다는 거다. 그러니 점으로 시작하는 줄은 세그먼트로 간주하자.

- ‘_main:’는 레이블을 나타낸다.

> 이 경우는 맨 뒤에 콜론(:)이 있어서 레이블임을 알 수 있다. 원래 NASM에서는 레이블 뒤에 코드가 올 수 있지만, 안 그래도 딱히 상관없지 않은가? 줄을 나누면 되니 말이다. 그러니 여기서도 생각을 간단하게 하자. 줄의 마지막이 콜론으로 끝나면 레이블로 간주하자.

- push ebp와 같은 명령들이 있다.

> 명령 분석 규칙은 여기서 단정 짓기 힘들어 보인다. 일단 위에서 결정한 세 가지를 먼저 구현해보고, 그 다음에 생각하자.

일단 이 정도로 전략을 세웠다. 바로 작성해보자.

 

2.3) 코드 텍스트 얻기

2.3.1) 준비 코드

먼저 Runner 싱글톤 객체를 위한 준비 코드를 작성하는 것으로 시작하자.

Runner.js

/**

어셈블리 실행기 모듈 Runner를 초기화합니다.

*/

function initRunner() {

var runner = {}; // 싱글톤 객체 생성

// 속성 정의 이전에 정의해야 하는 내용 작성

/**

Runner 모듈의 메서드를 수행할 때 발생하는 예외를 정의합니다.

@param {string} msg

*/

function RunnerException(msg, data) {

this.description = msg;

this.data = data;

}

RunnerException.prototype = new Exception();

RunnerException.prototype.toString = function() {

return 'Runner' + Exception.prototype.toString.call(this);

};

// 필드 및 메서드 정의

/**

지정한 파일을 실행합니다.

@param {string} filename

*/

function run(filename) {

// 파일로부터 텍스트를 획득합니다.

var code = HandyFileSystem.load(filename);

if (code == null) // 텍스트를 획득하지 못한 경우 예외를 발생합니다.

throw new RunnerException("Cannot load file", filename);

// 코드를 더 작성하기 전에 일단 동작하는지 확인합니다.

log(code);

}

// 속성 등록

runner.run = run;

// 전역에 등록

window.RunnerException = RunnerException;

window.Runner = runner;

}

main.html <html.head>

<!-- Handy 프로젝트의 시작점입니다. -->

<script src="handy.js"></script>

 

<!-- 공용 모듈 목록입니다. -->

<script src="common.js"></script>

<script src="StringBuffer.js"></script>

 

<!-- 컴파일러를 위한 모듈 목록입니다. -->

<script src="Runner.js"></script>

 

<script>

function main() {

try {

// 초기화한 Runner 모듈을 이용하여 코드를 실행합니다.

Runner.run('HelloHASM.hda');

} catch (ex) {

if (ex instanceof RunnerException) {

// RunnerException에 대한 처리를 작성합니다.

log('Runner module throws an error');

log(ex);

}

else {

// RunnerException이 아니라면 여기서 처리하지 않습니다.

throw ex;

}

}

}

function init() {

var logStream = document.getElementById('HandyLogStream');

logStream.style.width = '100%';

logStream.style.height = '100%';

initHandyFileSystem();

 

initRunner(); // Runner 모듈 초기화 함수가 추가되었습니다!

}

</script>

실행 결과 (HelloHASM.hda 파일이 경로에 없는 경우)

Runner module throws an error

RunnerException: Cannot load file [HandyHASM.hda]

실행 결과 (HelloHASM.hda 파일이 경로에 있는 경우)

; 데이터 세그먼트의 시작을 나타냅니다.

; nasm과 다르게 segment 지시어를 기록하지 않습니다.

.data

 

; 코드 세그먼트의 시작을 나타냅니다.

.code

_main:

push ebp

mov ebp, esp

 

; put your code here

 

mov esp, ebp

pop ebp

ret

이렇게 일단 파일 읽기가 잘 되는 것을 확인할 수 있다. 이제 코드를 분석할 차례인데, 명령을 분석하기 전에 파악한 세 가지 특성부터 해결해보자. 어렵지 않다.

 

2.3.2) 주석, 빈 줄, 세그먼트, 레이블 처리

다음은 각각의 라인을 획득하고 구문을 분석하는 예제다.

Runner.js (run)

function run(filename) {

// 파일로부터 텍스트를 획득합니다.

var code = HandyFileSystem.load(filename);

if (code == null) // 텍스트를 획득하지 못한 경우 예외를 발생합니다.

throw new RunnerException("Cannot load file", filename);

 

// 획득한 텍스트를 줄 단위로 나눈 배열을 획득합니다.

var lines = String(code).split(NEWLINE);

 

// 얻은 텍스트 줄 배열을 분석합니다.

var segment = null;

for (var i=0, len=lines.length; i<len; ++i) {

// 줄 텍스트를 참조합니다.

var line = lines[i];

 

// 줄의 마지막 문자가 개행 문자라면 제거합니다.

if (line.charAt(line.length - 1) == '\r')

line = line.substr(0, line.length - 1);

 

// 빈 줄이라면 무시합니다.

if (line == '')

continue;

// 첫 문자가 세미콜론(;)이라면 주석이므로 무시합니다.

else if (line.charAt(0) == ';')

continue;

// 첫 문자가 점(.)이라면 세그먼트로 간주합니다.

else if (line.charAt(0) == '.') {

log('segment found: [' + line + ']');

segment = line; // 세그먼트를 보관합니다.

}

// 끝 문자가 콜론(:)이라면 레이블로 간주합니다.

else if (line.charAt(line.length-1) == ':') {

// 세그먼트가 정의되지 않았는데 텍스트가 먼저 발견되었다면 예외 처리합니다.

if (segment == null)

throw new RunnerException("segment is null", {

lineNumber: i,

lineString: line

});

log('label: ' + line);

}

// 나머지는 "세그먼트: 텍스트"의 형식으로 출력합니다.

else {

if (segment == null)

throw new RunnerException("segment is null", {

lineNumber: i,

lineString:line

});

log(segment + ': ' + line);

}

}

log('analyzing complete'); // 분석에 성공한 경우 출력되는 문장입니다.

}

실행 결과

segment found: [.data]

segment found: [.code]

label: _main:

.code: push ebp

.code: mov ebp, esp

.code: mov esp, ebp

.code: pop ebp

.code: ret

analyzing complete

코드가 길고 복잡해보이지만 실제로 보면 정말 별 거 없다. 주석을 자세히 달아놓았으니 읽으면서 분석할 수 있을 것이라고 생각한다. 실행 결과는 우리가 코드를 아주 잘 분석해냈음을 보여준다. 이 정도면 코드 분석이 별 거 아니라는 생각에 뿌듯해 해도 된다.

 

2.3.3) 명령 처리

그럼 이제 코드 영역의 문장을 분석해보자. 코드를 분석하려면 코드가 무엇인지 알아야 한다. 우리는 5장에서 어셈블리 명령어에 대해 공부한 적이 있다. 다시 한 번 보자.

[label:] [mnemonic [operands]] [; comment]

- [label:]

> 명령어의 주소를 획득하고 싶다면 레이블을 등록하여 가져온다.

- mnemonic

> 기계어의 명령 코드(operation code, opcode)에 일대일 대응하는 연상 기호이다.

- [operands]

> 연상 기호의 인자로 넘어가는 피연산자를 말한다.

- [; comment]

> 주석을 제공하여 가독성을 높일 수 있다.

그럼 이를 바탕으로 명령어를 분석할 때 어떤 것을 고려해야 하는지를 우선 나열해보자.

- 주석은 고려할 필요 없다.

> 우리는 이미 가정에서, 첫 줄에 세미콜론이 나타나는 경우만 주석으로 간주하기로 했다.

- 레이블 또한 고려할 필요 없다.

> 이유는 주석의 경우와 같다.

- 그러면 문장에 남는 건 니모닉(mnemonic)과 피연산자(operands).

> 따라서 첫 단어는 반드시 니모닉이다.

> 첫 단어로 끝난다면 피연산자가 없다.

> 두 단어라면 피연산자가 하나다.

> 세 단어라면 피연산자가 두 개이고 가운데에 반점이 들어간다.

이를 바탕으로 코드를 분석해보자. 코드 분석 또한 전략에 따라 단계별로 나눈다. 먼저 첫 단어는 반드시 니모닉이므로, 일단 니모닉으로만 구성된 문장부터 획득해보자.

Runner.js (run.else)

// StringBuffer 객체를 생성합니다.

var buffer = new StringBuffer(line);

 

// 맨 처음 획득하는 토큰은 무조건 니모닉입니다.

// (거기다 무조건 식별자여야 합니다.)

var mne = buffer.get_identifier();

 

// 식별자를 획득하지 못했다면 예외를 발생합니다.

if (mne == null)

// 예외에 대한 정보를 담는 객체를 생성하고 같이 전달합니다.

throw new RunnerException('first word is not mnemonic', {

lineNumber: i, // 오류가 발생한 줄 번호

lineString: line, // 오류가 발생한 줄 텍스트

});

 

// 다음 토큰을 획득합니다.

var left = buffer.get_token();

 

// 다음 토큰이 없다면 단어가 한 개 존재하는 것이므로 획득이 끝납니다.

if (left == null) {

log('mnemonic: [' + mne + ']');

continue;

}

 

// 다음 토큰이 있는 경우의 처리입니다.

...

실행 결과

segment found: [.data]

segment found: [.code]

label: _main:

mnemonic: [ret]

analyzing complete

코드에는 니모닉만 존재하는 명령어는 ret이 유일하다. 실행 결과를 통해 우리가 ret을 잘 획득한 것을 볼 수 있다. 이 코드에서는 ret이 잘 획득된다는 것과, 예외 발생 시에 예외에 대한 정보를 객체를 생성하고 전달한다는 사실만 눈여겨보면 된다.

다음은 피연산자가 한 개인 구문을 분석하는 일인데, 이 또한 어렵지 않다.

Runner.js (run.else)

// 획득한 다음 토큰이 식별자인 경우의 처리입니다.

var operand_type = null;

if (is_fnamch(String(left).charAt(0)) == true) {

operand_type = 'identifier';

}

// 획득한 토큰이 숫자인 경우의 처리입니다.

else if (is_digit(String(left).charAt(0)) == true) {

operand_type = 'number';

}

// 이외의 경우 일단 예외 처리합니다.

else {

throw new RunnerException('invalid left operand', {

lineNumber: i,

lineString: line,

operand: left

});

}

 

// 다음 토큰을 획득합니다. 문법적으로 다음 토큰은 반점(,)이어야 합니다.

var right = buffer.get_token();

 

// 다음 토큰이 없다면 분석이 끝납니다.

if (right == null) {

log('mnemonic ' + operand_type + ': [' + mne + ' ' + left + ']');

continue;

}

 

// 다음 토큰이 있는 경우의 처리입니다.

...

실행 결과

segment found: [.data]

segment found: [.code]

label: _main:

mnemonic identifier: [push ebp]

mnemonic identifier: [mov ebp]

mnemonic identifier: [mov esp]

mnemonic identifier: [pop ebp]

mnemonic: [ret]

analyzing complete

그런데 실행 결과를 보니 우리가 원하는 결과가 나오지 않는다. 피연산자 한 개만 제대로 획득한다면 실행 결과는 다음과 같아야 하는데 말이다. 어떻게 된 일일까?

예상 결과

segment found: [.data]

segment found: [.code]

label: _main:

mnemonic identifier: [push ebp]

mnemonic identifier: [pop ebp]

mnemonic: [ret]

analyzing complete

이런 문제는 아주 골치 아픈데, 어디서 오류가 발생하는지를 찾아내는 것이 매우 어렵기 때문이다. 정말 끔찍하지만 어찌되었든 오류는 발생했고 우리는 이것을 해결해야 한다. 그럼 어떻게 오류를 찾아야 할까?

필자는 이를 원시 디버깅으로 해결했다. 원시 디버깅이란 별 거 없고, 오류가 있을 법한 지점에 일일이 출력문을 삽입해서 결과를 확인하는 일을 말한다(필자가 군대에서 한 일이다). 여기에 한 번 적용해보자. 실행 결과를 보니 피연산자가 2개인 명령어도 포함이 되었는데, 그렇다면 이는 피연산자가 두 개인 상황과 피연산자가 한 개인 상황을 서로 구분하지 못해서 나온 결과일 것이라고 예상해볼 수 있다. 그러니 아마도 left 피연산자의 다음 토큰을 획득하는 구문에서 오류가 발생했을 가능성이 높다. 우리는 다음 구문에서 토큰이 없으면 null, 토큰이 있으면 획득하기를 원하고 있다.

var right = buffer.get_token();

그럼 실제로 그런지 확인해보자. 이 문장 밑에 다음 코드를 추가한다.

log(right);

그리고 그 실행 결과는 다음과 같다.

실행 결과

segment found: [.data]

segment found: [.code]

label: _main:

null

mnemonic identifier: [push ebp]

null

mnemonic identifier: [mov ebp]

null

mnemonic identifier: [mov esp]

null

mnemonic identifier: [pop ebp]

mnemonic: [ret]

analyzing complete

우리의 예상대로라면 결과는 대충 다음과 같아야 한다.

예상 결과

segment found: [.data]

segment found: [.code]

label: _main:

null

mnemonic identifier: [push ebp]

null

mnemonic identifier: [mov ebp]

,

mnemonic identifier: [mov esp]

,

mnemonic identifier: [pop ebp]

mnemonic: [ret]

analyzing complete

즉 피연산자가 두 개인 지점의 다음 토큰은 반드시 반점이어야 한다. 그런데도 불구하고 우리 프로그램은 어째선지 null을 출력하고 있다! 그렇다면 우리는 오류가 buffer.get_token 메서드에 있을 것이라고 짐작해볼 수 있다. StringBuffer.js에서 해당 부분을 보자.

StringBuffer.js (get_token)

StringBuffer.prototype.get_token = function() {

try {

this.trim();

if (this.is_empty())

throw new StringBufferException("Buffer is empty", this);

var ch = this.str[this.idx];

var ss = ''; // 문자열 스트림 생성

if (is_digit(ch)) { // 정수를 발견했다면 정수 획득

ss += this.get_number(); // cout 출력 스트림처럼 사용하면 된다

}

else if (is_fnamch(ch)) { // 식별자 문자를 발견했다면 식별자 획득

ss += this.get_identifier();

}

else { // 이외의 경우 일단 획득

ss += this.get_operator();

}

return ss; // 획득한 문자열을 반환한다

} catch (ex) {

// 토큰 획득에 실패한 경우 null을 반환합니다.

return null;

}

}

반점은 식별자가 아니고, 숫자도 아니다. 따라서 이 경우 else 블록으로 이동하고 this.get_operator 함수를 호출하게 될 것이다. 그렇지? 그럼 결국 우리의 get_operator가 문제가 있다는 거다. 해당 메서드의 정의로 이동하자.

StringBuffer.js (get_operator)

StringBuffer.prototype.get_operator = function() {

this.trim();

if (this.is_empty())

throw new StringBufferException("Buffer is empty", this);

var ch = this.str[this.idx++]; // 현재 문자를 획득하고 포인터를 이동한다

var op = '';

switch (ch) {

case '+': op = ch; break;

case '-': op = ch; break;

case '*': op = ch; break;

case '/': op = ch; break;

default: throw new StringBufferException("invalid operator", this);

}

return op;

}

그리고 이 코드를 자세히 보면 우리의 문제가 어디서 발생했는지 파악할 수 있다. 바로 get_operator 메서드 내에 반점에 대한 처리가 없다는 것이다. switch-case 구문에서는 사칙연산 기호 네 개를 제외한 문자는 모두 예외 처리하도록 구현되어있다! 따라서 반점을 발견하면 get_operaotr는 이를 토큰으로 인지하지 못하고 예외를 발생한 것이다.

따라서 우리는 이 문제를 개선해야 한다. 일단 throw 구문을 없애고 다음과 같이 수정하자.

default: op = ch;

이를 저장하고 프로그램을 실행하면 우리가 원하는 결과를 얻을 수 있다.

실행 결과

segment found: [.data]

segment found: [.code]

label: _main:

null

mnemonic identifier: [push ebp]

,

,

null

mnemonic identifier: [pop ebp]

mnemonic: [ret]

analyzing complete

처음에 예상한 결과와 약간 다르지만, 이는 프로그램이 어떻게 실행되는지를 추적하면 금방 옳다는 결론을 얻을 수 있다.

이제 마지막 단계인 피연산자가 두 개인 경우만 남았다. 지금까지의 내용을 모두 이해했다면 이는 스스로의 힘으로도 작성할 수 있다.

Runner.js (run.else)

// 다음 토큰이 반점이 아니라면 예외 처리합니다.

if (right != ',')

throw new RunnerException(

'syntax error; right operand must be after comma(,)', {

lineNumber: i,

lineString: line,

operand: right

});

 

// 먼저 획득한 토큰이 반점이므로 토큰을 한 번 더 획득하여

// 오른쪽 피연산자를 획득합니다.

right = buffer.get_token();

 

// 반점이 있는데 피연산자 획득에 실패한 경우 예외 처리합니다.

if (right == null)

throw new RunnerException(

'syntax error; cannot find right operand', {

lineNumber: i,

lineString: line,

});

 

// mnemonic operand, operand 코드를 획득했으므로 처리합니다.

log('mnemonic operand, operand: ['

+ mne + '] [' + left + '], [' + right + ']');

실행 결과

segment found: [.data]

segment found: [.code]

label: _main:

mnemonic identifier: [push ebp]

mnemonic operand, operand: [mov] [ebp], [esp]

mnemonic operand, operand: [mov] [esp], [ebp]

mnemonic identifier: [pop ebp]

mnemonic: [ret]

analyzing complete

실행 결과는 너무나도 깔끔하다.

 

2.4) 니모닉 구현 시도

코드를 각각의 부분으로 뜯어내는 것은 완벽하게 성공했다. 그럼 이제 작성한 코드를 의미 있게 만들어야 할 것이다. 코드를 의미 있게 만드는 것은 니모닉이고, 우리는 명령어를 니모닉과 피연산자로 나누는 방법까지 알았다. 나머지는 획득한 니모닉에 맞는 처리를 하면 되는 것 밖에 없다.

HelloHASM.hda 소스 코드에는 총 네 개의 니모닉 push, mov, pop, ret이 있다. 그러니 이들 각각에 대해 처리를 해보자. 획득한 니모닉은 문자열이니 다음과 같은 if-else를 구성하면 될 것이라고 예상해볼 수 있다.

if (mne == 'push') { /* push routine */ }

else if (mne == 'mov') { /* mov routine */ }

else if (mne == 'pop') { /* pop routine */ }

else if (mne == 'ret') { /* ret routine */ }

else { throw new RunnerException('invalid mnemonic'); }

그런데 이를 실제 코드에 적용하면 생각보다 못생긴 코드 덩어리가 나온다. 코드가 너무 못생겨서 여기에 온전히 싣기 싫으므로 어떻게 되는지를 부분부분 잘라 보여주겠다. 전체 코드는 첨부 파일을 참조하라.

Runner.js (analysis_try)

...

if (left == null) {

// 니모닉만으로 구성된 명령에 대한 조건 분기 (피연산자 0)

if (mne == 'ret') {

log('ret routine');

}

else {

throw new RunnerException('invalid mnemonic', {

lineNumber: i, lineString: line

});

}

continue;

}

 

...

if (right == null) {

// 니모닉, 피연산자 구성의 명령에 대한 조건 분기 (피연산자 1)

if (mne == 'push') {

log('push routine');

}

else if (mne == 'pop') {

log('pop routine');

}

else {

throw new RunnerException('invalid mnemonic', {

lineNumber: i, lineString: line

});

}

continue;

}

 

...

// 니모닉, 피연산자 구성의 명령에 대한 조건 분기 (피연산자 2)

if (mne == 'mov') {

log('mov routine');

}

else {

throw new RunnerException('invalid mnemonic', {

lineNumber: i, lineString: line

});

}

실행 결과

segment found: [.data]

segment found: [.code]

label: _main:

push routine

mov routine

mov routine

pop routine

ret routine

analyzing complete

! 결과는 잘 나오지만 정말 너무 못생겼다! 같은 위치에 있어야 할 코드가 떨어져있고 같은 예외를 세 군데에 나누어 처리한 데다, run 메서드의 길이마저 끔찍하게 길어졌다! 이 총체적 난국을 어떻게 해결해야 하는가?

문제는 이것만이 아니다. 이것을 해결하기 전에 push, mov, pop, ret 각각의 모듈을 어떻게 구현하는지에 대해 감이 오는가? 아니, 여기서 가장 기본 명령인 mov를 구현하는 방법은 떠오르는가?

이는 사실 책을 보던지 고민을 해야 하는 부분이다. 이쯤에서 절을 나누어, 어떤 것을 배워야 더 진행할 수 있는지를 알아보고자 한다.

 

3. 고려되지 않은 것

이 절의 내용은 4장의 어셈블리 튜토리얼 1”과 같이 공부하면 효과가 좋다.

 

3.1) 레지스터

첫 줄의 push 명령을 보면 ebp 레지스터를 인자로 넘긴다. 여기서 우리가 레지스터를 아직 코드로 정의하지 않았다는 사실을 알 수 있다. 이전 문서에서 레지스터를, 단순히 CPU가 사용하는 변수라고 설명한 바 있다. 여기서도 같은 접근 방법을 쓴다. ebp, eax와 같은 레지스터를 모두 변수로 정의하고, 이 레지스터를 사용하는 구문은 이렇게 정의된 변수를 사용할 것이다.

예를 들어보자. 우리가 다음 구문을 분석했다고 치자.

mov edx, 4 ; edx = 4

mov eax, 3 ; eax = 3

add eax, edx ; eax += edx

edx4, eax3을 대입한 후 eaxedx를 더한다. 그런데 마지막 문장이 정상적으로 실행되려면 적어도 eax의 값인 3edx의 값인 4는 당연히 저장하고 있어야 한다. 바로 이것이 목표다. 우리는 각각의 니모닉을 만났을 때 다음과 연산되도록 코드를 작성할 것이다.

if (mne == 'mov') { // 니모닉 mov에 대해

if (left == 'eax') { // lefteax 레지스터라면

if (right == 'edx') { // rightedx 레지스터라면

// 레지스터 left에 레지스터 right의 값을 대입합니다.

Register.eax = Register.edx;

 

}

...

}

...

}

else if (mne == 'add') { // 니모닉 add에 대해

if (left == 'eax') { // lefteax 레지스터라면

if (right == 'edx') { // rightedx 레지스터라면

// 레지스터 left에 레지스터 right의 값을 더합니다.

Register.eax += Register.edx;

 

}

...

}

...

}

좀 더 일반적인 형식으로 작성하면 다음과 같이 된다.

if (mne == 'mov') { // 니모닉 mov에 대해

if (is_register(left)) { // left가 레지스터라면

if (is_register(right)) { // right 또한 레지스터라면

 

// 레지스터 left에 레지스터 right의 값을 대입합니다.

Register[left] = Register[right];

 

}

...

}

...

}

else if (mne == 'add') { // 니모닉 add에 대해

if (is_register(left)) { // left가 레지스터라면

if (is_register(right)) { // right 또한 레지스터라면

 

// 레지스터 left에 레지스터 right의 값을 더합니다.

Register[left] += Register[right];

 

}

...

}

...

}

이에 대한 논의는 4절에서 하는 것으로 하자.

 

3.2) 메모리

push와 같은 명령은 값을 메모리에 푸시 하는 명령이다. 즉 값을 푸시 할 메모리가 있어야 한다. 더 정확히 얘기하면, Memory 모듈은 가상 메모리를 표현하는 모듈이기 때문에 메모리에 접근하여 값을 가져오거나 특정 메모리에 값을 기록하는 메서드가 반드시 필요하다. 그런데 다음에 보일 메서드의 정의를 이해하려면 우리가 메모리를 어떻게 접근할지를 먼저 이해해야 한다. 그림을 통해 이를 알아보자.

 

3.2.1) 기본

메모리는 바이트 배열이다. 이를 그림으로 표현하면 다음과 같다.



우리가 생각하는 바이트가 그대로 구현된 것이다. 정말 단순하다! 그럼 이제 메모리를 조작해보자. 메모리 조작이라고 해서 별 게 아니다. 그냥 임의의 번지에 값을 기록하면 그것이 메모리를 조작한 것이다. 예를 들어 8번지의 메모리부터 4바이트만큼을 0으로 채운다면 이렇게 될 것이다.



여기서 5번지의 메모리부터 2바이트를 0으로 채운다면 이렇게 될 것이다.



한 번만 더 해보자. 우리는 기본적으로 16진법을 이용하여 수를 표시한다. 이진수 111116진수로 F와 같으므로, 바이트 하나가 모두 1로 차 있다면 16진수로는 FF가 된다(1바이트 = 8비트, 11111111 = FF). 따라서 6번지부터 3바이트의 모든 비트를 1로 초기화하면 다음 결과를 얻는다.



이제 메모리에 대한 접근 방법을 알 수 있을 것이므로, 두 가지 개념에 대해서 추가로 설명한 후에 이에 대한 구현을 보이기로 하겠다.

 

3.2.2) 메모리 포인터

여기서는 메모리 모듈이, 내부적으로 자신의 위치를 기록하고 있다고 가정한다. bytePtr라는 필드가 메모리 내에 있어서 다음의 형태로 그림이 그려진다고 생각하면 된다.



만일 현재 메모리에서부터 1바이트를 0으로 기록한다고 하면, bytePtr가 가리키는 메모리의 값이 0으로 바뀌고 bytePtr1만큼 이동한다.



여기서 4바이트를 모두 FF로 초기화하면 bytePtr가 가리키는 메모리의 값이 모두 FF가 되고 bytePtr가 이동한다.



개념은 단순하다. 다만 0번지를 사용 불가능한 번지로 만들기 위해 bytePtr의 초기 값은 0은 아니라고 가정하자.


이 정도로 메모리 포인터는 이해할 수 있다.

 

3.2.3) 엔디안

다음과 같이 빈 메모리가 있다고 하자.



이 메모리에 16진수 정수 0x1234를 기록하고 싶다. 어떻게 해야 할까?

먼저 메모리를 어느 위치에 기록해야 할지를 결정해야 한다. 여기서는 4번지에 기록한다고 하자. 또한, 정수를 몇 바이트에 기록할 것인지 결정해야 한다. 0x12341바이트가 저장할 수 있는 정수의 최댓값인 255를 넘어서므로, 1바이트만으로 기록할 수 없다. 0x12341바이트에 담기에는 크지만 2바이트 이상의 모든 메모리에 충분히 담을 수 있다. 이 경우 2바이트만큼의 메모리에 값을 쓴다면 그 결과는 다음과 같을 것이다.



그런데 컴퓨터 과학자들은 메모리에 값을 쓰는 방법을 두 가지로 나눠서 생각했다. 예를 들어보자. 주어진 정수 0x1234는 이렇게 기록하는 것이 상식적이다.



그런데 컴퓨터 과학자들은 수를 이렇게 거꾸로 기록하는 방식도 생각해냈다.



그리고 이 두 방식 중 전자를 빅 엔디안(Big endian), 후자를 리틀 엔디안(Little endian)이라고 한다. 엔디안(endian)은 컴퓨터에 값을 기록하는 순서를 말한다. 빅 엔디안은 큰 자릿수부터, 리틀 엔디안은 작은 자릿수부터 메모리에 채우기 때문에 이러한 이름이 붙었다. 실제로 인텔 계열의 CPU들은 수를 기록할 때 리틀 엔디안 방식을 사용한다. 이는 이렇게 수를 저장할 때 연산이 쉬워진다는 장점이 있기 때문인데 이에 대해서는 후에 다루겠다. 그렇다고 세상 모든 컴퓨터가 리틀 엔디안으로 수를 기록하는 것도 아니다. 빅 엔디안도 물론 장점이 있으며 소켓 통신의 경우는 모든 데이터를 빅 엔디안 방식으로 주고받도록 표준으로 정해져있다. 이에 대해서는 엔디안을 키워드로 검색해보기 바란다.

 

4. Runner: 빠진 것을 고려하고

4.1) fetch, decode, execute

무엇이 고려되지 않았는지는 3절에서 다루었다. 하지만 각각의 구현에 고려해야 하는 것을 알았다고 쳐도 아까 본 코드는 너무 난잡하다. 우선 이걸 정리하자.

run의 몸체가 아주 길어졌으므로, 이를 함수로 나누어 관리하면 편할 것이라는 생각이 든다. 바로 나눠보자. 각 명령어의 실행은 다음 세 단계를 거친다.

1. fetch: 텍스트 덩어리로부터 우리가 분석할 문장을 가져온다.

2. decode: 가져온 문장을 분석하여 실행 가능한 형태의 정보로 변환한다.

3. execute: 분석이 완료된 정보를 바탕으로 명령을 실행한다.

즉 우리의 run 메서드는 반복문이 있고, 반복문 안에 fetch, decode, execute가 순서대로 호출되는 구조를 가지게 된다. 이를 반영하여 코드를 작성하면 run 메서드는 다음과 같이 정리가 된다.

Runner.js (run_fix.run)

function run(filename) {

// 파일로부터 텍스트를 획득합니다.

var code = HandyFileSystem.load(filename);

if (code == null) // 텍스트를 획득하지 못한 경우 예외를 발생합니다.

throw new RunnerException("Cannot load file", filename);

 

// 획득한 텍스트를 줄 단위로 나눈 배열을 획득합니다.

var segment = null;

var lines = String(code).split(NEWLINE);

for (var i=0, len=lines.length; i<len; ++i) {

try {

// 분석할 i번째 줄을 가져옵니다.

var line = fetch(lines, i);

 

// Runner에 보내는 지시어(directive)를 처리합니다.

if (line == '') continue; // 빈 줄이라면 무시합니다.

else if (line.charAt(0) == ';') continue; // 주석을 무시합니다.

else if (line.charAt(0) == '.') { // 세그먼트를 처리합니다.

segment = line; // 세그먼트를 보관합니다.

log('segment found: [' + line + ']');

continue;

}

 

// 지시어가 아닌 일반 명령으로 확인되면 분석합니다.

if (line.charAt(line.length-1) == ':') { // 레이블을 처리합니다.

// 세그먼트가 정의되지 않았는데 텍스트가 먼저 발견되었다면 예외 처리합니다.

if (segment == null) // 같이 넘기던 인자가 사라졌습니다!

throw new RunnerException("segment is null");

log('label: ' + line);

continue; // 레이블은 처리가 끝나면 지나갑니다.

}

 

// fetch를 통해 가져온 정보를 분석합니다.

var info = decode(line);

 

// decode를 통해 분석된 정보를 바탕으로 명령을 실행합니다.

execute(info);

}

catch (ex) {

if (ex instanceof RunnerException) {

log('RunnerException occurred');

log(i + ': [' + lines[i] + '] ' + ex.description);

}

else {

throw ex;

}

}

}

log('analyzing complete'); // 분석에 성공한 경우 출력되는 문장입니다.

}

이전의 run 메서드보다 아주 예쁜 코드가 되었다. 어느 부분이 코드를 획득하고, 어느 부분이 코드를 분석하는지, 어느 부분이 코드를 실행하며 또 어느 부분이 예외 처리인지에 대한 내용이 한 눈에 보인다.

추가로 여기에는 개선된 사항도 있는데, 바로 RunnerException의 인자로 줄 번호와 줄 텍스트를 넘기지 않아도 된다는 사실이다. 줄 번호와 텍스트는 사실 줄을 분석할 때 반복문에서 가지고 있던 정보이므로, 필요하다면 여기서 참조하면 되기 때문에 반복문 내의 catch 영역에서 이를 사용하는 식으로 개선했다.

그럼 이제 추가한 fetch, decode, execute를 볼 차례다. 먼저 fetch를 보자.

Runner.js (fetch)

/**

텍스트를 가져옵니다.

@param {Array} lines

@param {Number} lineNumber

@return {string}

*/

function fetch(lines, lineNumber) {

var line = lines[lineNumber]; // 줄 번호에 해당하는 줄을 획득합니다.

return line.trim(); // 양 끝에 공백을 없애 정리한 줄을 반환합니다.

}

fetchtrim이라는 메서드가 있다는 사실만 알면 볼 것이 없다. decode를 보자.

Runner.js (decode)

/**

코드를 분석합니다.

@param {string} line

@return {InstructionInfo}

*/

function decode(line) {

// StringBuffer 객체를 생성하고 line으로 초기화합니다.

var buffer = new StringBuffer(line);

 

// 가장 처음에 획득하는 단어는 반드시 니모닉입니다.

var mne = buffer.get_token();

// 니모닉 획득에 실패한 경우 예외 처리합니다.

if (is_fnamch(mne) == false)

throw new RunnerException('invalid mnemonic');

 

// 다음 토큰 획득을 시도합니다.

var left = buffer.get_token();

var right = null;

if (left != null) { // 다음 토큰이 있는 경우의 처리

 

// 피연산자가 두 개인지 확인하기 위해 토큰 획득을 시도합니다.

right = buffer.get_token();

if (right != null) { // 다음 토큰이 있는 경우

if (right != ',') // 반점이 아니라면 HASM 문법 위반입니다.

throw new RunnerException

('syntax error; right operand must be after comma(,)');

 

// 오른쪽 피연산자 획득

right = buffer.get_token();

if (right == null) // 획득 실패 시 문법을 위반한 것으로 간주합니다.

throw new RunnerException

('syntax error; cannot find right operand');

}

 

// 다음 토큰이 없다면 rightnull이고

// 그렇지 않으면 다음 토큰 문자열이 된다

}

 

// 획득한 코드 정보를 담는 객체를 생성하고 반환합니다.

var info = { mnemonic: mne, left: left, right: right };

return info;

}

decode의 경우는 내용이 길지만, 아까 다루었던 내용이라 주석만으로 이해할 수 있을 것이다. 여기서 중요한 건 마지막 부분에 있는, 코드 정보 객체를 생성하는 부분이다.

이 코드에서는 니모닉을 먼저 획득한 다음 left에 다음 토큰을 획득한다. 그런데 다음 토큰이 있는 경우의 처리는 되었지만 없는 경우에 대해 따로 처리가 없다. 왜 그런가? 그 이유는 필자가 코드 정보를 객체에 담을 때, 피연산자가 없다면 기본적으로 null을 대입하기로 결정했기 때문이다. 예를 들어보자. push 니모닉은 하나의 피연산자를 가지므로 leftpush의 인자고 rightnull이 된다. mov 니모닉은 두 개의 피연산자를 가지므로 leftright 모두 null이 아니다. 마지막의 ret 니모닉은 피연산자가 하나도 없기 때문에 leftright가 모두 null이 된다. 이는 get_token 메서드가, 토큰을 획득할 수 없는 경우 기본적으로 null을 반환하도록 정의된 것에서 아이디어를 찾을 수 있다.

마지막으로, 분석된 명령을 실행하는 execute 메서드다.

Runner.js (execute)

/**

분석된 코드를 실행합니다.

@param {InstructionInfo}

*/

function execute(info) {

var mne = info.mnemonic; var left = info.left; var right = info.right;

log('instruction: [' + mne + '][' + left + '][' + right + ']');

}

아직 각각의 명령을 구현하지 않았으므로, 일단 코드 분석이 정상적으로 진행되었는지를 확인하는 코드를 삽입했다. 그리고 이렇게 작성한 네 함수를 모두 종합하여 실행한 결과는 다음과 같다.

실행 결과

segment found: [.data]

segment found: [.code]

label: _main:

instruction: [push][ebp][null]

instruction: [mov][ebp][esp]

instruction: [mov][esp][ebp]

instruction: [pop][ebp][null]

instruction: [ret][null][null]

analyzing complete

결과가 아주 잘 출력되었다. 시간이 된다면, 이렇게 분석한 코드를 실제 어셈블리 코드처럼 다시 출력하도록 코드를 변경해보기 바란다. 꽤 재미있을 것이다.

 

4.2) 메모리 반영

3절에서는 고려되지 않은 것에서 레지스터를 먼저 다룬 다음 메모리를 이야기했지만, 여기서는 아무래도 메모리를 먼저 구현하는 것이 이해적 측면에서 도움이 될 것 같다. Runner에서 했던 것처럼 모듈을 작성하기 위한 코드를 먼저 준비하자.

Memory.js

/**

메모리 관리 모듈 Memory를 초기화합니다.

*/

function initMemory() {

var memory = {}; // 싱글톤 객체 생성

 

// 상수 정의

var MAX_MEMORY_SIZ = 100;

 

// 속성 정의 이전에 정의해야 하는 내용 작성

/**

Memory 모듈의 메서드를 수행할 때 발생하는 예외를 정의합니다.

@param {string} msg

*/

function MemoryException(msg, data) {

this.description = msg;

this.data = data;

}

MemoryException.prototype = new Exception();

MemoryException.prototype.toString = function() {

return 'Memory' + Exception.prototype.toString.call(this);

};

 

// 필드 정의

memory.byteArr = new Array(MAX_MEMORY_SIZ); // 메모리 바이트 배열 정의

memory.bytePtr = 0; // 메모리를 가리키는 바이트 포인터 정의

/* 구현 */

// 전역에 싱글톤 등록

window.MemoryException = MemoryException;

window.Memory = memory;

}

이전과 똑같다. 빈 싱글톤 객체를 생성하고 예외 형식을 정의한다. 다만 구현 이전에 필드의 정의가 있고, 그 멤버로 3절에서 설명한 bytePtr가 있음에만 주목하면 된다.

이어서 메서드를 작성하겠다. 특정 메모리에 값을 기록하는 set_byte 메서드를 보자.

Memory.js (set_byte)

/**

value를 바이트로 변환한 값을 index 주소에 기록합니다.

@param {number} value

@param {number} index

*/

function set_byte(value, index) {

this.byteArr[index] = (value & 0xFF);

}

바이트 배열 byteArr의 주소 indexvalue를 바이트로 변환한 값을 기록하고 있다. & 연산자를 이용하여 16진수 FF와 비트 & 연산을 함으로써 value를 바이트로 변환할 수 있다. FF11111111임을 생각하면 이해할 수 있을 것이다.

나머지는 해당 인덱스에서 값을 가져오는 get_byte 메서드를 구현한 것인데 어렵지 않다.

Memory.js (set_byte)

/**

바이트 배열에서 index 주소에 있는 바이트를 획득합니다.

@param {number} index

@return {byte}

*/

function get_byte(index) {

return this.byteArr[index];

}

다음에는 read_byte, write_byte 메서드의 정의인데, bytePtr를 이용한다는 점을 빼면 get, set 메서드와 전혀 다르지 않다.

Memory.js (read_byte, write_byte)

/**

바이트 배열에서 바이트를 획득합니다.

@return {number}

*/

function read_byte() {

return this.byteArr[this.bytePtr++];

}

/**

value를 바이트로 변환한 값을 기록합니다.

@param {number} value

*/

function write_byte(value) {

this.byteArr[this.bytePtr++] = (value & 0xFF);

}

그리고 이를 테스트하는 코드는 다음과 같다.

main.html <html.head>

...

<!-- 컴파일러를 위한 모듈 목록입니다. -->

<script src="Memory.js"></script>

<script src="Runner.js"></script>

<script>

// Memory 테스트 예제입니다.

function main() {

try {

var value;

Memory.bytePtr = 4; // bytePtr를 초기화합니다.

 

// 메모리에 값을 기록합니다.

Memory.write_byte(10); Memory.write_byte(20);

 

Memory.bytePtr = 4; // bytePtr를 다시 초기화합니다.

 

// 메모리에서 값을 가져옵니다.

value = Memory.read_byte(); log(value);

value = Memory.read_byte(); log(value);

 

// 메모리에서 주소를 이용하여 직접 값을 가져옵니다.

value = Memory.get_byte(4); log(value);

 

// 현재 바이트 포인터가 가리키는 위치에 값을 기록합니다.

Memory.set_byte(30, Memory.bytePtr);

 

// 바이트 포인터가 가리키는 값을 읽습니다.

value = Memory.read_byte(); log(value);

} catch (ex) {

if (ex instanceof MemoryException) {

// MemoryException에 대한 처리를 작성합니다.

log('Memory module throws an error');

log(ex);

}

else {

// MemoryException이 아니라면 여기서 처리하지 않습니다.

throw ex;

}

}

}

function init() {

...

initMemory();

initRunner();

}

</script>

실행 결과

10

20

10

30

사실 이 실행 결과는 단번에 받아들이기 힘들 수 있다. 여기서 한 번만 더 정리를 해주면 앞으로 이런 문제가 닥쳤을 때 이 글을 읽는 본인이 스스로 문제를 해결할 수 있으리라 생각하므로 귀찮음을 이겨내고 마지막으로 한 번만 정리하겠다.

Memory.bytePtr = 4; 구문을 통해 바이트 포인터를 초기화하면 다음 상태가 된다.



write_byte 메서드를 통해 메모리에 값을 두 번 기록한다.





그러면 값 104번지에, 205번지에 저장되게 된다. 맞지?

여기서 다시 한 번 Memory.bytePtr = 4;를 실행해서 바이트 포인터를 옮긴다.



그리고 거기서 read_byte를 통해 바이트 하나를 읽으면 다음과 같이 된다. 여기서 10을 출력한다.



다시 한 번 read_byte를 호출하면 이렇게 된다. 여기서 20을 출력한다.



get_byte는 바이트 포인터를 조작하지 않는다. 따라서 실행한다고 메모리에 변화가 생기지 않는다. 여기서 10을 출력한다.

set_byte를 이용하여 현재 바이트 포인터가 가리키는 위치에 30을 기록한다.



그리고 다시 read_byte를 호출하여 값을 읽는다. 여기서 30을 출력한다.



이로써 이 예제 코드는 끝이 난다.

많은 내용을 했지만 아직 메모리에 내용 하나가 남아있다. 3절에서 엔디안에 대해 다루었는데, 이것만 처리를 끝내면 메모리 모듈의 작성이 완료된다. 이 예제에서는 리틀 엔디안 방식으로 수를 저장할 것인데, 크게 어렵지 않으므로 빠르게 보고 다음으로 넘어가자.

3절에서 말했지만, 정수를 기록하려면 기록 위치와 기록할 값, 사용할 메모리의 크기를 알아야 한다. 바이트의 경우는 기록 위치와 값은 인자로 넘겼고, 사용할 메모리의 크기는 1바이트로 정해져있어서 이것이 해결되었다. 워드(2바이트)와 더블 워드(4바이트)의 경우도 다르지 않지만 코드를 볼 필요는 있다. 다음은 특정 주소에서 워드 값을 읽고 쓰는 read_word, write_word 메서드를 작성한 것이다.

Memory.js (read_word)

/**

바이트 배열에서 워드를 획득합니다.

@return {number}

*/

function read_word() {

var byte1 = this.read_byte(); // 첫 번째 바이트 XY을 획득합니다.

var byte2 = this.read_byte(); // 두 번째 바이트 ZW를 획득합니다.

var word = ((byte2 << 8) | byte1); // 결과 값은 ZWXY입니다.

return word;

}

/**

value를 워드로 변환한 값을 기록합니다.

@param {number} value

*/

function write_word(value) {

this.write_byte(value & 0xFF); // 첫 번째 바이트를 기록합니다.

value >>= 8; // 다음에 기록할 바이트로 value 값을 옮깁니다.

this.write_byte(value & 0xFF); // 두 번째 바이트를 기록합니다.

}

처음에는 스스로 이에 대해 연구하게끔 하려고 했으나, 한 도영은 친절하므로 이를 그냥 두고 볼 수 없다. 따라서 이것도 설명하고자 한다.

write_word(0x8F4B) 메서드가 호출된 경우를 먼저 보자. 이진수 10001111 01001011(16진수 8F4B)이 변수 value에 저장되어있는 상황을 가정하자. 그림으로는 다음과 같다.



여기서 write_byte(value & 0xFF)가 수행되면서 먼저 value & 0xFF를 계산하여 다음을 얻는다.



그리고 write_byte(0x4B)가 수행되면 다음과 같이 된다.



그 다음 value >>= 8; 문장을 수행하여, 다음에 획득할 바이트를 첫 번째 바이트로 맞춘다.



이 과정을 반복한다.





이렇게 0x8F4B를 리틀 엔디언으로 기록하는 과정은 완료가 된다. 그럼 이제 read_word를 통해 이렇게 기록한 정수를 읽어보자. 4번지로 바이트 포인터를 옮기면 이렇게 될 것이다.



현재 위치에서 바이트 하나를 획득하고 byte1에 저장한다.



똑같이 바이트 하나를 획득하고 byte2에 저장한다.



마지막으로 수를 모두 합치면 된다.



크게 어렵지 않으므로, 그림을 보면서 공부하면 이해할 수 있을 것이다. 나머지는 이전 예제를 조합하여 스스로 작성할 수 있으므로, 여기에 재차 코드를 싣지 않겠다. 혹 코드가 궁금하다면 첨부한 소스 파일을 참조하라. 그리고 사실 이 예제에서 MemoryException 클래스는 정의만 하고 사용하지 않았는데, 이렇게 필요할 것처럼 보이는 정의가 실제 구현에서는 쓸모없어지는 일도 은근히 생기니 지금은 신경 쓰지 않아도 좋다. 다음은 Memory 모듈의 메서드를 정리한 것이다.

Memory

// 필드

Array byteArr; // 메모리를 표현하는 바이트 배열을 정의합니다.

Number bytePtr; // 메모리를 가리키는 바이트 포인터를 정의합니다.

// 메서드

byte read_byte(); // 바이트를 획득합니다.

void write_byte(value); // value의 첫 바이트를 기록합니다.

byte get_byte(index); // index 주소에 있는 바이트를 획득합니다.

void set_byte(value, index); // value의 첫 바이트를 index 주소에 기록합니다.

word read_word(); // 워드를 획득합니다.

void write_word(value); // value의 첫 워드를 기록합니다.

word get_word(index); // index 주소에 있는 워드를 획득합니다.

void set_word(value, index); // value의 첫 워드를 index 주소에 기록합니다.

dword read_dword(); // 더블 워드를 획득합니다.

void write_dword(value); // value의 첫 더블 워드를 기록합니다.

dword get_dword(index); // index 주소에 있는 더블 워드를 획득합니다.

void set_dword(value, index); // value의 첫 더블 워드를 index 주소에 기록합니다.

이와 같이 Memory 모듈을 작성할 수 있었다.

 

4.3) 레지스터 반영

이제 레지스터를 생각해보자. 레지스터! 여러 차례 CPU가 사용하는 변수라고 설명했다. 그러니까 우리도 변수처럼 쓰면 된다. 어떻게? 사실 전역에 레지스터 변수를 var로 몽땅 선언하고 필요할 때 이걸 써도 우리는 레지스터를 사용하는 흉내를 낼 수 있다. 하지만 그거보다는 더 멋지게 만들어놓는 것이 후에 관리하기 편할 것 같다.

그럼 레지스터는 어떻게 표현해야 하는가? 이를 생각하기에 앞서, 레지스터의 위치는 어디로 결정하는 것이 옳은가? 메모리와 관계가 있으므로 Memory 모듈에 넣어야 하는가? 명령을 분석하는 실행기인 Runner 모듈에 넣어야 하는가? 아니면 두 모듈과 다른 별개의 Register 모듈을 만드는 것이 좋을까?

사실 이 경우는 지금 당장 어느 하나가 정답이라고 분명히 결정하기 어렵다. 이유는 간단하다. 아직 안 해봤으니까. 그러면 일단 마음이 가는 모듈에 한 번 구현을 해본 다음, 후에 수정하고 싶을 때 다른 위치로 옮기면 그만이다. 여기서는 이전에 그래왔던 것처럼 Register라는 모듈을 새롭게 만들고 이를 통해 레지스터를 관리하도록 하는 것이 좋겠다(이 절을 읽기 전에 먼저 Register.js라는 파일을 만든 사람이 있을까 하여 배려하는 것이다). Register.js 모듈을 만들고 준비해놓자.

Register.js

/**

레지스터 관리 모듈 Register를 초기화합니다.

*/

function initRegister() {

var register = {}; // 싱글톤 객체 생성

/* ...? */

// 전역에 등록

window.Register = register;

}

그래, 일단 Register 모듈은 만들었다. 그런데 이제 뭐 해야 할까?

이에 대해서는 3절에서 밝힌 바 있다. 우리는 레지스터가 CPU가 사용하는 변수라고 생각하고 있고, 그래서 여기서도 레지스터를 변수처럼 쓰고 싶어한다고 계속 말하고 있다. 그리고 Register.eax와 같이 레지스터에 직접 접근하는 코드를 예제로 보여 우리가 무엇을 개발해야 하는지에 대한 힌트도 제공했다.

그럼 남은 건 이들을 조합하는 것뿐이다. Register.eax, Register.ebx와 같은 식으로 레지스터에 접근하려면 이들은 모두 Register 모듈의 필드여야 한다. 코드로 작성하자.

Register.js

function initRegister() {

var register = {}; // 싱글톤 객체 생성

// 필드를 정의합니다.

register.eax = 0;

register.ebx = 0;

register.ecx = 0;

register.edx = 0;

register.ebp = 0;

register.esp = 0;

// 전역에 등록

window.Register = register;

}

? 이게 끝인가? eflagseip 같은 용도 모를 레지스터 같은 것이 나왔던 것 같기는 한데 나중에 추가하면 그만일 것 같다. 코드에도 ebp, esp밖에 안 나왔고, eax 같은 레지스터는 뭐 당연히 필요할 것 같으니까 일단 넣어봤다. 그런데 지금 이 상태로도 별 부족한 느낌이 당장 들지 않는다!

그러면 일단 이것만으로 구현이 가능한지를 알아보는 것이 우선이다. push, mov, pop, ret의 네 니모닉을 구현하는 데 성공하면 이것으로 HelloHASM을 분석하는 데 무리가 없는지 알아보자. HelloHASM의 코드 영역을 다시 보이겠다. 주석은 분석할 필요가 없으니 뺐다.

HelloHASM.hda (code)

...

_main:

push ebp

mov ebp, esp

mov esp, ebp

pop ebp

ret

그럼 우리에게는 레이블을 제외하고 5개의 명령이 남는다. 분석해보자.

명령 획득은 fetch, 명령 분석은 decode가 한다. 따라서 우리는 execute 메서드만 수정하면 된다. push에 대한 처리를 추가해보자.

Runner.js (execute)

function execute(info) {

var mne = info.mnemonic, left = info.left, right = info.right;

log('instruction: [' + mne + '][' + left + '][' + right + ']');

 

if (mne == 'push') { // push 니모닉에 대한 처리

if (left == null) // 피연산자가 없으면 예외 처리합니다.

throw new RunnerException('invalid operand');

 

/* ...? */

}

}

코드를 보면 알겠지만, push는 완전히 구현되지 않았다. 이를 직접 구현해가면서 다른 것들을 어떻게 구현할 지에 대한 감각을 키워보자.

 

4.3.1) push의 행동

push ebp는 무엇을 하는 명령인지 그림으로 그려볼 수 있는가? 이를 그릴 수 있다면 4장을 아주 열심히 공부한 것이며, 더 깊게 생각하면 필자의 도움 없이 이 문제를 해결할 수 있다. 이것을 직접 한 사람이라면 자신의 생각을 검증하는 용도로, 아니라면 그냥 필자의 구현을 보고 이해하면 된다.

4장의 CIL 코드를 다시 가져오자. 내용 이해를 위해 필요 없는 부분은 제외했다.

ProcNaked.c (modified)

#include "CIL.h"

STRING sHelloWorld = "Hello, world!\n";

 

PROC(main)

// naked_proc 프로시저를 호출합니다.

CALL(naked_proc)

ENDP

 

// NAKED 프로시저를 정의합니다.

PROC_NAKED(naked_proc)

PUSH(bp) // 이전 스택 시작 주소를 푸시하여 보관합니다.

MOVL(bp, sp) // 스택 시작 주소를 현재 스택 포인터로 맞춥니다.

 

MOVL(sp, bp) // 현재 스택 포인터를 스택 시작 주소로 맞춥니다.

POP(bp) // 보관했던 이전 스택 시작 주소를 불러옵니다.

RET() // 복귀 지점으로 돌아갑니다.

ENDP_NAKED

그리고 각각의 상태를 그림으로 표현하면 다음과 같이 진행이 된다고 설명했었다.





그리고 호출한 함수를 정리하는 코드가 있지만, 당장 중요한 부분은 PUSH(bp)MOVL(bp, sp)로 이루어진, 호출한 함수의 스택을 형성하는 부분이다. CALL에서 PUSH로 넘어갈 때 어떤 일이 일어났다고 생각하는가? 이를 추론할 수 있으면 위에서 제시했던 문제는 해결이 된다.

답을 바로 말하겠다. 이 사이에서는 다음의 명령들이 일어났다.

1. sp의 값이 4만큼 줄었다.

2. sp가 가리키는 위치의 메모리에서 4바이트만큼을 bp로 채웠다.

이 두 가지가 끝이다. 그런데 그 전에 또 고려된 것이 있다. 바로 bpsp가 초기 값을 가지고 있었다는 사실이다. PROC(main) 부분의 코드를 보라. spbp는 기본적으로 메모리의 끝을 가리키고 있었다. 이 별것 아닌 것처럼 보이는 사실이 고려되지 않으면 우리는 프로젝트를 진행할 수 없게 된다!

 

4.3.2) Memory와의 결합

그럼 이를 모두 반영하여 push를 작성하겠다. 이를 위해 Memory의 코드가 바뀐다.

Memory.js

...

/**

메모리의 전체 크기를 반환합니다.

@return {number}

*/

function GetMaxMemorySize() {

return MAX_MEMORY_SIZ;

}

 

// 싱글톤에 속성 등록

...

memory.MaxSize = GetMaxMemorySize;

...

그리고 컴파일러를 위한 모듈 목록에 방금 작성한 Register를 추가한다.

main.html <html.head>

<!-- 컴파일러를 위한 모듈 목록입니다. -->

<script src="Memory.js"></script>

<script src="Register.js"></script>

<script src="Runner.js"></script>

그리고 ebp, esp 레지스터를 방금 말했듯 메모리의 마지막으로 설정한다.

Register.js

...

register.ebp = Memory.MaxSize();

register.esp = Memory.MaxSize();

...

이제 Runner, 방금 그림을 통해 보인 내용을 코드로 작성하면 된다. 출력문은 주석 처리했다.

Runner.js

/**

분석된 코드를 실행합니다.

@param {InstructionInfo}

*/

function execute(info) {

var mne = info.mnemonic, left = info.left, right = info.right;

// log('instruction: [' + mne + '][' + left + '][' + right + ']');

 

if (mne == 'push') { // push 니모닉에 대한 처리

if (left == null) // 피연산자가 없으면 예외 처리합니다.

throw new RunnerException('invalid operand');

 

// esp의 값이 4만큼 줄었다.

Register.esp -= 4;

 

// esp가 가리키는 위치의 메모리에서 4바이트만큼을 ebp로 채웠다.

Memory.set_dword(Register.ebp, Register.esp);

}

}

다음은 Memory에 해당 값이 제대로 들어갔는지를 확인하기 위해 테스트 코드를 작성한 것이다.

main.html <html.head.script>

// Memory 테스트 예제입니다.

function main() {

try {

// run 이전 고려사항:

// 1. Runner가 프로그램을 실행합니다.

// 2. Memory 모듈의 bytePtr0으로 초기화되어있었습니다.

// 3. ebp, espMemory.MAX_MEMORY_SIZ였습니다.

Runner.run('HelloHASM.hda');

 

// run 이후 고려사항:

// 1. push만 작성했으므로 push만 메모리에 반영되어있습니다.

// 2. ebp는 변하지 않았습니다.

// 3. esp4만큼 줄었습니다.

// 4. esp가 가리키는 위치의 메모리는 ebp의 값과 같습니다.

var dword = Memory.get_dword(Register.esp);

log(dword);

 

} catch (ex) {

if (ex instanceof RunnerException) {

// RunnerException에 대한 처리를 작성합니다.

log('Runner module throws an error');

log(ex);

}

else {

// RunnerException이 아니라면 여기서 처리하지 않습니다.

throw ex;

}

}

}

function init() {

...

initMemory();

initRegister();

initRunner();

}

실행 결과

segment found: [.data]

segment found: [.code]

label: _main:

analyzing complete

100

마지막 줄에 ebp의 값인 100이 제대로 찍혔음을 확인할 수 있다. 이렇게 push는 완성이 된다.

 

5. 모듈 개선

5.1) 메모리가 제대로 바뀐 게 맞나요?

그런데 지금 우리가 제대로 하고 있는 걸까? 100이야 초기화가 안 된 메모리에서 아무 것이나 읽어내서 나온 숫자일 수도 있지 않은가? 우리가 얻어낸 100이 정말로 우리가 원하는 위치에서 읽어낸 결과가 맞다고 보장할 수 있는가? 이 문제를 해결하기 위해 이 절에서는 현재 메모리의 상태를 보여주는 show 메서드를 작성할 것이다.

C++ 소스 코드를 하나 작성하자. 9번 줄에 적당히 중단점을 설정한다.

#include <iostream>

int main(void) {

int arr[] = {

8, 9, 10, 11, // 10진수 표기

0x0A, 0x0b, 0x12, 0x34, // 16진수 표기

'A', 'B', 'C', 'D', // 아스키 코드 값 표기

65, 66, 67, 68 // 아스키 코드 값(정수) 표기

};

for (int i = 0; i < 10; ++i) {

std::cout << arr[i] << std::endl;

}

return 0;

}

Microsoft의 훌륭한 도구인 Visual Studio로 프로그램을 디버깅하면 이런 창을 볼 수 있다.



디버깅 모드로 프로그램을 실행하고 [디버그>>메모리>메모리 1] 메뉴를 통해 현재 메모리 상태를 확인할 수 있도록 되어있다. 이 창을 처음 봤다면 많이 당황했겠지만, 배열 arr가 메모리에 표현되어있는 게 전부다. 나머지는 대부분 지금 상황과 관련 없는 쓰레기 메모리 값이다. 이것이 arr 배열이라는 사실을 보다 자세히 보여주겠다.



int의 크기인 4바이트만큼, 리틀 엔디안 방식으로 수가 기록되어있는 것을 볼 수 있다. 이렇듯 이 창은 아주 단순한 사실을 보여준다. 왼쪽은 메모리의 주소값, 오른쪽은 해당 값의 아스키 코드 값을 보여준다. 우리도 이런 식으로 메모리를 보여준다면 한층 프로그램을 작성하기 쉬워질 것이다.

그럼 이런 식으로 메모리를 보여주는 코드를 작성해보자. 그러니까 결과가 이렇게 나오면 좋겠다.

예상 결과

0x00000000 | 0000000000000000 | ................

0x00000010 | 0000000000000000 | ................

0x00000020 | 0000000000000000 | ................

0x00000030 | 0000000000000000 | ................

0x00000040 | 0000000000000000 | ................

0x00000050 | 0000000000000000 | ................

0x00000060 | 0000 | ................

그러면 다음의 값은 필요하다.

var address_begin; // 보여주기 시작하려는 메모리의 시작 주소

var address; // 현재 보여주려는 메모리의 주소

var byte = Memory.get_byte(address); // 해당 메모리의 바이트 값

var letter = String.fromCharCode(byte); // 코드 값으로부터 문자를 획득

그리고 다음은 이를 바탕으로 메모리의 바이트를 보여주는 show 메서드를 작성한 것이다.

Memory.js (show)

/**

메모리를 출력합니다.

*/

function show() {

var mem_str = ''; // 모든 메모리 텍스트를 보관하는 변수

var mem_addr = ''; // 메모리 텍스트 줄을 보관하는 변수

var mem_byte = ''; // 바이트 텍스트를 보관하는 변수

var mem_char = ''; // 아스키 문자 텍스트를 보관하는 변수

 

var addr_beg = 0; // 보여주기 시작하려는 메모리의 시작 주소

var addr_end = Memory.MaxSize() - 1; // 메모리의 끝 주소

 

// 모든 메모리의 바이트를 탐색합니다.

for (var i=0; (addr_beg+i <= addr_end); ++i) {

 

// 주소를 얻습니다.

var addr = addr_beg + i;

 

if (i % 16 == 0) { // 출력 중인 바이트의 인덱스가 16의 배수라면

// 개행합니다.

mem_str += (mem_addr + ' | ' + mem_byte + ' | ' + mem_char + NEWLINE);

 

// 표현하려는 주소 값을 16진수로 표시합니다.

var addr_str = Number(addr).toString(16);

 

// 다른 값을 초기화합니다.

mem_addr = addr_str;

mem_byte = '';

mem_char = '';

}

 

// 획득한 주소의 바이트 값을 얻습니다.

var byte = Memory.get_byte(addr);

 

// 초기화되지 않은 메모리라면 0을 대입합니다.

if (byte == undefined)

byte = 0;

 

// 바이트 값으로부터 문자를 획득합니다.

var ascii = String.fromCharCode(byte);

 

// 널 문자인 경우 점으로 대체합니다.

if (ascii == '\0')

ascii = '.';

 

// 각각의 문자열에 추가합니다.

mem_byte += Number(byte).toString(16);

mem_char += ascii;

}

 

// 마지막 줄이 출력되지 않았다면 출력합니다.

if (addr_beg + i == addr_end + 1) {

mem_str += (mem_addr + ' | ' + mem_byte + ' | ' + mem_char);

}

log(mem_str);

}

main.html <html.head.script> (main.try)

Memory.show();

실행 결과

| |

0 | 0000000000000000 | ................

10 | 0000000000000000 | ................

20 | 0000000000000000 | ................

30 | 0000000000000000 | ................

40 | 0000000000000000 | ................

50 | 0000000000000000 | ................

60 | 0000 | ....

결과가 나오기는 했지만 그렇게 이쁘게 출력되지 않았다. 이 문제를 어떻게 해결해야 하는가? 일단 위에 빈 세로 줄이 나온 것은 그렇다 치더라도 작성한 줄을 정렬하기는 해야 할 것 같다. 사실 지금 작성한 코드는 주석을 달긴 했지만 읽으라고 만든 코드는 아니기 때문에 읽는 데 시간을 들일 필요는 없다. 그저 이대로 그냥 show를 구현하는 것은 문제가 있다는 사실을 알면 된다. 형식을 맞추려면 어떻게 해야겠는가?

필자는 그 답으로 Handy.format 메서드를 제안한다.

 

5.2) Handy 모듈 개선

Handy.format? 일단 Handyformat 사이에 점이 들어갔으니 formatHandy라는 객체의 멤버로 보인다. 정확한 판단이다! 우리는 Handy라는 객체를 만들고 여기에 format이라는 메서드를 넣을 것이다. 그리고 여기서 log 메서드도 개선하여 보다 사용하기 편하게 만들고자 한다.

그럼 바로 시작하자. 일단 Handy는 싱글톤 객체로 구현할 것이다. 따라서 여기에도 다음과 같은 코드가 들어가게 된다.

handy.js (initHandy)

/**

Handy 라이브러리 기본 객체인 Handy 싱글톤 객체를 초기화합니다.

*/

function initHandy() {

var handy = {}; // 빈 객체 생성

/* ... */

window.Handy = handy;

}

그리고 여기서 format이라는 메서드를 구현할 것이다. 다만 오류를 줄이기 위해 일단 바깥에서, main.html 파일에서 구현해본 다음 initHandy 메서드 안으로 옮기는 식으로 구현하자.

 

5.2.1) Handy.format

C에는 printf 함수가 있어서 형식화된 문자열을 매우 편리하게 출력할 수 있다. 만약 우리가 작성한 show 메서드를 printf로 작성할 수 있었다면 출력하기 편했을 것이다. Handy.format은 바로 이를 위한 메서드다. 이 메서드는 printf와 비슷한 방법으로 형식화된 문자열을 반환해준다. 한 번 사용해보고 나면 Handy.format의 매력에 빠져 헤어 나오지 못할 것이라고 장담한다.

그래, 이제 format이 무엇인지 알았으니 이를 구현할 차례다. Handy.format은 이렇게 사용한다.

var str = Handy.format('Hello, world!');

log(str); // Hello, world!

str = Handy.format('Hello, %s, %d and %c!', 'your_name', 100, 65);

log(str); // Hello, your_name, 100 and A!

log('Hello! %04b * %-4d is %04x', 3, 6, 3 * 6); // Hello, 0011 + 6___ is __12

맨 마지막을 보면 log 메서드도 printf처럼 사용하고 있음을 알 수 있다. 더 자세히 보자.

1. %d는 정수, %s는 문자열이다. %c는 문자 코드 값에 해당하는 문자를 출력한다.

2. Handy.format은 문자열을 반환한다. 인자의 수는 제한이 없고 첫 문자열은 형식 문자열이다.

3. %b는 이진수, %x16진수로 수를 출력한다.

4. % 다음에 -가 붙으면 왼쪽 정렬, 0이 붙으면 빈 칸에 0이 붙는다. 기본적으로 오른쪽 정렬이다.

이 정도면 Handy.format을 구현하기 위해 어떤 것이 고려되어야 하는지 알 수 있을 것이다. printf를 구현해본 경험이 있다면 편하게 구현할 수 있겠지만, 없어도 별 상관없다. 여기서 설명하는 내용은 모두 Runner를 구현하는 것과 직접적으로 관련이 있기 때문에 반드시 이해하고 넘어가야 한다(Memory 모듈의 show를 구현하는 데 그치지 않는다).

먼저 format을 가장 단순하게 구현하자. 다음과 같을 것이다.

main.html <html.head.script> (format)

/**

형식화된 문자열을 반환합니다.

@param {string} fmt

*/

function format(fmt) {

// fmt를 그대로 반환합니다.

return fmt;

}

이것만으로는 format은 무의미하다. 우리는 printf처럼 가변 인자를 이용하여 출력을 하고 싶다. 이를 위해 우리는 JavaScript의 함수가 기본적으로 가지는 필드인 argument를 이용해야 한다.

예제를 보자. 다음은 넘겨받은 모든 정수의 합을 반환하는 sum을 구현한 것이다.

main.html <html.head.script> (sum)

/**

넘겨받은 모든 정수의 합을 반환합니다.

@return {number}

*/

function sum() {

 

// 인자의 정보를 담는 리스트 객체를 획득합니다.

var args = sum.arguments;

 

// 인자의 갯수를 획득합니다.

var argc = args.length;

 

// 주어진 모든 인자의 합을 구합니다.

var s = 0;

for (var i=0; i<argc; ++i) {

s += args[i]; // i번째 인자에 접근합니다.

}

 

// 정수의 합을 반환합니다.

return s;

}

main.html <html.head.script> (main)

function main() {

log(sum(1,2,3));

log(sum(1,2,3,4,5));

log(sum(1,2,3,4,5,6,7,8,9,10));

}

실행 결과

6

15

55

함수인 sum을 마치 객체처럼 접근했다. 사실 6장에서 설명한 것처럼, JavaScript의 모든 것은 객체다. 지금 보인 바와 같이 함수조차도 말이다. 메서드는 각자의 인자에 대한 정보를 가지고 있으며, 이를 이용해 넘겨받은 인자를 일괄적으로 처리하는 것이 가능해진다.

이를 이용하면 우리가 코드를 어떻게 변경해야 하는지 감이 오게 된다. 일단 연습을 해보자. 인자로 넘긴 모든 문자열을 더하여 반환하는 cat 함수를 작성해보라. 즉 다음과 같이 코드를 작성하면,

main.html <html.head.script> (main)

...

function main() {

log(cat('hell', 'oven', 'us'));

}

...

실행 결과는 다음과 같아야 한다.

예상 결과

hellovenus

필자의 구현은 다음과 같다.

main.html <html.head.script> (cat)

/**

문자열을 모두 더하여 반환합니다.

@return {string}

*/

function cat() {

var args = cat.arguments;

var argc = args.length;

var s = ''; // 문자열을 반환하므로 초기값도 문자열입니다.

for (var i=0; i<argc; ++i) {

s += args[i]; // i번째 인자에 접근합니다.

}

return s;

}

코드를 보면 알 수 있는데, 실제로 sum과 다른 것이 하나도 없다! 이제 이를 이용해 format을 구현하는 방법을 생각해보자.

printf는 형식적으로 값을 출력할 때 언제나 앞에 퍼센트(%) 기호가 붙었다. 그리고 그렇지 않은 문자는 그냥 그대로 출력된다. 따라서 우리는 format을 만들 때 이 내용을 고려해야 한다.

1. %를 만났다면 그에 대해 처리합니다.

2. %가 아니라면 그냥 출력합니다.

바로 반영해보자. printf는 어려운 것이 없고, 그냥 % 기호가 나타나면 그에 대해 처리할 뿐이다.

main.html <html.head.script> (format)

function format(fmt) {

// 반환할 문자열입니다.

var result = '';

 

// fmt의 모든 문자에 대해 반복문을 구성합니다.

for (var i=0, len=fmt.length; i<len; ++i) {

 

// i번째 문자를 획득합니다.

var c = fmt.charAt(i);

 

// 퍼센트 기호라면 특별하게 처리합니다.

if (c == '%') {

result += '<value>'; // 퍼센트 기호라면 '<value>' 문자열을 붙입니다.

}

// 퍼센트 기호가 아니라면 그냥 붙입니다.

else {

result += c;

}

}

// 문자열을 반환합니다.

return result;

}

그리고 이렇게 작성된 format을 테스트해보면 다음과 같다.

main.html <html.head.script> (main)

function main() {

log(format('hello, %s!', 'test'));

}

실행 결과

hello, <value>s!

<value>를 붙이도록 했는데 뒤에 s가 그냥 붙었다. 왜 그런지 이해하겠는가? 이는 우리가 퍼센트 기호를 읽은 후에 그냥 다음 문자를 획득했기 때문이다. 즉 퍼센트 기호를 해석했다면 뒤따라오는 문자까지 해석해야 올바르게 문자열을 해석했다고 할 수 있다.

이에 대한 처리는 어렵지 않다. 일단 정수(d), 문자열(s)와 퍼센트 기호 자체를 출력하기 위한 %만을 고려하여 작성해보자. 이는 다음과 같다.

main.html <html.head.script> (format.case%)

...

// 퍼센트 기호라면 특별하게 처리합니다.

if (c == '%') {

 

// i번째 문자는 이미 해석했으므로 i를 한 칸 옮깁니다.

++i;

 

// % 기호 다음의 문자를 가져옵니다.

var nextChar = fmt.charAt(i);

 

// 가져온 문자를 기준으로 조건 분기합니다.

var value;

switch (nextChar) {

case 'd': // 정수를 출력합니다.

value = '<integer>'; // <integer> 문자열을 붙입니다.

break;

case 's': // 문자열을 출력합니다.

value = '<string>'; // <integer> 문자열을 붙입니다.

break;

case '%': // 퍼센트를 출력합니다.

value = '%'; // <integer> 문자열을 붙입니다.

break;

}

 

// 해석한 문자열을 붙입니다.

result += value;

}

...

main.html <html.head.script> (main)

function main() {

log(format('hello, %s, %d and %%!', 'test', 10));

}

실행 결과

hello, <string>, <integer> and %!

결과는 나름 만족스럽다. 하지만 문자열과 정수에 대해서는 잘 처리되지 않았다. 사실 우리는 formatarguments 속성에 접근한 적도 없으니 이는 당연한 것이다. 그럼 이제 이에 대해 처리해보자.

main.html <html.head.script> (format)

...

var params = format.arguments; // format 인수 목록입니다.

 

// fmt의 모든 문자에 대해 반복문을 구성합니다.

// pi는 인자의 인덱스를 의미합니다.

// 0번 인덱스는 fmt이므로 그 다음 인자부터 고려합니다.

for (var i=0, pi=1, len=fmt.length; i<len; ++i) {

var c = fmt.charAt(i); // i번째 문자를 획득합니다.

if (c == '%') { // 퍼센트 기호라면 특별하게 처리합니다.

// % 기호 다음의 문자를 가져옵니다.

var nextChar = fmt.charAt(++i);

 

// 가져온 문자를 기준으로 조건 분기합니다.

var value;

switch (nextChar) {

case 'd': // 정수를 출력합니다.

 

// pi번째 인수를 획득합니다.

value = params[pi];

 

// 인덱스 번호를 증가시킵니다.

++pi;

 

break;

case 's': // 문자열을 출력합니다.

 

// 한 문장으로 합친 것입니다.

value = params[pi++];

break;

 

case '%': // 퍼센트를 출력합니다.

value = '%';

break;

}

...

실행 결과

hello, test, 10 and %!

좋아, 이제 우리가 원하는 출력이 제대로 되고 있음을 알 수 있다. 그럼 이제 남은 건 이진수와 16진수, -0을 이용한 공백 및 간격에 대한 처리뿐이다. 사실 이 부분은 어렵다기보다는 그냥 헷갈리는 것이 전부이므로 코드만 보이고 설명은 더 하지 않겠다.

main.html <html.head.script> (format)

/**

형식화된 문자열을 반환합니다.

@param {string} fmt

@return {string}

*/

function format(fmt) {

var value;

var result = ''; // 반환할 문자열입니다.

var params = format.arguments; // format 인수 목록입니다.

 

// fmt의 모든 문자에 대해 반복문을 구성합니다.

// pi는 인자의 인덱스를 의미합니다.

// 0번 인덱스는 fmt이므로 그 다음 인자부터 고려합니다.

var i, pi, len;

for (i=0, pi=1, len=fmt.length; i<len; ++i) {

var c = fmt.charAt(i); // i번째 문자를 획득합니다.

 

if (c == '%') { // 퍼센트 기호라면 특별하게 처리합니다.

// % 기호 다음의 문자를 가져옵니다.

var nextChar = fmt.charAt(++i);

 

// 옵션을 먼저 획득합니다.

var showLeft = false; // 기본적으로 오른쪽 정렬합니다.

var fillZero = false; // 기본적으로 여백을 공백으로 채웁니다.

switch (nextChar) {

case '0': // 이 옵션이 적용되면 여백을 0으로 채웁니다.

fillZero = true;

// 다음 문자를 다시 얻습니다.

nextChar = fmt.charAt(++i);

break;

case '-': // 이 옵션이 적용되면 왼쪽 정렬합니다.

showLeft = true;

// 다음 문자를 다시 얻습니다.

nextChar = fmt.charAt(++i);

break;

}

 

// 여백의 크기를 획득합니다.

// nextChar가 숫자인 동안 크기를 획득합니다.

var width = 0;

while ('0123456789'.indexOf(nextChar) >= 0) {

// 길이를 획득합니다. get_number 구현을 참조하십시오.

width = width * 10 + (nextChar - '0');

// 다음 문자를 다시 얻습니다.

nextChar = fmt.charAt(++i);

}

 

// 가져온 문자를 기준으로 조건 분기합니다.

switch (nextChar) {

case 'b': // 2진수를 출력합니다.

value = params[pi++].toString(2);

break;

case 'x': // 16진수를 출력합니다.

value = params[pi++].toString(16);

break;

case 'd': // 정수를 출력합니다.

value = params[pi++].toString();

break;

case 's': // 문자열을 출력합니다.

value = params[pi++];

break;

case 'c': // 문자 코드에 해당하는 문자를 출력합니다.

var param = params[pi++];

// 문자열이라면 첫 번째 글자만 획득합니다.

if (typeof(param) == 'string') {

value = param.charAt(0);

}

else { // 그 외의 경우 정수로 간주합니다.

value = String.fromCharCode(param);

}

break;

case '%': // 퍼센트를 출력합니다.

value = '%';

break;

}

 

// 정상적으로 값을 획득하지 못한 경우 undefined를 출력합니다.

if (value == undefined) {

value = 'undefined';

}

 

// 옵션을 문자열에 반영합니다.

if (showLeft) { // 왼쪽 정렬 옵션입니다.

var space = ''; // 여백 문자열입니다.

// 주어진 크기만큼 여백을 확보합니다.

for (var si=0, slen=width - value.length; si<slen; ++si)

space += ' ';

// 옵션을 반영합니다.

value = value + space;

}

else if (fillZero) { // 공백을 0으로 채우는 옵션입니다.

var space = ''; // 여백 문자열입니다.

// 주어진 크기만큼 여백을 확보합니다.

for (var si=0, slen=width - value.length; si<slen; ++si)

space += '0';

// 옵션을 반영합니다.

value = space + value;

}

else if (width > 0) { // 두 옵션 없이 크기만 주어진 경우입니다.

var space = ''; // 여백 문자열입니다.

// 주어진 크기만큼 여백을 확보합니다.

for (var si=0, slen=width - value.length; si<slen; ++si)

space += ' ';

// 옵션을 반영합니다.

value = space + value;

}

 

// 해석한 문자열을 붙입니다.

result += value;

}

// 퍼센트 기호가 아니라면 그냥 붙입니다.

else {

result += c;

}

}

 

// 문자열을 반환합니다.

return result;

}

main.html <html.head.script> (main)

// format 테스트 예제입니다.

function main() {

log(format(' ----- score -----'));

log(format('%-5x|%5s|%5s|%5c', 1, 'A', 'B', 67));

log(format('%-5d|%5b|%5d|%5x', 2, 5, 5, 5));

log(format('%-5d|%05b|%05d|%05x', 4, 5, 5, 5));

}

실행 결과



이제 이렇게 만든 formatHandy 내부로 옮기면 Handy.format의 작성은 완료가 된다.

 

5.2.2) log 메서드 개선

바로 위에서 main 함수에 log(format(...));과 같은 식으로 코드를 작성했는데, 보기 안 좋고 불편하다. 그러니 기왕 할 거 log 메서드도 저렇게 사용할 수 있도록 해보자. 그런데 보통 log 메서드를 저렇게 바꾸자고 하면 코드를 통째로 복사해서 붙여 넣는 식을 생각하기 쉽다. 물론 그렇게 할 것이었다면 여기서 소개하지 않을 것이고, 우리는 좀 더 현명한 방법으로 log를 개선하면서 가변 인자에 대한 감각을 더 늘려볼 생각이다.

먼저 log 메서드의 정의로 돌아가보자.

handy.js (log)

function log(message) {

var logStream = document.getElementById('HandyLogStream');

logStream.value += (message + NEWLINE);

}

log를 가변 인자 메서드처럼 사용하려고 한다면, 당연히 logarguments 인자는 호출해야 한다. 그런데 log가 넘겨받은 인자 리스트를 그냥 format에 가져다주면 바로 끝나지 않을까? 그렇다면 log의 파라미터 목록을 format에 어떻게 넘겨야 하는가?

필자는 arguments 객체를 넘기기 위해 format 메서드를 vformat으로 분리하는 방법을 생각해냈다. 이를 이용하면 log 메서드와 format 메서드의 내부는 다음과 같이 바뀐다.

handy.js (log)

/**

로그 스트림에 문자열을 출력합니다.

@param {string} fmt

*/

function log(fmt) {

var result; // 로그 스트림에 출력할 문자열을 보관합니다.

if (log.arguments.length == 0) { // 인자의 수가 0이라면

result = ''; // 개행만 합니다.

}

else if (log.arguments.length > 1) { // 인자의 수가 1보다 크면 format을 호출합니다.

var params = Handy.toArray(log.arguments, 1); // 인자 목록 배열을 획득합니다.

result = Handy.vformat(fmt, params); // 형식 문자열과 인자 목록을 같이 넘깁니다.

}

else { // 인자의 수가 1이면 그냥 출력합니다.

result = fmt;

}

// 로그 스트림에 출력합니다.

var logStream = document.getElementById('HandyLogStream');

logStream.value += (result + NEWLINE);

}

handy.js (format)

function format(fmt) {

// 1번 인수부터 끝 인수까지의 인수들을 배열로 만들어서 vformat의 인자로 넘깁니다.

return Handy.vformat(fmt, toArray(format.arguments, 1));

}

그리고 vformat의 구현은, 다음과 같이 두 번째 인자로 인수의 목록을 받는 형식으로 바뀐다.

handy.js (vformat)

...

function vformat(fmt, params) {

var value;

var result = ''; // 반환할 문자열입니다.

 

// fmt의 모든 문자에 대해 반복문을 구성합니다.

// pi는 인자의 인덱스를 의미합니다.

var i, pi, len;

for (i=0, pi=0, len=fmt.length; i<len; ++i) { // pi0으로 바뀌었습니다!

...

마지막으로 toArray의 정의를 보면 다음과 같다.

handy.js (vformat)

/**

리스트 객체의 부분집합을 배열 객체로 변환하여 반환합니다.

@param {number} startIndex

@param {number} endIndex

@return {Array}

*/

function toArray(list, startIndex, endIndex) {

// 시작 인덱스와 끝 인덱스의 기본 값을 설정합니다.

startIndex = getValid(startIndex, 0);

endIndex = getValid(endIndex, list.length-1);

 

// 배열을 복사합니다.

var arr = new Array();

for (var i=startIndex; i<=endIndex; ++i) {

arr[arr.length] = (list[i]);

}

 

// 복사한 배열을 반환합니다.

return arr;

}

그리고 이를 이용해 Memory.show 메서드를 다음과 같이 수정한다(2군데다).

Memory.js (show)

...

// mem_str += (mem_addr + ' | ' + mem_byte + ' | ' + mem_char + NEWLINE);

mem_str += Handy.format

('0x%08x | %-32s | %-16s \n', mem_addr, mem_byte, mem_char);

...

mem_byte += Handy.format('%02x', byte);

...

그리고 코드를 실행하면 다음 결과를 얻는다.

실행 결과



이제야 우리는 메모리를 제대로 표현하게 되었다. 맨 위에 있는 줄이 거슬린다면 지금의 당신은 스스로 해결할 수 있을 것이다. 마지막으로 main.html 파일을 다음과 같이 수정하면 된다.

main.html <html.body.script>

try {

initHandy();

init();

main();

} catch (ex) {

if (ex instanceof Exception) {

log(ex);

} else {

// Exception 예외 객체가 아니라면 여기서 처리하지 않습니다.

throw ex;

}

}

이전 문서에서 얘기하지 않았지만, 사실 try-catch로 문장을 감싸지 않는 것이 디버깅엔 더 편할 것이다. Chrome의 특성 상 오류가 발생한 지점에 대한 정보가, try-catch가 없는 경우에 더 잘 나온다.

 

5.3) 출력 스트림 분리

우리는 메모리의 상태를 봐야 한다. 그런데 우리는 또한, 실행한 프로그램의 결과도 봐야 한다. 즉 우리는 한 화면에 두 개 이상의 결과를 봐야 한다. 그러므로 하나의 텍스트박스만 가지고서는 아무래도 내용이 섞여서 관리도, 알아보는 것도 힘들어진다. 그래서 이쯤에서 출력 스트림을 두 개 이상으로 분리하는 것이 좋겠다는 생각이 든다.

그럼 생각해보자. 메모리랑 실행 결과는 출력해야 한다. 또 뭐 출력해야 될 게 더 있을까? 일단 메모리와 실행 결과는 모두 로그 스트림에 출력하지 않을 것이므로, 로그 스트림도 별도로 필요하다. 그러면 일단 3개의 스트림은 반드시 필요한 셈이다. 필자가 스트림을 구현할 때는 총 6개의 스트림을 사용했는데, 지금 설명하면 납득하기 힘들 것이라고 생각하므로 후에 다루겠다.

main.html 파일을 열고 다음을 추가한다.

main.html <head.body>

<textarea id='outputStream'></textarea>

<textarea id='memoryStream'></textarea>

<textarea id='HandyLogStream'></textarea>

<script>

...

그리고 스타일을 변경하자. 이대로는 화면이 좁으므로 package.json 파일을 수정한다.

package.json

{

"name": "nw_demo",

"version": "0.0.1",

"main": "main.html",

"window": {

"width": 1280,

"height": 768,

"position": "center"

}

}

그리고 outputStreammemoryStream 요소의 크기를 적절히 맞춰준다.

main.html (init)

function init() {

var logStream = document.getElementById('HandyLogStream');

logStream.style.width = '100%';

logStream.style.height = '29%';

 

var outStream = document.getElementById('outputStream');

outStream.style.width = '49%';

outStream.style.height = '70%';

 

var memStream = document.getElementById('memoryStream');

memStream.style.width = '49%';

memStream.style.height = '70%';

 

initHandyFileSystem();

initMemory();

initRegister();

initRunner();

}

그런데 이렇게 놓고 보니 init 함수가 그 내용과 상관없이 지나치게 길어지는 것 같다. 따라서 화면의 스타일을 조정하는 함수를 새로 만들고 이를 호출하는 식으로 변경하자.

main.html (init)

function init() {

initHandyFileSystem();

initStyle();

 

initMemory();

initRegister();

initRunner();

}

function initStyle() {

...

이제 스타일에 대해서는 모두 정리가 되었다. 남은 것은 만든 textarea 요소를 실제 스트림처럼 동작하게 만드는 일이다. 별 게 있는 건 아니고 log 메서드를 복사해서 출력되는 위치만 바꿔주면 된다. 메모리를 출력하는 메서드를 mem_write, 출력 문자열을 출력하는 메서드를 out_write라고 하자.

하나만 더 생각하자. 이 두 메서드의 위치가 어디라고 생각하는가? Handy? Runner? 아니면 Memory인가? 일단 이 함수들은 모두 Runner만 사용하는 함수로 보인다. 그렇다면 Runner에 구현하고 후에 이것이 틀렸을 경우에 수정하기로 하자.

 

5.4) register 예제 확인

이제 Register 예제가 정말 올바른 결과를 냈는지 확인할 차례다. main에 다음 두 구문을 적는다.

main.html (init)

function main() {

Runner.run('HelloHASM.hda');

Memory.show();}

그리고 Memory.show가 메모리 스트림에 출력하도록 코드를 고친다.

Memory.js (show)

function show() {

...

// log(mem_str);

Runner.mem_write(mem_str);

}

그리고 코드를 실행하면 다음 결과를 얻는다.

실행 결과



메모리를 확대해서 보자.

실행 결과



메모리에서 맨 아랫줄만 64로 바뀌었는데, 제대로 된 게 맞는 것일까? 결론부터 말하자면, 아주 깔끔한 결과다! 6416진수로 표기된 값인데 이를 10진수로 바꾸면 100이 된다. 우리가 기록한 ebp의 값인 것이다! 맨 왼쪽에 있는 것은 리틀 엔디안 방식으로 표기했기 때문이고, 뒤에 06개 붙는 것은 우리가 esp4만큼 뺐기 때문이다. 이로써 show 함수를 통해 메모리를 관찰할 수 있게 되었다.

push 연산이 올바르게 동작한다는 사실을 확인했으니, 나머지 연산들이 제대로 동작하는지를 확인하면 된다. push 명령 다음은 다음의 mov 명령이다.

mov ebp, esp

그런데 mov 명령은 메모리가 변하는 명령이 아니라, 순수하게 레지스터의 값만 변하는 명령이다. 그러므로 이 명령이 제대로 동작했는지 확인하려면 우리는 레지스터의 값도 볼 수 있어야 한다. , 레지스터 값을 출력하는 스트림이 필요하다. 또한, 각 명령어 단위로 레지스터의 값이 어떻게 변하는지를 볼 수 있어야 한다. 이 내용을 바탕으로 스트림을 만들고 몇 가지 수정해보자.

 

5.5) 스트림 추가

일단 우리가 무엇을 만들려는지부터 다시 파악하자. 레지스터 값을 출력하는 스트림이 필요한 건 알겠는데, 각 명령어 단위로 레지스터의 값의 변화를 보는 스트림은 아직 잘 모르겠다.

이 스트림은 expressionStream이라고 하는데, 현재 실행한 명령을 보여준다. HelloHASM 예제를 이용해 각 스트림의 용도를 설명하겠다. Runner는 처음 프로그램을 실행하면 이 상태가 된다.

Runner

HelloHASM.hda

Run

Undo

Redo

output stream

memory stream

register stream

expression stream

log stream

가장 위의 줄은 메뉴 바다. 맨 왼쪽에 입력 요소가 있어서 실행할 hda 파일의 이름을 입력한다. 나머지 세 요소는 모두 버튼이다. Run은 실행 버튼이다. Undo/Redo는 후에 설명하겠다. 그림에는 각각의 스트림의 용도가 설명되어있는데, 이것이 정확히 어떤 뜻인지 예제를 보이면서 설명하겠다.

여기서 run 버튼을 누르자. 그러면 코드 영역 이전의 내용이 먼저 분석되어 로그 스트림에 출력된다.

Runner

HelloHASM.hda

Run

Undo

Redo

 

...

0x60 | 00000000 | ....

eax = 0, ebx = 0, ecx = 0, edx = 0,

ebp = 100, esp = 100

 

...

label: _main:

push ebp 명령을 만나면 다음과 같이 상황이 변한다.

Runner

HelloHASM.hda

Run

Undo

Redo

 

...

0x60 | 64000000 | d...

eax = 0, ebx = 0, ecx = 0, edx = 0,

ebp = 100, esp = 96

push ebp

...

label: _main:

한 번만 더 해보자. mov ebp, esp가 수행되면 다음과 같이 상황이 변한다.

Runner

HelloHASM.hda

Run

Undo

Redo

 

...

0x60 | 64000000 | d...

eax = 0, ebx = 0, ecx = 0, edx = 0,

ebp = 96, esp = 96

mov ebp, esp

...

label: _main:

각각의 스트림에 맞게 문자열이 들어갔음을 볼 수 있다. 이 내용을 Runner에 반영해보자.

요소를 새로 만드는 과정은 크게 어렵지 않다. 적당히 버튼 요소와 textarea 요소를 만든다.

main.html <html.body>

<input id='inputFileName'>

<input type="button" value='Run' onclick='run()'>

<input type="button" value='Undo' onclick='undo()'>

<input type="button" value='Redo' onclick='redo()'><br>

<textarea id='outputStream'></textarea>

<textarea id='memoryStream'></textarea><br>

<textarea id='registerStream'></textarea>

<textarea id='expressionStream'></textarea><br>

<textarea id='HandyLogStream'></textarea>

...

이 부분은 프로그램에 전혀 중요한 부분이 아니지만, HTMLJavaScript를 먼저 배우지 않아도 좋다고 시작 문서에서 말해놓은 만큼 설명을 하고 가겠다. input은 사용자로부터 입력을 받을 때 사용하는 요소다. 사용자로부터 입력을 받는 방법은 텍스트 상자도 있지만 버튼도 있고, 라디오 버튼도 있고 그 종류가 다양하다. 그래서 버튼을 만들 때는 typebutton으로 조정해주면 버튼이 된다. value는 만든 버튼에 표시할 텍스트의 이름이고, onclick은 버튼을 클릭했을 때 호출할 함수를 말한다. <br>은 줄을 바꿀 때 쓰는 요소다. 그리고 여기에 registerStream, expressionStream 요소가 추가되었음을 확인하면 된다.

추가한 요소들의 스타일을 맞춰서 깔끔하게 만들어준다.

main.html <html.head.script> (initStyle)

function initStyle() {

var logStream = document.getElementById('HandyLogStream');

logStream.style.width = '100%'; logStream.style.height = '25%';

var outStream = document.getElementById('outputStream');

outStream.style.width = '49%'; outStream.style.height = '34%';

var memStream = document.getElementById('memoryStream');

memStream.style.width = '49%'; memStream.style.height = '34%';

var regStream = document.getElementById('registerStream');

regStream.style.width = '49%'; regStream.style.height = '34%';

var expStream = document.getElementById('expressionStream');

expStream.style.width = '49%'; expStream.style.height = '34%';

}

그리고 각각의 스트림에 대해 출력 메서드를 만들어준다. 각각 reg_write, exp_write라 하자. 위치는 변하지 않는다. 이는 메모리, 출력 스트림을 만드는 것과 완전히 동일하므로 코드를 재차 싣지 않겠다.

 

6. 나머지 명령에 대한 처리

이제 mov ebp, esp를 처리해보자. 이를 위해 execute 메서드가 바뀐다.

Runner.js (execute.mov)

...

else if (mne == 'mov') { // mov 니모닉에 대한 처리

// right의 레지스터 값을 left에 대입합니다.

Register[left] = Register[right];

}

...

그리고 각 레지스터의 값을 출력하기 위해 run 메서드를 바꾼다. execute는 명령을 실행하는 구문이기 때문에 레지스터의 값을 출력하는 코드를 여기에 넣는 것은 바람직하지 않다.

Runner.js (run.after_execute)

// decode를 통해 분석된 정보를 바탕으로 명령을 실행합니다.

execute(info);

 

// 식을 분석하고 실행한 결과를 스트림에 출력합니다.

Runner.exp_write(line);

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);

 

// 각 명령을 단계적으로 보기 위해 중단점을 삽입합니다.

alert();

그리고 이를 실행해보면, 최종적으로 다음 결과를 얻는다.

실행 결과



각각의 명령이 단계적으로 보이고, 마지막에 최종 메모리 상태가 보인다. 어떤 명령을 분석했는지가 식 스트림에, 명령이 아닌 어떤 구문을 분석했는지가 로그 스트림에, 어떻게 레지스터가 변화했는지가 레지스터 스트림에 제대로 출력되었다. 구현한 pushmov가 제대로 반영되었음을 메모리 스트림과 레지스터 스트림을 통해 알 수 있다.

다음으로 넘어가자. 처리해야 하는 명령은 pop인데, 그 구현이 전혀 어렵지 않다.

Runner.js (execute.pop)

...

else if (mne == 'pop') { // pop 니모닉에 대한 처리

// esp가 가리키는 메모리의 dword 값을 획득합니다.

var popVal = Memory.get_dword(Register.esp);

 

// esp를 레지스터 크기만큼 증가시킵니다.

Register.esp += 4;

 

// 목적지 레지스터에 획득한 값을 대입합니다.

Register[left] = popVal;

}

...

4장의 그림을 보면서 공부하면, 정말 그림대로 코드가 구현되었음을 알 수 있다. 남은 건 ret이다. 그런데 이 문서에서 배운 내용만으로는 ret을 구현할 수 없기 때문에, ret은 일단 무시하고 다음 문서에서 다루려고 한다.

 

7. 아직 안 끝났어요

꾸준히 글을 읽어오던 독자라도 생각보다 긴 분량에 당황했을 것이라고 생각한다. 이번 문서에서 설명할 것이 많겠다고 생각은 했지만, 필자도 60쪽이 넘게 글을 썼는데 아직도 Runner를 모두 설명할 수 없었다는 사실에 놀라고 있다. 글을 계속 읽는 분이 있다면, 갑자기 두 배만큼 길어진 이 글을 정말 모두 읽을 수 있을까 하는 걱정이 앞선다.

얼마 전 Facebook 페이지에 운영자님께서 공지를 올려주셔서 용기를 얻었지만, 사실 이 긴 글을 정말 읽고 있는 사람이 있는지 모르겠다. 이제는 그냥 나를 위해, 후에 내 능력을 증명할 때 증거로 내놓기 위해 글을 쓴다고 생각하고 있다. 매번 댓글 달아주시는 분들은 진심으로 감사하게 생각하고 있다.

다음 문서 또한 Runner에 대한 것이다. 다음 문서로 끝날지 모르겠지만, 최대 3챕터까지만 투자하면 Runner 모듈을 모두 설명할 수 있을 것이라 생각한다(사실 Runner가 제일 복잡하고 어렵다).

'알려주기' 카테고리의 다른 글

[JSCC] 10. 링커 개발  (0) 2015.07.10
[JSCC] 9. 가상 머신 개발 2  (0) 2015.07.03
[JSCC] 7. JSCC 준비  (0) 2015.06.19
Ubuntu Tips  (0) 2015.06.18
How to get current directory(current path) using node-webkit  (2) 2015.06.15
Posted by 누아니
,