상세 컨텐츠

본문 제목

5일만에 뚝딱 스크립트 언어 만들기 PGLight (4/5)

프로그래밍/PG어

by ∫2tdt=t²+c 2013. 6. 24. 02:45

본문




짠, 저번에 파싱을 통해서 추상언어트리를 만드는걸 했구요, 이제는 가상머신을 구현해볼 차례입니다. 말이 거창하게 가상머신인데, 알고보면 간단한 놈이에요. 명령어를 받아들여서, 해석하고, 그에 따라 적당한 행동을 하면 됩니다. (CPU의 작동원리랑 거의 비슷해요!)

다만 실제로 구현할때 까다로운 부분은 메모리를 관리하는 겁니다. 가비지 컬렉션을 구현해야하니까요. 근데 그런거 없고 그냥 닥치고 shared_ptr로도 어느정도 메모리 관리를 할 수 있어요.

PGLight가 채택한 명령어 체계는 스택기반의 0주소 명령어에요. 스택기반의 명령어 체계는 트리를 후위순회(post order)하면 쉽게 얻어질 수 있고 피연산자의 주소를 계산하는 일이 없어서 코드 생성작업이 간단하기 때문이죠.

일단 스택에 보관될 자료타입에 대해서 정의하는 일이 필요합니다. 동적타이핑 언어답게 자료타입은 자유롭게 변경이 가능합니다. 그렇기에 변수 하나는 반드시 스택 1칸을 차지하므로 명령어를 단순하고 일관적으로 만들수 있습니다.


먼저 PGLight가 지원하는 자료타입이에요.


단순 타입

void : (값이 음슴ㅋ)

정수

실수 : (32비트 부동소수점 float. 64비트는 걍 지원안했음. 쓸일이 없어서...)

문자열 : (UTF-8 포맷이 원칙입니다)

부울

시간

쓰레드


복합 타입

배열

사전 : (키는 단순 타입만 사용할수 있도록 제한했습니다)


호출가능한 타입

함수 : (PGLight 언어의 함수)

C언어 함수 : (외부 C언어 함수)

클로져 : (변수가 캡쳐된 함수)


언어 내부적으로 사용되는 타입

레퍼런스 : (클로져에서 캡쳐된 변수들의 데이터를 공유하기 위해 사용됩니다)

배열 반복자 : (for문에서 배열을 순회하기 위해 사용됩니다)

사전 반복자 : (for문에서 사전을 순회하기 위해 사용됩니다)


정리해보니깐 타입이 은근 많네요. 우리는 이런 타입들을 모두 표현할 추상 클래스를 만들어야합니다ㅋ


자, 이를 바탕으로 이제 각각의 데이터 타입을 선언해 봅시다.





이런식으로 연산을 구현해나가면 됩니다.
코드에 shared_ptr이 남발되고 있습니다... 매번 포인터 참조를 하느라 가상머신 성능이 썩 좋지는 않겠지만 그래도 가비지 컬렉팅은 모두 스마트 포인터에게 양도하는걸로...ㅋ

자 그러면 이제 가상머신을 위한 명령어를 짜봅시다. 명령어는 바이트 단위가 아니라 short 단위(2바이트)로 구성됩니다. 별 이유는 없고, 그냥 2바이트로 넉넉하게 잡아놨는데... 별 쓸모는 없네요. (다시 줄여도 괜찮을거같아요ㅋ)
명령어는 오퍼랜드를 하나 가질수 있도록 약속을 세웠습니다. 그러니깐 명령어들은 모두 2바이트에서 4바이트 사이로 결정되겠네요.

으갸갸. 명령어가 많죠? (생각보다 많은 편은 아닙니다.) 요 명령어들만 있으면 모든 연산이 가능하다는게 놀라울 정도네요.

간단히 훑어보자면
스택에 자료를 넣거나 스택에 있는 자료를 외부로 저장하는 명령어들이 있어요
PUSH, POP, STORE, COPY, WRITE, PUSHNULL

PUSH 같은 경우는 스택에 자료를 넣는놈인데, 뒤에 오는 피연산자는 넣을 자료의 주소를 설정합니다. PGLight어 타입이 다양하고 길어서 코드 안에 직접 자료를 넣을순 없구요, 그 대신에 전역변수와 리터럴들이 모여있는 글로벌 데이터 존을 만들었어요. 거기에서 값을 찾아와서 스택에 넣어주는 역할을 합니다.

POP은 그냥 스택에서 자료를 빼는 녀석이구요

STORE 같은 경우는 글로벌 데이터 존에 자료를 입력하는 역할을 합니다. 값을 읽어오는 PUSH의 반대지요.

COPY는 스택에 있는 자료를 복사해서 마지막에 추가하는 역할을 합니다. 지역변수는 스택에 차곡차곡 저장되거든요. 지역변수 값을 불러올때 사용합니다. 그래서 이놈의 피연산자는 베이스 스택으로부터의 위치를 지정합니다.

WRITE는 스택에 있는 값을 덮어쓰는데 사용됩니다. 지역변수의 값을 설정할때 사용하는거죠. 그래서 이놈 역시 피연산자가 베이스 스택으로부터의 위치를 지정합니다.

PUSHNULL은 널 값을 넣어주는 명령어입니다.


호출과 관련된 명령어들이 있어요

CALL, THISCALL, RETURN, YIELD

CALL은 함수 호출입니다. 피연산자로 함수의 인수 갯수가 들어갑니다. 인수에 앞서 먼저 호출할 함수가 스택에 들어있어야하겠죠. 이 함수가 일반 함수인지, C함수인지, 클로져인지 구분하여 적절하게 다른 일을 하는게 포인트입니다. 당연히 베이스 스택과 호출 스택을 쌓는 역할도 하죠

THISCALL은 클래스 메소드를 위한 함수 호출입니다. 이걸로 함수를 호출할 경우에는 첫번째 인자로 this가 넘어가게 됩니다.

RETURN 은 반환이죠. 함수 수행을 중지하고 호출했던 곳으로 돌아갑니다. 당연히 베이스 스택과 호출 스택을 정리하는 역할도 합니다. 만약 돌아갈 곳이 없는 상태에서 RETURN이 호출되면 실행을 정지합니다.

YIELD는 return과 비슷하지만, 쓰레드의 수행을 일시 정지하고, 자신을 생성시킨 부모 쓰레드에게로 돌아가는 일을 합니다.


연산과 관련된 명령어들이 있어요

산술 ADD, SUB, MUL, DIV, MOD, POW, SIGN

비교 EQ, NEQ, GT, GTE, LS, LSE

논리 AND, OR, NOT


흐름제어와 관련된 명령어들이 있어요

JMP, UNLESSJMP

JMP는 무조건 분기입니다. 분기되는 위치는 상대주소(현재 실행되는 주소를 중심으로하는 변위)로 피연산자에 지정됩니다.

UNLESSJUMP는 조건문과 반복문을 위한 조건분기입니다. 스택에서 가져온 값이 거짓일때 분기를 해요. (왜 거짓일때 분기하도록 했느냐하면, if문과 while문 작동방식이 그렇거든요.) 역시나 피연산자로 상대주소를 지정합니다.


배열, 사전과 관련된 명령어

ASSEMBLE, NEWDICT, AT, ATD, LEN, SET, SETD

ASSEMBLE은 새 배열을 만들어요

NEWDICT는 새 사전을 만들구요

AT은 배열, 사전을 참조합니다.

ATD는 배열을 참조하는데, 참조하는 위치를 스택에서 가져오는것이 아니라 피연산자로 지정합니다. 클로저 구현에 중요하게 사용됩니다.

LEN은 배열, 사전의 크기를 얻어와요

SET은 배열, 사전의 값을 설정해요

SETD는 배열의 값을 설정하는데, 참조하는 위치를 스택에서 가져오는게 아니라 피연산자로 지정합니다.


클로저와 관련된 명령어

MAKECLOSURE, REF, DEREF, COPYDEREF, SETDDEREF, WRITEDEREF

MAKECLOSURE는 함수 주소와 캡쳐 변수 배열을 이용해 클로저 타입을 만듭니다. 이때 캡쳐되는 변수들은 레퍼런스화 되어있어야해요

REF는 스택에 있는 자료를 레퍼런스화하는데 사용합니다. 그 결과 그 값을 여러곳에서 공유하는게 가능해집니다 (c언어의 포인터 & 연산)

DEREF는 스택에 있는 레퍼런스화된 자료를 참조해서 그 값을 가져오는데 사용합니다. (c언어의 포인터 * 연산)

COPYDEREF 는 COPY DEREF를 합친거에요

SETDDEREF 는 SETD DEREF

WRITEDEREF는 WRITE DEREF


순회와 관련된 명령어

PUSHBEGIN, ISNOTEND, NEXT, PUSHKEYVAL

PUSHBEGIN은 배열, 사전의 순회를 시작하기 위해 첫 반복자를 얻는데 사용됩니다

ISNOTEND는 스택에 있는 반복자가 끝에 다다르지 않았으면 참을 스택에 넣습니다

NEXT는 스택에 있는 반복자를 다음으로 넘기구요

PUSHKEYVAL은 스택에 있는 반복자에서 키와 값을 얻어옵니다



명령어를 짰으니, 명령어를 실행하는 머신을 만들어야겠죠?



길기는 무지막지하게 긴데, 실제로는 별 내용없는 코드입니다. 에뮬레이터 구현하는게 이런 뻘뻘한 느낌이겠죠 아마...?

실제로 이런 명령어들을 통해 어떻게 함수가 호출되고, 클로저가 구현되며, YIELDING이 가능한지는 다음으로 넘겨야할거 같네요. 글이 너무 길어져서...




관련글 더보기

댓글 영역