90년대 초, ‘Write Once, Run Anywhere’라는 슬로건을 걸고 자바가 등장했다. 기존 다른 언어와 달리 단 한 번의 코딩으로 모든 플랫폼에서 실행할 수 있다는 점은 커다란 매력이 아닐 수 없었다. 물론 이런 이점 뒤에는 JVM이라는 가상 머신이 있다. 이번 연재에서는 소프트웨어가 하드웨어로부터 실행되는 원리를 간단히 살펴보고 그 원리대로 TVM이라는 하나의 작은 가상 머신을 구현해 봄으로써 로우 레벨을 더 잘 알 수 있는 계기를 마련하고자 한다.
2000년 이후 전반적인 프로그래밍의 흐름은 자바나 닷넷과 같은 하이 레벨 언어의 강세가 두드러졌다. 많은 기능들을 로우 레벨 레이어에서 제공해 주고 하이 레벨 언어는 그 기능들을 적절히 배치하고 활용하기만 하면 훌륭한 프로그램을 완성할 수 있다. 하지만 로우 레벨 레이어를 이해하고 그것을 활용할 수 있다면 훨씬 유연하게 프로그래밍을 할 수 있을 것이다. 이런 이유로 많은 사람들이 강조하는 말이 “Return to the root(근원으로)”였다. 이제 로우 레벨 레이어에서 제공해 주는 기능을 단지 사용만하기보다 그 기능을 제공해 주는 로우 레벨 레이어를 직접 만들어 보는 것은 어떨까? 이번 연재에서는 소프트웨어가 하드웨어로부터 실행되는 원리를 간단히 살펴보고 그 원리대로 TVM(Tiny Virtual Machine)이라는 하나의 작은 가상 머신을 구현해 봄으로써 로우 레벨을 더 잘 알 수 있는 계기를 마련하고자 한다.
소프트웨어 실행 원리 컴퓨터 시스템에서 일반적으로 프로그래머에게 보여지는 부분을 말할 때 ‘컴퓨터 아키텍처(computer architecture)’라는 용어를 사용하고, 그 부분들을 직접 구현하기 위해 회로 차원에서 여러 유닛들의 상호 관계를 말할 때 컴퓨터 오거나이제이션(computer organization)이라는 용어를 사용한다. 가상 머신을 개발하기 위해선 기본적으로 자신이 개발한 프로그램이 어떤 과정을 거쳐 실행되는지를 이해해야 한다.
컴퓨터 아키텍처 관점 우선 컴퓨터 아키텍처 관점에서 프로그래머가 작성한 소스코드를 컴퓨터에서 실행하는 과정을 살펴보자. <그림 1>은 유닉스 환경에서 C언어로 개발된 프로그램이 최종적으로 실행 파일을 생성하는 블럭 다이어그램이다. 소스 파일이 cc나 Tcc와 같은 컴파일러를 거치고 나면 확장자가 s인 어셈블리 코드를 만들어 내고 컴파일러는 자동으로 어셈블러를 호출한다. 어셈블러는 어셈블된 파일을 해당 시스템에 맞는 기계 코드(machine code)로 변환하고 변수의 위치 정보를 재배치 테이블에 저장하고 그 둘을 합쳐 오브젝트 파일을 생성한다. 링커(linker)는 각 오브젝트 파일과 참조한 라이브러리 파일을 합쳐서 하나의 실행 가능한 파일인 a.out을 생성하게 된다. 보통 a.out은 COFF나 ELF의 파일 포맷으로 저장되는데, 각 오브젝트 파일들이 갖고 있는 재배치 테이블에 있는 상대적인 주소 값을 정리하고 프로그램 실행에 필요한 여러 정보를 헤더에 추가해 실행 파일을 생성하게 된다. <그림 2>는 유닉스용 실행 파일 구조인 ELF(Executable and Linkable Format) 파일 포맷과 Win32용 실행 파일 구조인 PE(Por table Executable) 파일 포맷을 나타내었다. <그림 2>의 구조를 살펴보면 기계 코드와 변수를 참조하기 위한 헤더와 프로그램에 대한 정보가 공통으로 들어가고 추가적으로 각 OS에서 필요한 정보들을 담는 섹션들이 있음을 알 수 있다. 이 파일 포맷에 대한 자세한 정보는 참고자료를 살펴보기 바란다. 우리가 만들어 볼 TVM에서는 TEF(Tiny Executable Format)라는 파일 포맷을 정의해서 사용한다. 차후에 어셈블러가 TEF를 생성하고 TVM에서 TEF를 로드하는 과정을 다루기로 하겠다. 이렇게 각 OS에 맞는 실행 파일이 만들어지고 그 파일을 실행하게 되면 내부적으로 로더가 해당 실행 파일을 읽어 들인다. 로더는 실행 파일의 각 섹션에서 실행에 필요한 여러 정보들을 가져와서 필요한 데이터를 메모리에 복사한다. 그리고 나서 기계 코드가 저장된 메모리 영역으로 실행 위치를 옮겨 순차적으로 기계 코드를 실행함으로써 프로그램이 실행되게 된다. 기계 코드를 이해하기에 앞서 인스트럭션 셋(instruction set)을 알아보자. 인스트럭션 셋은 해당 CPU에서 처리할 수 있는 명령어들의 집합으로 각 CPU의 매뉴얼을 참조하면 어떤 명령어를 지원하는지 알 수 있다. 일반적으로 인스트럭션은 다음과 같은 형식을 가진다. OPCode는 명령어의 종류를 나타내고 Operand는 명령에 필요한 작업 대상으로 메모리나 레지스터나 숫자가 올 수 있다. CPU에 따라 Operand의 수와 각 항목의 값, 비트 수는 다르다. 컴퓨터는 모든 처리를 비트로 처리하기 때문에 인스트럭션은 해당 CPU에서 정의한 수치로 변환된 후 바이트 단위로 저장을 하게 된다. 이렇게 저장된 바이트가 기계 코드이고 이 데이터를 순차적으로 수행함으로써 프로그램이 실행되게 된다. 인텔 x86 인스트럭션 셋은 차후에 TVM 인스트럭션을 인텔 x86 기계 코드로 포팅하여 직접 실행하는 부분에서 더 자세히 알아보자.
☞ 내 이글루에 이 글과 관련된 글 쓰기 (트랙백 보내기) [도움말]