티스토리 뷰

입문

"hello, world"

Just4Fun 2016. 2. 16. 22:51

C언어를 배울 때 처음으로 작성해 보는 코드는 십중팔구 printf()를 이용하여 화면에 문자를 출력해보는 것으로 시작했을 것이다.  이때 printf() 안에 들어가는 내용은 "hello, world" 아니면 자기 이름이었을 것이다.  C언어뿐만 아니라 대부분의 컴퓨터 프로그래밍 언어는 "hello, world"와 같이, 문자를 출력하는 몇줄의 간단한 예제 코드로 설명을 시작한다.

이렇게 간단한 코드를 첫번째 예제로 사용하는 이유는, 작성된 소스코드를 컴파일러가 제대로 해석하여 실행파일을 성공적으로 만드는지, 생성된 실행 파일이 제대로 동작되는지를 빠르고 쉽게 검증해 보고자 하는 것이다.

비록 두 단어를 출력하기 위하여 작성되는 코드는 단 몇줄에 불과하더라도, 작성된 소스코드를 분석하여 프로그래머가 의도한 대로 정확히 동작 시키기 위해서는 눈에 보이지 않는 복잡한 과정을 거쳐야 한다.  즉, 화면에 정상적으로 문자가 출력된다는 것은 하드웨어와 소프트웨어 모두 정상적으로 동작하여 본격적인 프로그램 개발이 가능한 상태가 되었다는 것을 의미한다.

학교나 학원, 혹은 회사에서 강의를 통해서 프로그래밍 언어를 배울때는 강사가 시키는대로 따라하면 별 문제없이 제대로 된 결과를 확인할 수 있다. 하지만, 책이나 인터넷으로 독학하는 경우에는 소스코드를 작성하는 과정에서부터 어떻게 해야 될지 모르고 당황하게 되는 경우가 있고 분명히 하라는대로 따라해도 제대로된 결과가 나오지 않는 경우가 대부분이다.  왜냐하면, 정상 동작 한다는 것은 모든 것이 문제가 없는 단 하나의 경우에 해당되지만, 제대로 동작하지 않을 경우의 수는 무수히 많기 때문이다.

C언어를 만든 Dennis Ritchie가 직접 쓴 책인 "The C Programming Language"에도 'hello, world'를 화면에 출력하는 것을 제대로 할 수 있다면 나머지 것들은 이에 비하면 별거 아니라는 것임을 다음과 같은 글로써 표현하고 있다.

This is a big hurdle; to leap over it you have to be able to create the program text somewhere, compile it successfully, load it, run it, and find out where your output went. With these mechanical details mastered, everything else is comparatively easy.

이번글에서는 'C'소스코드 작성에서부터 컴파일 과정을 거쳐 화면에 글자가 출력되는 각 단계를 간략하게 설명하겠다. 이 과정을 이해하면 앞으로 이어지게 될 임베디드 시스템 프로젝트를 수행할 때 많은 도움이 될 것이다.

 

  • 소스 코드 작성

요즘은 거의 대부분의 개발 환경이 Visual Studio나 Eclipse와 같은 GUI 기반의 통합개발환경(IDE: Integrated Development Environment)을 사용하므로 소스 코드 작성에서의 어려움은 없을 것이다.  하지만 코딩따로, 컴파일따로, 실행따로인 상황에서는 소스코드를 제대로 작성하는것부터 배워야 했다.

/* hello.c */
#include <stdio.h>
 
void main()
{
    printf("hello, world\n");
}

"hello, world"를 출력하는 C 프로그램의 소스코드는 다음과 같다.

너무나 당연한 얘기이겠지만 이런 코드를 작성하는 목적은 프로그래머가 이해하기 위해서 작성하는것이 아니고, 컴파일러가 읽어서 이해할 수 있도록 작성하는 것이다.  만약 위의 코드를 MS워드로 작성하였다면 사람의 눈으로 보이는 모습은 별 다를게 없겠지만 컴파일러는 전혀 다른 내용으로 읽어 들인다.  컴파일러가 코드를 이해할 수 있도록 하기 위해서는 메모장이나 소스코드 전용 에디터를 이용하여 ASCII 문자로만 작성되어야 한다.

위의 코드를 hello.c라는 이름의 파일로 만들었다면 실제 하드디스크에 저장되는 파일은 다음과 같은 데이터로 저장된다.

위의 그림에서와 같이 '#'은 0x23으로 'i'는 0x69라는 값으로 표현되어야지만 컴파일러가 제대로 소스코드를 이해할 수 있다.  컴파일러는 이렇게 생긴 파일을 분석하여 main이라는 이름의 함수가 있고, 그 안에서 printf라는 다른 함수를 부르는구나라고 이해하게 되는 것이다.

 

  • 전 처리과정(Pre-processing)

전 처리과정은 간단히 설명하면 소스코드 내에 있는 '#'으로 시작되는 모든 것을 실제 컴파일 과정이 진행되기 이전에 미리 처리 한다는 뜻이다.  위의 예에서 보이는 것처럼

#include <stdio.h>

는 stdio.h파일을 읽어서 그 위치에 삽입하라는 것이다.  <> 안에 있는 파일은 컴파일러에 미리 설정되어 있는 디렉토리를 검색하여 그 안에서 읽어 온다.  include 파일이 " "로 지정되면 현재 c파일이 위치한 곳에서 파일을 찾아서 읽어 들인다.

이 과정에는 파일 삽입 기능과 함께 또 한가지 더 처리하는 기능이 있다.  #define으로 선언 되어 있는 것들이 소스 안에 있으면 모두 치환하는 동작을 수행한다.  이러한 과정이 모두 끝나면 .c 파일은 .i 파일로 변환 된다.

 

C 컴파일러는 전처리 과정을 거친 .i 파일을 가지고 본격적인 컴파일을 진행한다.

 

  • 컴파일

컴파일 과정은 앞 단계에서 생성된 .i파일을 컴파일 하여 어셈블 파일인 .s 파일을 생성하는 동작을 수행한다.  컴파일 과정은 크게 전단부(front-end), 중단부(middle-end), 후단부(back-end)로 세분화 된다.  각 단계에서 수행하는 기능은 다음과 같다.

1. 전단부

전단부에서 하는 일은 소스코드에 오류가 없는지 검사하여 이상이 없으면 소스코드를 트리 구조로 변경하는 작업을 수행한다.

2. 중단부

중단부에서는 CPU 종류와 무관한 프로그램 최적화 작업을 수행하여 프로그램이 가장 효율적으로 동작될 수 있도록 한다.  예전에는 컴파일러 자체 성능이 좋지 않아 최적화를 제대로 수행하지 못하였다.  따라서 성능 향상을 위하여 일부 소스코드를 어셈블러로 작성하기도 했다.  하지만 최근의 컴파일러는 프로그래머가 생각하는 이상으로 좋은 성능의 실행 코드를 만들어주므로 왠만해서는 프로그래머가 직접 어셈블러로 코드를 작성하는 일은 거의 없게 되었다.

3. 후단부

후단부에서는 중단부에서 수행한 CPU 비종속적인 최적화 결과를 받아서 CPU 종속적인 최적화 작업을 수행한다.  그리고 모든 최적화 작업이 완료되면 CPU에서 사용가능한 명령어로 된 어셈블 코드를 생성한다.  어셈블 코드는 CPU마다 다르게 생성되어야 하므로 컴파일러를 특정 CPU용으로 만들어진것을 사용하여야 한다.  즉, Visual Studio로 AVR CPU에서 동작되는 실행파일을 만들 수 없다는 뜻이다.

hello.c 의 x86 CPU용 컴파일을 사용하여 생성된 어셈블 코드는 다음과 같이 생성된다.

같은 코드를 AVR GCC 컴파일러를 사용하여 생성된 어셈블 코드는 x86 CPU와는 다르게 생성되었음을 알수 있다.

 

  • 목적코드 생성

컴파일 과정의 최종 생성파일인 어셈블 코드를 기계어로 변환하는 과정을 수행하여 목적코드인 .o 파일을 생성한다.  앞단계에서 만들어진 어셈블 코드가 어떻게 기계어로 변환되었는지 아래 그림을 보면 알 수 있다.

push %ebp라는 어셈블 코드가 기계어로는 0x55로 변환되었다.  목적코드 생성 과정에서는 어셈블 코드를 기계어로 변환 시켜줄뿐만 아니라 섹션, 혹은 세그먼트라고 불리워지는 같은 특성을 갖는 내용들을 분류하는 작업도 수행한다.  위의 그림을 보면 .text라는 섹션과 .rodata라는 섹션을 볼 수 있다.  .text섹션은 코드 섹션이다.  즉 CPU가 수행해야 되는 명령어들을 모아 놓은 섹션이다.  .rodata섹션은 .data 섹션의 일부로써 read only data들만 모아 놓은 섹션이다.  주로 printf와 같은 함수에서 사용하는 문자열 같은 것이 여기에 포함된다.  위의 그림에 보이는 .rodata섹션을 잘 보면 68 65 6c라고 되어 있는 것을 볼 수 있는데 이것은 명령어 코드가 아니고 이 글 맨 앞에 설명된 hello.c 파일의 그림에서 보이는 printf안에 있는 문자열에 대한 헥사값 표현된 값인 것을 알 수 있다.  섹션에는 대표적으로 text섹션, data섹션, bss섹션이 있다.  각각의 섹션의 특성에 대해서는 심화 과정에서 자세하게 다룰 예정이므로 여기에서는 그냥 실행 파일이 여러개의 섹션으로 이루어 진다는 것만 알고 있어도 된다.

참고로 AVR 컴파일러에 의하여 생성된 어셈블 코드를 기계어 코드로 변환한 내용은 다음 그림과 같이 나타난다.

 

목적코드는 바이너리 데이터이므로 에디터로는 직접 읽어 볼수 없고 readelf라는 프로그램을 이용하면 각각의 섹션들이 어떤 특성을 가지고 있는지 간단한 정보를 알수 있다.

hello.c 파일의 목적코드인 hello.o 파일은 다음과 같은 섹션으로 이루어진 것을 알 수 있다.

 

  • 링크

컴파일 과정이 모두 끝나면 목적파일과 라이브러리 파일을 이용하여 최종 실행파일을 만드는 링크 과정을 수행한다.

"hello, world"를 출력하는 프로그램은 hello.c 파일 하나만을 가지고 프로그램을 만들지만 실제 프로젝트에는 이런 경우는 잘 없고 수십개에서 수백개 이상의 c파일을 가지고 하나의 실행 파일을 만든다.  이런 경우 각각의 기능을 디렉토리로 세분화하여 개발하는데, 일단 디렉토리 안에 있는 .c 파일을 .o파일로 컴파일하고, 다시 디렉토리내에 생성된 모든 .o파일을 합쳐서 .a 파일로 만든다.  각 디렉토리에 있는 .a파일들과 컴파일러에서 제공하는 공용 라이브러리를 합쳐서 최종 결과물을 만드는게 일반적이다.

hello.c 파일의 main함수 안에서 printf함수를 부르는데, printf함수는 소스내에 포함되어 있지 않고 컴퓨터의 라이브러리 디렉토리에 있는 libc.a 안에 바이너리 형태로 포함되어 있다.  흔히 C언어를 처음 배울때 printf와 같은 C에서 제공하는 라이브러리 함수가 stdio.h 파일처럼 헤더파일 안에 있는 것으로 착각하는데, stdio.h 안에는 printf함수가 어떤 모양으로 생겼는지만 알려주는 function proto-type 코드가 들어 있을 뿐이다.  컴파일러는 단지 main 함수 안에서 printf함수는 부른다는 것만 표시해 놓으면 링크 과정에서 libc.a에 있는 printf함수의 코드를 가지고 온다.

링크 단계에서 라이브러리를 참조할 때 두가지 방식중에 하나를 이용한다.  하나는 정적라이브러리를 이용하는 것이고 또 다른 하나는 동적 라이브러리를 이용하는 것이다.

예를 들면 printf함수의 경우 정적라이브러리를 이용하면 실행 파일안에서 printf함수의 코드가 포함되게 된다.  그러면 자연히 실행파일의 크기가 증가하게 되는 단점이 있지만 성능상의 이점이 있다.  이에 반해 동적라이브러리의 경우, printf함수는 거의 대부분의 프로그램에서 사용되는 함수이므로 실행파일 안에 포함시키지 않고, 프로그램이 실행 될 때 OS를 통해서 참조하게 할 수도 있다.  그러면 굳이 모든 실행파일안에 하나씩 printf함수를 포함하지 않아도 되므로 코드 사이즈를 줄일 수 있다.  게다가 printf함수 자체가 변경되더라도 개별 실행 파일을 수정할 필요가 없는 장점이 있다.

OS가 있는 환경에서 실행 파일을 개발할 경우에는 정적라이브러리와 동적라이브러리 중 어떤 라이브러리를 이용하는게 좋을지 잘 판단해서 개발하면 되겠지만, AVR과 같이 OS가 없는 환경에서 프로그램을 개발할 때에는 무조건 정적라이브러리를 사용하여 실행파일 내에 라이브러리 코드가 포함되도록 만들어야 한다.

최종 생성되는 실행파일의 형식은 OS나 컴파일러에 따라 다르게 생겼는데, 임베디드 시스템용 실행 파일은 ELF파일 형태로 많이 만들어 진다.  임베디스 시스템 개발하다 보면 ELF파일을 분석해서 필요한 동작을 해야 하는 경우가 있으므로 어떤 구조로 생겼는지 한번쯤 공부해 두는 것이 좋다.

 

  • 실행

컴파일러를 이용하여 실행파일인 hello를 생성했으면 실행 시켜보자.  문제없이 화면에 "hello, world"가 출력되는것을 확인할 수 있다.  hello를 실행 시키면 shell 프로그램이 OS에게 hello를 실행 시킬것을 알려준다.  OS에서는 hello라는 파일을 분석하여 각 섹션에 들어 있는 데이터를 메모리에 복사하여 프로그램의 시작 주소에서부터 프로그램이 동작 되도록 한다.  이 글에서 사용된 hello라는 프로그램은 printf함수안에 문자열만 있으므로 내부적으로 puts함수를 사용하여 hello 가 실행된 터미널로 문자열을 출력해 준다.  이러한 모든 것들이 대부분 OS에서 처리해 주기 때문에 실제 프로그래머나 사용자는 어떤 과정을 거쳐 화면에 글자들이 나타나는지 굳이 알 필요는 없다.  하지만 AVR CPU를 이용하여 "hello, world"를 화면에 출력하기 위해서는 많은 것을 알고 있어야 한다.

 

지금까지 기본적인 컴파일 과정과 프로그램이 실행되는 과정에 대해서 비교적 간단하게 소개하였는데 임베디드 시스템 프로그램을 하기 위해서는 이 정도의 지식을 가지고 있는 것이 좋다.  혹시라도 문제가 발생하였을때 어떤 식으로 문제를 찾아내고 해결해야 되는지 많은 도움이 되기 때문이다.

 

 

입문 과정 목차

'입문' 카테고리의 다른 글

LED 제어 1  (3) 2016.02.27
AVR에 실행 파일 다운로드  (8) 2016.02.26
개발환경 구축  (8) 2016.02.21
하드웨어 준비  (9) 2016.02.21
입문 과정 목차  (5) 2016.02.16
댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG
more
«   2025/03   »
1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30 31
글 보관함