Computer Science/컴파일러개론(Compiler)

[컴개/CI] 3-address code: 예제 2

gxxgsta 2023. 12. 14. 15:38
반응형
SMALL

각각의 예를 분류하면 아래와 같다.

 

- 3-addr code: GCC GIMPLE, LLVM

- Stack machine code: JVM byte code, MSIL

- Tree code: GCC RTL

 

가상 기계 코드 (Bytecode, MSIL)

컴파일러는 전반부와 후반부로 나뉘는데, 전반부가 끝나면 IR이 등장하고, IR은 후반부를 거쳐서 target machine code로 변환된다. 전반부는 주로 High-level 언어만 관련이 있고, 후반부는 주로 머신코드와 관계가 되어있으므로 IR을 잘 정의 해야 한다.

 

전반부와 후반부로 나눠놓고, 후반부를 interpreter로 만드는 경우가 있다. 후반부를 interpreter로 만들면 후반부 자체가 가짜 기계가 되고, 중간코드는 interpreter 위에서 실행되는 구조를 갖게 된다. 이런 구조에서 사용되는 머신이 스택머신인 경우가 많고, IR이 스택머신 위에서 돌아가는 스택머신인 경우가 많다.

 

이때, 후반부를 interpreter로 만드는 이유는 대부분 이식성과 호환성이 목적인데, Java Byte code는 인터넷 환경에서도 잘 수행되는 applet(애플릿)이라는 특징이 있다. 예를 들어 서버 컴퓨터 A가 라이언트 컴퓨터 B가 있을 때, B가 어떤 운영체제인지를 몰라도 자바 버츄얼 머신만 깔면 애플릿을 B에게 전송할 수 있다. 즉 이식성이 좋다고 할 수 있다.

 

이식성이란 곧 타겟 머신 호환성이라고 할 수 있으며, abstract machine interpreter만 운영체제를 맞춰서 잘 깔아주면, 어떤 IR이라도 내 컴퓨터의 운영체제와 무관하게 돌아간다.

 

호환성의 경우 여러 언어를 지원해 올 때, 언어들의 라이브러리 공유가 어려워 MS에서는 C#이라는 언어의 구조에 맞게 low-level한 형태를 통일하고 이 형태는 중간코드화 되어 통일된다. 따라서 이렇게 통일된 IR이 가상머신 위에서 돌아가는 형태가 된다.

 

예 1) JVM Bytecode

위 사진은 JVM의 구조로 왼쪽이 메모리, 즉 스택머신의 구조이다.

함수를 호출하면 call stack에 호출된 함수가 사용하는 메모리 공간이 쌓이게 된다. 따라서 마지막에 호출된 함수가 가장 먼저 리턴되는 모양을 하고 있으며, call stack에 들어가는 형태가 위 그림에서의 메모리 형태이다.

 

스택 머신 코드안에는 Operand stack가 있다. 이 스택을 통해서 계산을 진행하는데, 3-addr 코드의 임시변수는 여기서 발생하지 않고, push로만 계산이 가능하므로 명령이 한글자로 명령이 가능하다. 즉, 코드의 크기가 작아질 수 있다.

 

자바 파일을 커맨드 라인에서 "javac test.java"를 통해 컴파일하면 .class 파일이 결과물로 도출된다.  .class 파일은 stack, 로컬 변수 등을 운용하는 자바 바이트 코드 파일로, 일반적으로는 인코딩 되어 있어 우리의 눈으로 읽을 수 있으려면 "javap -c test.class" 명령어로 컴파일하면 된다.

 

위 사진에서 메모리 내부의 스택이 지역 변수를 담는 스택이고, 아래 부분에 Constant pool을 합쳐 모두 전역 변수를 저장한다. 위 사진에서 언급되는 전체 메모리가 메소드 하나에서 필요로 하는 메모리 공간이며, call stack에는 이러한 메모리 구조가 쌓인다.

 

 

위 사진은 javac를 통해 Employee.java파일을 컴파일한 형태이며, Employee.class 파일이 결과로 도출된다. 이 파일을 열어보면 글씨가 거의 깨져있으며, 사진에 나온 부분은 comment에 해당된다.

 

 

위 코드를 javap로 컴파일한 JVM 코드의 형태로 나타내보자.

이때 위의 부분은 comment로 현재 컴파일 중인 메소드에 대한 간단한 설명이다.

 

aload_0이란, 스택에 변수를 push하라는 의미인데, 보통 로컬변수에서 0번째 인덱스의 값은 this이다. 따라서 this 값을 stack에 푸시하는 것이 0번째 줄의 명령이다.

 

1번째 줄의 명령어에서 진짜 명령어는 invokespecial #3까지다. 이후의 내용은 보기 좋기 나열한 것으로 3번 함수 값을 invokespecial하라는 의미이다. 이때, constant pool에서 전역변수를 담당한다고 하였는데, constant pool은 전역변수 뿐만 아니라 글로벌하게 성립되는 모든 정보를 가지고 있다.(함수 이름은 전역) 이때, <java.lang.Object()>의 생성자가 고유의 3번 번호를 가지고 있으며, 이 함수를 invoke하라는 의미이다.

 

이때, 이 명령어는 본인의 super 클래스의 생성자를 호출하는데, 이 코드에서 extends 키워드가 따로 보이지 않으므로 super 클래스인 Object의 생성자를 호출한다.

 

이때 invoke'specical'이라는 키워드는 보통 JVM에서 생성자나 private를 호출할 때 사용한다.

 

이렇게 생성자까지 불러오면 operand stack이 pop되어 사라질 것이다. 즉, 앞서 호출한 this도 함께 pop된다.

 

 

마찬가지로 아까 pop되어 사라진 this를 aload_0으로 다시 스택에 넣어준다.

이후 5번째 줄 명령어에서 aload_1을 수행해주는데, 1번을 가지고 있는 변수는 staName라는 변수이다. 즉, 함수의 인자로 들어오는 값이다. 이렇게 로컬 변수를 스택에 가져오는 명령어는 보통 load이고, 로컬 변수에 값을 저장하는 명령어는 보통 store이다.

 

this와 strName를 스택에 모두 넣은 상태에서 6번째 줄 명령어를 실행해보자. putfield #5 명령어는 필드의 5번 자리에 값을 넣으라는 의미이다. 이때, 5번 필드는 <>안의 내용을 미루어보아 Employee의 필드의 name라는 변수를 의미한다. 즉, name = strName이라는 명령어를 수행하는 부분이다.

 

위 명령어도 4, 5, 6 명령어와 동일한 순서를 가지고 있다.

 

 

위 명령어는 0, 1, 2를 모두 load하고, invokespecial 명령어를 통해 #6번째의 필드에 존재하는 storeData라는 String과 int를 인자로 받는 함수를 실행시킨다.

 

이때 위의 예시에서 명령어 앞에 존재하는 숫자들을 보면, 숫자가 일정하게 증가하지 않고 있다.

왜냐하면, 해당 숫자들은 index가 아니라 해당 명령어의 byte 시작 주소이기 때문이다.

보통 인자가 없는 명령어의 경우 1byte지만, 인자가 있는 명령어라면 인자의 크기에 따라 명령어의 길이가 늘어난다. 따라서 위 예제는 편의상 1, 2, 3으로 표기하였지만 실제로는 00000003으로 쓰여 있으며, 이러한 번호는 해당 명령이 시작하는 바이트 주소 번호이다.

 

예제에 load 시 aload와 iload가 존재한다. 이 차이는 단순히 부르는 대상에 따라 명령어가 바뀌는데, 객체 id를 stack에 푸쉬할 때에는 aload를 사용하지만, type이 int인 경우에는 iload를 통해 스택에 푸시한다.

 

또한 aload는 aload 하나만으로 명령어다. 즉, 실제로 표기를 하려면 aload #0으로 표기하여 aload 1byte, #0 2byte로 총 3byte의 명령어가 되어야 하지만, 자주 사용하는 0, 1, 2, 3까지는 aload_0과 같이 표기되어 1byte로 인코딩이 가능하다.

 

메소드를 invoke 또는 putfield하는 경우 this의 사용 유무와 관계 없이 꼭 this를 스택에 넣어 주는 것이 좋다. 이렇게 넣은 this는 invoke, putfield할 때에 함께 pop되어 사라진다.

 

예2) CIL (Common Intermediate Language)

 

.NET에서 생성된 중간 코드를 보자.

옛날에는 MSIL로 불리고 현재는 CLI라고 불리며, 처음에는 그냥 IR이라고 불렀지만, 보통 명사라 MS를 붙이거나 CLI이라고 부른다.

 

여러 종류의 C#, VB.NET, J# 등을 컴파일러를 통해 머신 코드를 가지 않고 CLI 형태로 바꾼 뒤, CLR에서 동작한다. 기본적으로는 C#에 맞추어 정의되어 있다.

 

CLI, CLR은 MS에서 많이 사용되며, 여기서 버추얼 버신을 VES라고 하며, VES에서 중간 코드를 실행시킬 수 있는데, 이는 기계어로 번역하는 비중이 더 크다.

 

인터프리팅을 수행하지만, 자주 사용되는 것은 미리 기계어로 번역시켜두고, 그 다음 사용부터는 바로 기계어로 사용하는 방법을 쓰는데, 이 방법을 JIT 컴파일링이라고 한다.

 

자바에서도 JIT 컴파일링을 진행하지만, 보통은 인터프리팅만 진행하고 옵션을 줘야 JIT 컴파일링을 한다. 반대로 MS는 기본적으로 JIT 컴파일링을 수행하고, 옵션을 빼면 인터프리팅만 진행한다.

 

용어

- CLI (Common Language Infrastructure)

- CLR (Common Language Runtime)

- CTS (Common Type System)
- CLS (Common Language Specification)
- VES (Virtual Execution System)

 

 

add eax, edx와 같이 실제 어셈블리 코드를 CIL 코드로 번역될 수 있다.

마찬가지로 스택 머신 코드이기 때문에, ldloc.0, ldloc.1을 통해 0과 1의 값은 가져온 후 둘을 add하고, stloc.0으로 0번째에 저장해준다.

 

아래 코드를 c코드로 변경하는 과정을 보자.

main()함수에서 entrypoint부터 시작하고, "Hello, world!"를 ldstr 명령어로 operand stack에 저장한다. 연산자는 stack top에 있는 값은 인자로 계산하는데, 해당 값을 인자로 갖는 메소드는 WriteLine으로 printf 명령어가 된다.

 

.assembly 부분은 자바의 버전 미스매치로 인한 경우를 방지하고자 연관되는 코드를 묶어놓은 부분이다.

 

옛날에 컴파일 했던 걸 지금 컴파일한 것과 붙여서 run 하면 런타임에러가 발생하는 경우가 있다.

따라서 비슷한 코드를 묶고, 묶음에 대해 버전을 통일한다. 따라서 이 묶음 중 일부를 받아오려면 해당 묶음을 다 받아야 한다는 운영 공동체를 어셈블리라고 한다.

 

.NET에서는 클래스가 여러 가지 있지만 클래스는 어셈블리 단위로 묶여있고 그 안에는 리소스도 있다. 이것과 유사한 개념이 자바의 모듈 개념이다.

 

TREE 구조 코드

 

트리 구조는 기계어를 만들 때에 유용하다.

AST나 HIR에 비해 로우한 레벨이며, 메모리 로드 등의 명시적인 표현이 존재하므로 복잡하다.

 

어디까지를 하나의 명령어로 볼 것인지는 트리를 보고 결정하는데,

왼쪽으로 묶은 경우 어떤 값을 구하기 위해 메모리에서 값을 읽고 있고,

오른쪽으로 묶은 경우는 메모리에서 메모리를 카피하기 위한 연산으로 판단할 수 있다.

 

즉, 코드를 생성하는 부분이 복잡하고, 트리를 가지고 어떻게 묶느냐에 따라 명령어를 만들 수 있다. 즉. 기계어 코드 selection을 위해 사용된다.

 

GCC 예) RTL Example

 

위 GCC RTL은 로우 레벵하고 트리 기반의 언어이다.

S-expression을 사용하는데, 이는 트리를 자식과 형제로만 엮는 방법과 비슷하며

위 예제에서는 레지스터 138번과 139번에 있는 값을 로드하여 plus로 더한 후 set을 통해 140번 레지스터에 set한다는 의미이다.

반응형
LIST