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

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

[Chapter 11] Introduction to Programming in C - 고급 언어의 세계로

피그브라더 2020. 2. 19. 00:03

이제 우리는 어셈블리어로 작성된 프로그램이 어떠한 원리로 CPU에 의해 실행이 되는지 이해할 수 있는 수준이 되었다. 그러나 실제로 프로그래밍을 할 때 어셈블리어를 쓰는 사람은 거의 없다. 대부분의 프로그래머는 C 언어나 파이썬과 같은 고급 언어를 사용하여 프로그래밍을 한다. 따라서 이번 포스팅에서는 어셈블리어보다 한층 더 추상화된 고급 언어에 대한 기본 개념을 알아보고, 대표적인 고급 언어에 해당하는 C 언어와 관련한 내용도 간단하게 살펴볼 것이다. 우리의 최종 목표는 C 언어로 작성된 프로그램이 어떻게 어셈블리어로 번역이 되어 실행으로까지 이어지는지 이해하는 것이다.

 

1. 고급 언어의 특징

C 언어와 같은 고급 언어들은 어셈블리어나 ISA와 비교했을 때 어떠한 특징을 가지고 있을까? 몇 가지만 간단하게 알아보자.

 

첫째, 변수에 이름을 붙여서 사용한다. 즉 어떤 레지스터를 사용하는지, 어떤 메모리 공간을 사용하는지 알 필요가 없다. 예를 들어 count라는 이름을 가진 변수를 사용한다면, 이는 내부적으로 특정 레지스터 혹은 메모리 공간에 해당할 것이다. 하지만 우리는 count라는 이름을 붙여서 사용하기 때문에 그것이 내부적으로 무엇인지까지 알 필요가 없어진다.

 

둘째, 기저가 되는 하드웨어(CPU)를 추상화한다. 고급 언어는 CPU의 ISA 체계에 의존하지 않는다. 예를 들어 LC-3의 ISA에는 곱셈 명령어가 없으므로 곱셈을 구현하려면 덧셈 명령어를 활용해야 한다. 하지만 우리가 직접 구현할 필요는 없다. 고급 언어에서 곱셈 기호를 사용하면 내부적으로 CPU의 ISA 체계에 따라 적절하게 곱셈이 구현된 형태의 기계어로 번역이 된다. 따라서 우리는 CPU의 종류와 상관없이 해당 고급 언어에서 곱셈 연산을 사용할 수 있다는 것만 알면 된다. 내부적으로 그것이 어떻게 구현되었는지는 관심 밖인 것이다.

 

셋째, 표현성과 가독성이 탁월하다. 어셈블리어보다 인간이 사용하는 자연어에 가까운 키워드를 사용하기 때문에, 의미를 전달하는 것에 있어 보다 명확하며 코드를 읽기에도 이해하기 더욱 쉽다. 예를 들어, 조건 분기를 프로그래밍할 때 사용하는 if-else 구문은 인간이 사용하는 자연어(영어)의 구조와 아주 유사하여 그 의미를 더욱 쉽게 받아들일 수 있다.

 

넷째, 버그에 대해 더욱 안전하다. 컴파일 혹은 런타임 시에 만족해야 하는 특정 규칙이나 조건들을 약속함으로써 잘못된 프로그래밍으로 인한 문제 발생을 막을 수 있다. 예를 들어, 존재하지 않는 함수를 사용할 경우 컴파일 자체가 되지 않도록 하여 문제 발생을 사전에 예방할 수 있다.

 

2. 컴파일러, 인터프리터

2-1. 컴파일러 (Compiler)

고급 언어로 작성된 코드들 전부를 CPU가 이해할 수 있는 ISA 체계의 기계어로 번역하여, 실행 가능한 파일을 만들어 낸다. 즉 해당 프로그램을 실행시키는 것이 아니라, 해당 프로그램을 실행할 수 있게 하는 실행 파일을 만들어 내는 것이다. 한 번에 모든 코드를 읽고 번역을 하는 것이기 때문에, 똑똑한 컴파일러의 경우 최대한 적은 수의 기계어로 번역이 되도록 최적화하는 것도 가능하다. 실행 파일이 실행되는 시점에는 기계어를 CPU가 그대로 읽고 실행하기만 하면 되므로 프로그램의 속도가 상대적으로 빠르다. 다만 코드의 한 부분이라도 수정하면 코드 전부를 다시 컴파일해야 한다는 단점이 있다.


2-2. 인터프리터 (Interpreter)

컴파일러와 달리 인터프리터는 고급 언어로 작성된 코드들을 한 줄 한 줄 읽으면서 번역하고 실행하기를 반복한다. 즉 인터프리터는 프로그램 코드들을 실행시키는 또 하나의 프로그램이라고 볼 수 있다. 인터프리터로 실행되는 프로그램의 경우, 읽는 즉시 실행하는 것이 아니라 번역이라는 과정을 먼저 거쳐야 하므로 컴파일된 프로그램보다는 상대적으로 속도가 느리다. 그러나 한 줄 한 줄 읽으면서 값이 어떻게 바뀌는지 등을 살펴보기 쉬우므로 디버깅에 있어서는 조금 더 수월하다고 볼 수 있다.

 

3. 컴파일

컴파일 언어로 작성된 프로그램이 실행 가능한 파일로 만들어지기까지 대략 세 단계의 과정을 밟게 된다. 첫째는 선행 처리기에 의한 선행 처리, 둘째는 컴파일러에 의한 컴파일, 마지막은 링커에 의한 링킹이다. 엄밀히 말하면 컴파일은 두 번째 단계만을 가리키지만, 통상적으로는 이러한 과정 전체를 컴파일이라고 부른다. 또한 선행 처리, 컴파일, 링킹을 모두 수행해주는 프로그램을 그냥 컴파일러라고 부르기도 한다. 각각의 단계가 대략적으로 무엇인지 한 번 짚고 넘어가자.

 


3-1. 선행 처리 (Preprocess)

선행 처리기(Preprocessor)에 의해 수행되는 작업으로, 단순히 C 언어 소스 코드의 특정 부분들을 정해진 규칙에 의해 치환하는 역할만 수행한다. 따라서 선행 처리에 의해 만들어지는 결과물도 여전히 C 언어 소스 파일이다.


3-2. 컴파일러 (Compiler)

선행 처리된 C 언어 소스 파일들을 분석하여 CPU의 ISA 체계에 맞는 기계어 코드로 이뤄진 오브젝트 파일을 만들어 낸다. 컴파일러도 어셈블러와 비슷하게 대략 두 단계를 거쳐서 컴파일을 진행한다. 첫 번째 단계는 소스 코드를 분석하여 프로그램을 구성하는 변수, 함수 등의 조각들을 파악하여 심볼 테이블을 구성하는 작업으로, CPU의 종류와는 무관한 단계이다. 두 번째 단계는 그렇게 만든 심볼 테이블의 정보를 바탕으로 소스 코드를 기계어로 번역하는 작업으로, CPU의 ISA 체계에 맞게 번역해야 하므로 당연히 CPU의 종류에 의존적인 단계이다.


3-3. 링커 (Linker)

컴파일된 오브젝트 파일들(라이브러리 포함)을 서로 연결하여 하나의 실행 가능한 파일을 만들어 낸다.

 

4. C 언어 프로그램 예제

C 언어로 작성된 아주 간단한 프로그램 하나를 살펴보며 관련된 내용에 대해 맛만 보도록 하자.

 


4-1. 선행 처리 문 (Preprocessor Directives)

#으로 시작하는 것들이 선행 처리기에 의해 선행 처리되는 대상에 해당한다. "#include <stdio.h>"는 stdio.h라는 헤더 파일의 내용을 그대로 가져다 붙이라는 의미이다. 뒤에서 살펴볼 표준 입출력 함수(printf, scanf 등)를 사용하기 위해서는 이 파일을 반드시 포함시켜줘야 한다. "#define STOP 0"은 소스 코드 내에 등장하는 STOP이라는 문자를 전부 0이라는 문자로 치환하라는 의미이다.


4-2. 변수 선언 (Variable Declaration)

변수는 사용하고자 하는 값이 위치하는 공간에 이름을 붙이는 것을 말한다. 각 변수는 타입(Type)을 가지게 되는데, 이는 해당 데이터가 어떻게 해석되어야 하는지, 얼마만큼의 공간을 차지하는지 등을 컴파일러에게 알려주는 역할을 수행한다. 타입과 차지하는 공간 등을 정확히 알아야 그에 맞게 올바른 연산을 수행하고 적합한 메모리 공간을 할당하는 기계어로 번역을 할 수 있기 때문이다. int는 C 언어의 대표적인 내장 타입으로, 정수를 표현할 때 사용된다.


4-3. 표준 입출력 함수 (Standard Input and Output Function)

printf는 C 언어에서 제공하는 표준 출력 함수, scanf는 표준 입력 함수이다. 해당 함수들을 사용하려면 위에서 언급했듯이 stdio.h 파일을 반드시 포함시켜야 한다. %d는 입력 및 출력 시에 사용하는 서식 문자로, 입력하거나 출력할 데이터의 형식을 나타낸다.