안녕하세요. IT 엘도라도 에 오신 것을 환영합니다.
글을 쓰는 것은 귀찮지만 다시 찾아보는 것은 더 귀찮습니다.
완전한 나만의 것으로 만들기 위해 지식을 차곡차곡 저장해 보아요.   포스팅 둘러보기 ▼

컴퓨터 구조 (Architecture)/컴퓨터의 개념 및 실습

[Chapter 7] Assembly Language - 어셈블리어, 어셈블러

피그브라더 2020. 2. 2. 13:55

* [Chapter 6]은 컴퓨터 구조 개념과는 다소 거리가 먼 프로그래밍 방법론에 대해 설명하는 챕터이기 때문에 과감하게 생략하였다.

 

지금까지 우리는 ISA에 대해 공부했고, 그 규칙을 이해해서 기계어를 코딩하면 CPU에게 원하는 동작을 수행시킬 수 있음을 알게 되었다. 그러나 0과 1만으로 직접 코딩을 하는 건 너무 불편했다. 그래서 인간에게 조금 더 친숙한 형태로 어셈블리어(Assembly Language)가 고안이 되었고, 그 결과 프로그램 개발 속도가 혁신적으로 향상되었다. 어셈블리어는 저급 언어(Low-level Language)라고 부르기도 한다.

 

저급 언어로 작성된 프로그램을 CPU가 이해해서 실행하려면 변환 과정이 필요하다. 저급 언어로 작성된 코드는 어셈블러(Assembler)라는 프로그램에 의해 CPU의 ISA 체계에 맞게 기계어로 번역(어셈블)이 된다. 이때 하나의 프로그램은 여러 소스 파일로 구성될 수도 있는데, 그 경우 번역 과정도 각 파일마다 독립적으로 진행하여 기계어로 이뤄진 오브젝트 모듈을 여러 개 만들게 된다. 그것들을 적절히 합쳐서 하나의 실행 가능한 파일로 만드는 프로그램이 바로 링커(Linker)이다. 그리고 실행 가능한 파일의 데이터와 코드를 메모리에 올리고 CPU의 제어를 해당 프로그램의 시작 주소로 바꿔줌으로써 프로그램을 실행시키는 프로그램이 바로 로더(Loader)이다.

 

이번 포스팅에서는 LC-3 ISA를 기준으로 어셈블리어가 어떤 체계로 이뤄져 있는지 알아보고, 그러한 어셈블리어가 어떠한 과정을 거쳐서 어셈블러에 의해 기계어로 번역이 되는지도 살펴볼 것이다. 또한 그렇게 번역이 된 오브젝트 모듈들이 링커에 의해 합쳐지는 과정과, 합쳐져서 만들어진 하나의 실행 파일이 로더에 의해 실행되는 과정도 간략하게 한 번 알아볼 것이다.

 

※ 토막 지식 1 : 최초의 어셈블러와 컴파일러는 어떻게 만들었는가?

더보기

최초로 어셈블러를 만들 당시에는 당연히 어셈블리어가 없었을 것이다. 따라서 누군가 총대 메고 죽을 고생 해서 기계어로 어셈블러를 만들었을 것이다. 또한 고급 언어의 첫 번째 컴파일러도 마찬가지이다. 첫 번째 컴파일러를 만들 당시에는 당연히 해당 고급 언어가 없었을 것이다. 따라서 다른 고급 언어로 만들거나, 다른 고급 언어가 없다면 마찬가지로 누군가 총대 메고 죽을 고생 해서 어셈블리어로 만들었을 것이다. 컴퓨터 과학에서 이러한 작업을 보통 부트스트래핑(bootstrapping)이라고 부른다.

 

※ 토막 지식 2 : 컴퓨터 전원을 켰을 때 컴퓨터가 부팅되는 과정은?

더보기

부팅을 위해 맨 처음 컴퓨터의 전원을 켜면, Power Supply(전원 공급기)는 외부의 전압을 시스템에서 사용 가능한 전압(가령 0V ~ 2.9V)으로 변환하고, 그 변환 결과가 CPU로 전달된다. 그러면 CPU의 이전 값들은 모두 삭제되고, PC에 들어있는 값을 어떤 값(0, xFFFF, xF000)으로 초기화해버린다. 이 초기화 값은 곧 ROM(비휘발성 메모리)에 올라가 있는 BIOS 부트 프로그램의 시작 주소이다. 그 결과 ROM BIOS 부트 프로그램이 실행되며, (여러 가지 과정을 거친 후에) 하드디스크에 저장되어 있는 OS를 읽어 메모리에 올리고 CPU의 제어를 OS의 시작 주소로 바꿔주게 된다. 그러면 지금부터 OS가 지휘봉을 잡고 컴퓨터 전반을 통제하게 된다.

 

1. 어셈블리어 (Assembly Language)

1-1. 어셈블리어의 개념

CPU가 프로그램을 실행하려면, CPU가 채택한 ISA의 체계에 맞는 기계어 코드가 메모리에 적재되어야 한다. 하지만 0과 1로 직접 코딩하는 것은 상당히 난해하여 머리가 아플 수밖에 없다. 그래서 심볼과 같이 인간이 이해하기 쉬운 방식으로 프로그램 코드를 작성할 수 있기를 원하게 되었고, 그 과정에서 탄생한 것이 바로 어셈블리어(Assembly Language)이다. 그리고 어셈블리어를 해당 CPU의 ISA 체계에 맞게 기계어로 번역해주는 프로그램이 어셈블러(Assembler)이다.

 

당연하게도 어셈블리어는 반드시 하나의 ISA와 대응된다. 즉 어떤 ISA를 대상으로 고안된 어셈블리어로 작성된 프로그램의 경우, 다른 ISA를 사용하는 CPU에서 실행될 수 없다. 그래서 어셈블리어는 "ISA에 의존적(dependent)이다" 혹은 "하드웨어 이식성이 낮다"라고 표현된다. 이렇듯 하드웨어에 대한 의존성이 매우 높은 언어를 저급 언어(Low-level Language)라고 부른다. 

 

반면에 C 언어와 같은 언어들은 고급 언어(High-level Language)라고 부른다. 어셈블리어보다 훨씬 인간 친화적인 체계를 가질 뿐 아니라, ISA에 독립적이고 하드웨어 이식성이 높기 때문이다. 그 이유는, 고급 언어로 작성된 프로그램의 경우 해당 CPU가 사용하는 ISA에 대응되는 어셈블리어로 번역할 수 있는 컴파일러만 가지고 있다면 그 CPU에서 실행이 가능하기 때문이다.

 

쉽게 말해서, 어셈블리어는 ISA 체계에 맞는 기계어들을 인간이 그나마 이해하기 쉬운 심볼 등의 형태로 바꾼 것밖에 되지 않기에 당연히 ISA와 하드웨어에 의존적인 것이다. 반면 고급 언어는 ISA를 신경 쓰지 않고 고안된 언어이며 각 ISA의 기계어로 번역할 수 있는 컴파일러만 개발하면 되기에 ISA와 하드웨어에 독립적인 것이다.


1-2. 어셈블리어의 체계

어셈블리어의 각 줄은 반드시 다음 셋 중 하나에 해당된다. 각각에 대한 자세한 내용은 이어지는 부분에서 설명하도록 하겠다.

 

Instruction : 실제 ISA의 명령어와 일대일 대응되는 부분이다.

② Assembler Directive (= Pseudo-Instruction) : 실제 ISA의 명령어는 아니지만, 어셈블러에게 주는 일종의 메시지이다.

③ Comment : 코드의 가독성을 높이기 위해 작성하는 부분으로, 번역 과정에서 완전히 무시된다.

 

1-2-1. Instruction

명령어는 다음과 같은 형태로 작성이 된다.

→ LABEL  OPCODE  OPERANDS  ;  COMMENTS

 

LABEL은 특정 메모리 주소에 이름을 붙인 것을 말한다. 가령 메모리 주소 x00FF에 6이라는 데이터가 있다면, x00FF에 SIX라는 이름을 붙여 코드의 가독성을 높일 수 있을 것이다. 다만 LABEL을 쓰는 것이 필수는 아니다. OPCODE는 ISA 명령어의 opcode와 일대일 대응되는 심볼(mnemonic)이다. 예를 들어 opcode가 0001인 ADD 명령어의 경우 OPCODE 자리에 ADD라고 쓰면 되므로 모든 명령어의 opcode를 외우고 있을 필요가 없다. LABEL과 달리, OPCODE는 무슨 명령어인지 명시하기 위해 반드시 작성해줘야 한다. OPERANDS는 각 명령어에서 필요로 하는 피연산자들의 정보를 명시하는 부분이다. 레지스터는 Rn으로, 일반 상수는 #N 혹은 xN으로, 메모리 주소는 LABEL로 작성한다. 피연산자가 여러 개라면 콤마(,)로 구분한다. 해당 명령어가 피연산자를 필요로 하는 경우 OPERANDS 부분은 반드시 작성해줘야 한다. COMMENTS는 코드에 설명을 보충하는 주석으로, 프로그래머가 코드의 가독성을 높이기 위해 작성하는 부분이며 번역 과정에서 완전히 무시되므로 꼭 작성해야 하는 부분은 아니다.

 

참고로 LC-3 어셈블리어에서는 다음과 같이 TRAP 명령어에 대한 간편한 기호를 제공하고 있다. 이를 사용하면 호출하고자 하는 서비스 루틴의 Trap Vector를 굳이 외우고 있을 필요가 없다.

 

기호 명령어 설명
HALT TRAP x25 프로그램의 실행을 중단하고 콘솔에 메시지를 출력한다.
IN TRAP x23 콘솔에 프롬프트를 출력하고, 키보드로부터 문자 하나를 입력받아 R0[7:0]에 저장한다.
OUT TRAP x21 R0[7:0]에 저장된 문자 하나를 콘솔에 출력한다.
GETC TRAP x20 키보드로부터 문자 하나를 입력받아 R0[7:0]에 저장한다.
PUTS TRAP x22 R0에 저장된 메모리 주소에 존재하는 널 문자로 끝나는 문자열을 콘솔에 출력한다. 

 

1-2-2. Assembler Directive (= Pseudo-operation)

위에서 설명한 명령어와 달리, 실제 ISA의 어떤 명령어로 번역이 되는 부분은 아니다. 다만 어셈블러에게 특정 메시지를 전달하여 특정 정보를 최종적으로 생성할 오브젝트 모듈에 담도록 하는 부분이다. LC-3 ISA 기준으로 다음과 같은 것들이 존재한다.

 

종류 피연산자 의미
.ORIG 메모리 주소 프로그램의 시작 주소를 명시한다.
.END X 프로그램의 끝 주소를 명시한다.
.BLKW n n개의 메모리 location을 할당한다.
.FILL n 1개의 메모리 location을 할당하고 값을 n으로 초기화한다.
.STRINGZ 길이가 n인 문자열 (n+1)개의 메모리 location을 할당하고 널 문자로 끝나는 문자열로 초기화한다.

 

1-2-3. Comment

앞서 설명한 주석과 완전히 동일하다. 코드를 설명하여 가독성을 높이는 부분으로, 가독성을 높이는 역할을 수행한다. 참고로 주석뿐 아니라 여러 공백(White Space)들도 모두 번역 과정에서 무시된다.


1-3. 어셈블리어 프로그램 예시

 

2. 어셈블러의 번역 과정 (Assembly Process)

어셈블러는 어셈블리어로 작성된 코드를 읽어 들인 후, 해당 ISA의 명령어로 이뤄진 오브젝트 모듈을 생성한다. 이러한 번역 과정을 어셈블(Assembly)이라고 부른다. 어셈블러는 번역을 위해 어셈블리 코드 전체를 총 두 번 스캔한다. 첫 번째 스캔과 두 번째 스캔에서 각각 어셈블러가 무슨 일을 하는지 한 번 살펴보자.


2-1. 첫 번째 스캔 (First Pass) - 심볼 테이블 생성

어셈블리어 코드 전체를 위에서부터 아래로 스캔하면서, LABEL이 표시된 줄을 찾는다. 찾으면 그 LABEL이 어떤 메모리 주소에 해당하는지를 심볼 테이블(Symbol Table)이라는 곳에 기록한다. 어셈블러는 코드 첫 부분의 .ORIG에 명시된 메모리 주소를 기준으로 각 줄의 메모리 주소를 알아낼 수 있다. 참고로 주석만 존재하는 줄은 없는 줄로 간주한다. 이렇게 각 LABEL에 대응되는 메모리 주소를 알아낸 뒤에는, 다시 처음부터 코드를 스캔하면서 다음 과정을 진행한다.


2-2. 두 번째 스캔 (Second Pass) - ISA 명령어로 번역

첫 번째 스캔에서 만들어낸 심볼 테이블의 정보를 바탕으로, 어셈블리 코드를 ISA 명령어로 번역한다. 두 번째 스캔 과정에서는 각 어셈블리어 명령어에 적혀 있는 LABEL들이 어떤 메모리 주소인지 알고 있기 때문에 0과 1로 이뤄진 명령어로 번역할 수 있는 것이다.

 

3. 어셈블러, 링커, 로더의 관계 (Assembler, Linker, Loader)

3-1. 어셈블러 (Assembler)

어셈블러가 두 번의 스캔 과정을 거치면 0과 1로 이뤄진 오브젝트 모듈(Object Module)을 생성한다. 해당 오브젝트 모듈에는 프로그램의 기계어 코드와 데이터뿐 아니라, 프로그램의 시작 주소(.ORIG로 명시)와 심볼 테이블도 담긴다. 그러나 이렇게 생성된 오브젝트 모듈은 하나의 완전한 실행 가능한 파일이라고 보장할 수 없다. 하나의 프로그램이 여러 개의 오브젝트 모듈로 구성되었을 수도 있기 때문이다. 가령 시스템이 제공하는 라이브러리 모듈이나 다른 프로그래머가 작성한 모듈이 필요할 수도 있다.


3-2. 링커 (Linker)

한 프로그램을 구성하는 여러 오브젝트 모듈을 합쳐서 하나의 실행 가능한 파일(Executable Image)을 만들어주는 프로그램이 바로 링커(Linker)이며, 그 과정을 링킹(Linking)이라고 부른다. 다음과 같은 상황을 가정해보자. 모듈 A에 정의되어 있는 라벨 LABEL1를 모듈 B에서 사용한다면, 모듈 B에는 ".EXTERNAL LABEL1"와 같은 코드를 작성하여 어셈블러에게 모듈 B의 심볼 테이블에는 LABEL1에 대한 정보가 없다는 메시지를 전달해야 한다. 그러면 어셈블러는 모듈 B를 어셈블 할 때 우선 LABEL1의 값을 0으로 채워 넣어서 오브젝트 모듈을 생성하게 된다. 그리고 나중에 링커가 모듈 A와 모듈 B를 링킹 할 때 모듈 A의 심볼 테이블에서 LABEL1의 메모리 주소를 알아내어 모듈 B에서 0으로 채워 넣었던 부분을 수정함으로써 실행 가능한 파일 하나를 만들게 되는 것이다.


3-3. 로더 (Loader)

실행 가능한 파일을 실행시키는 운영체제의 프로그램이 바로 로더(Loader)이다. 하드디스크에 존재하는 실행 가능한 파일의 코드와 데이터를 메모리에 올리고, CPU의 제어를 해당 프로그램의 시작 주소로 옮겨줌으로써 프로그램을 실행하게 된다.