본 포스팅은 인프런의 개발자를 위한 컴퓨터공학 1: 혼자 공부하는 컴퓨터구조 + 운영체제를 참조하여 작성한 글입니다.
아래의 코드가 있다고 해보자.
#include <stdio.h>
void main()
{
printf("Hello World!");
}해당 C언어 코드가 과연 컴퓨터가 알아들을 수 있을까? 물론 이 언어 그 자체로는 컴퓨터는 알아듣지 못한다.
근데 우리가 코드를 실행하면 잘 결과가 나오는데 알아듣는거 아니에요?
그것은 이 자체로 컴퓨터가 언어를 알아듣지 않고 변환과정을 거쳐서 컴퓨터가 알아 들을 수 있는 명령어로 변환하기 때문이다. 그럼 이에 대해 한번 알아보자.
우리가 프로그램을 만들 때 사용하는 프로그래밍 언어는 컴퓨터가 이해하는 언어가 아닌 사람이 이해하고 작성하기 쉽게 만들어진 언어이다. 컴퓨터는 이 언어를 이해하지 못한다. 이렇게 사람을 위한 언어를 고급 언어라고 표현한다. 반대로 컴퓨터가 직접 이해하고 실행할 수 있는 언어를 저급 언어라고 표현한다. 그리고 이 고급 언어를 저급 언어로 변환해야 컴퓨터가 이해하고 실행을 하는 것이다. 그럼 한번 정리해보겠다.
그러면 저급 언어의 기계어부터 한번 살펴보자. 기계어는 다음과 같이 0과 1로 구성된 언어이다.
컴퓨터는 위와 같이 구성이 되어야 실행을 해주는 것이다. 물론 위와 같은 2진수를 16진수로도 표현해도 컴퓨터가 해석하고 실행을 해준다. 하지만 위와 같은 형태를 사람이 해석하기에는 매우 어렵다. 그래서 그나마 사람이 읽기 편한 형태로 번역한 언어가 존재하는데 바로 그것을 어셈블리어라고 표현한다.
LOADA 15
SUB 14 // 피제수 -= 제수
JMPC 7 // 피제수가 제수보다 크면 RAM의 7번 주소로 점프
JMPZ 7 // 피제수와 제수가 같으면 RAM의 7번 주소로 점프
LOADA 13
OUT
HLT
STOREA 15 // 현재 레지스터A의 값을 피제수로 업데이트
LOADI 1 // 몫 += 1
ADD 13
STOREA 13
JMP 0
0 // 몫
3 // 제수
7 // 피제수(결과는 나머지)그러면 이제 고급 언어를 살펴보도록 하겠다. 개발자들이 고급언어로 작성한 소스코드는 결국 저급 언어로 변환되어 실행이 되어야 한다. 그러면 고급 언어는 어떻게 저급언어로 변환을 할까? 여기에는 크기 2가지 방식이 존재한다. 컴파일방식과 인터프리터방식이 존재한다. 컴파일 방식으로 작동하는 프로그래밍 언어를 컴파일 언어, 인터프리터 방식으로 작동하는 프로그래밍 언어를 인터프리터 언어라고 표현한다.
컴파일 언어는 컴파일러에 의해 소스 코드 전체가 저급 언어로 변환이 된다. 즉, 컴파일 언어로 작성된 소스 코드 전체가 저급 언어로 변환되는데 이 과정을 컴파일이라고 한다. 그리고 이런 컴파일을 해주는 장치를 컴파일러라고 한다. 컴파일러는 개발자가 작성한 소스 코드 전체를 쭉 훓어보며 소스 코드에 문법적인 오류는 없는지, 실행 가능한 코드인지 불필요한 코드는 없는지 검사를 진행하고 소스코드 처음부터 끝까지 저급 언어로 컴파일을 한다. 그리고 컴파일러를 통해 저급 언어로 변환된 코드를 목적 코드라고 한다. 컴파일 언어는 소스 코드 전체를 훓어서 만약 오류가 발견하면 실행을 하지 않고 그 자체로 중단한다.
다음으로 인터프리터 언어를 살펴보자. 인터프리터 언어는 인터프리터에 의해 한줄씩 실행을 한다. 소스코드 전체가 저급언어로 변환될 때까지 기달리지 않고 한줄씩 실행을 한다. 그러다가 에러가 발생하면 실행 도중에 중단을 한다. 그리고 이렇게 인터프리터 언어를 저급 언어로 변환해주는 장치를 인터프리터라고 표현한다.
그러면 이런 생각이 들 것이다. 과연 컴파일 언어와 인터프리터 언어는 칼로 자르듯이 명확하게 구분할 수 있는 개념일까? C, C++과 같이 명확하게 구분할 수 있는 언어도 있으나 현대의 많은 프로그래밍 언어 중에는 컴파일 언어와 인터프리터 언어 간의 경계가 모호한 경우가 많다. 대표적으로 인터프리터 언어로 알려진 Python도 컴파일을 하지 않는 것은 아니며 Java의 경우 저급 언어가 되는 과정에서 컴파일과 인터프리트를 동시에 수행한다.
이번에는 하나의 명령어를 자세히 들여다보면서 연산 코드, 오퍼랜드, 주소 지정 방식이라는 개념에 대해 학습해보도록 하겠다.
우리가 누구한테 명령을 한테 이렇게 말할 것이다.
"철수야, 노트북좀 가져와줄래?"
컴퓨터도 마찬가지다. 명령어는 무엇을 대상으로, 어떤 동작을 수행해라라는 구조로 되어 있다. 명령어는 연산 코드와 오퍼랜드로 구성되어 있다. 연산 코드는 MSB 포함한 비트들을 나타내고 나머지가 오퍼랜드라고 봐도 무방하다. 연산 코드는 수행할 연산을 의미한다. 그리고 연산에 사용할 데이터를 오퍼랜드라고 의미한다.
오퍼랜드에는 실제 수행할 데이터가 직접적으로 들어가져 있는 경우도 존재하면 해당 데이터가 있는 메모리 주소 값을 표현하기도 한다. 또한 명령어 종류에 따라 오퍼랜드가 하나도 없을 경우도 있고 많으면 3개인 경우도 존재한다. 이럴때 오퍼랜드가 하나도 없는 명령어를 0-주소 명령어라고 하고, 오퍼랜드가 하나인 명령어를 1-주소 명령어, 두 개인 명령어를 2-주소 명령어, 세 개인 명령어를 3-주소 명령어라고 말한다.
오퍼랜드에는 실제 데이터 혹은 메모리의 주소가 들어 있을 수 있는데 많은 경우 메모리의 주소가 들어가져 있다. 이 때문에 우리는 오퍼랜드를 주소 필드라고도 부른다.
다음은 연산 코드를 살펴보도록 하겠다. 연산 코드의 종류는 매우 많지만 가장 기본적인 연산 코드 유형은 크게 4가지로 존재한다.
연산 코드에는 정말 많은 코드들이 존재하는데 아래 정리를 해두었으니 암기는 하지 말고 한번 이해해보도록 하자.
명령어의 오퍼랜드 필드에 메모리나 레지스터의 주소를 담는 경우가 많다. 그런데 아래와 같은 의문이 들 수도 있다.
왜 오퍼랜드 필드에 메모리나 레지스터의 주소를 담는가? 그냥 사용할 데이터를 직접적으로 담으면 안되나?
바로 명령어 내에서 표현할 수 있는 데이터 크가가 제한되기 때문이다. 직접적으로 저장되면 오퍼랜드가 할당된 크기밖에 데이터들을 표현을 못하지만 주소 값으로 하면 더 확장되기 때문에 해당 방식들을 많이 채택한다. 또한 연산 코드에 사용할 데이터가 저장된 위치, 즉 연산의 대상이 되는 데이터가 저장된 위치를 유효 주소라고 한다.
이런 명령어 주소 지정 방식에는 여러가지가 존재한다. 일단 명령어 주소 지정 방식이란 연산에 사용할 데이터가 저장된 위치를 찾는 방법을 말한다. 즉, 유효 주소를 찾는 방법이다. 해당 방식은 다양한 명령어 주소 지정 방식들이 아래와 같이 존재한다. 한번 살펴보자.
그러면 우리는 C언어를 통해서 소스코드가 명령어가 되기까지의 과정을 한번 살펴보도록 하겠다. 아래의 C언어 코드가 있다고 하자.
#include <stdio.h>
int main()
{
printf("Hello World!\n");
return 0;
}C언어는 컴파일 언어이기 때문에 컴파일 과정을 거쳐서 실행파일이 된다. 그런데 엄밀히 과정을 나타내면 아래와 같은 과정을 거친다.
그러면 과정 하나씩 살펴보도록 하겠다. test.c파일이 있다고 해보자. 해당 과정을 전 처리과정을 전처리기를 통해 거치면 test.i라는 파일이 된다. 전 처리과정에서 하는 것은 다음과 같다.
이렇게 test.i파일이 된 코드를 컴파일러를 통해서 컴파일을 한다. 컴파일 과정에서 다음과 같은 과정을 거친다.
이렇게 test.i가 컴파일 과정을 통해 test.s라는 어셈블리 언어 파일이 되었다. 이제 해당 파일을 어셈블 과정을 거쳐야 한다.
그리고 마지막으로 링킹 과정을 통하여 최종 실행파일이 된다. 링킹 과정에서는 다른 c언어 파일이라던지 외부 라이브러리들을 연결 시켜주는 작업을 한다.
목적 파일과 실행파일의 차이는 무엇일까? 목적 파일과 실행 파일은 둘다 기계어로 이루어진 파일이다. 하지만 목적 파일과 실행 파일은 다르다. 목적 파일은 링킹을 거친 이후에 실행 파일이 된다.