[CS] TIL 6. 컴파일링, 디버깅


  1. 컴파일링의 네 단계를 설명할 수 있습니다.
  2. 디버깅 하는 여러 방법을 설명할 수 있습니다.


boostcourse모두를 위한 컴퓨터 과학 (CS50 2019) 강의를 듣고 정리한 필기입니다. 😀


1. 컴파일링

[cs] TIL5까지는 아무것도 모른채로 마구잡이로 쓴 코드가 잘 돌아갔다면 이제부터는 연습과 응용을 통해 동작 원리를 이해할 것이다. 그렇기 위해서는 첫 수업(1월 4일 ~ 1월 10일 포스팅)에 봤던 예제를 다시 살펴보며 지금 사용하는 바업이 그때의 우리가 사용한 방법과 어떻게 다른지 알아볼 것이다.

#include <stdio.h>

int main(void)
{
    printf("hello, world\n")
}
  • main()

    첫주에 사용했던 C 코드를 살펴보면 main()을 프로그램의 시작점으로 사용하면 실행 버튼을 클릭하는 것과 같은 의미를 지닌다.

  • printf()

    main()의 중괄호에 있던 printf()는 출력을 담당하는 함수로써 최소 하나의 인자를 받는다. 보통은 큰 따옴표로 둘러싸인 “hello, world” 같은 문자열이다.

  • stdio.h

    printf()을 사용하기 위해서는 stdio.h라는 라이브러리가 필요하다. 라이브러리는 여러 함수들이 정의되어있는 것으로 누군가가 이미 작성해둔 코드이다. 정확히 말하면 stdio.h는 헤더 파일로 C언어로 작성되어있고, 파일명이 .h로 끝나는 파일이다.

    이와 같은 파일에서는 특정 함수의 프로토타입이 있어 Clang 컴파일러가 컴파일을 할 때, printf가 어떤식으로 돌아가야하는지 알려주는 역할을 한다.

    clang 컴파일 할 파일명.파일확장자
    ./a.out
    
  • 컴파일러

코드를 clang hello.c로 컴파일하고, ./a.out 명령으로 프로그램을 실행할 때 이 과정은 컴퓨터가 이해하는 0과 1로 가득한 파일을 생성하여 실행할 수 있게 한다.

문제는 a.out라는 파일은 파일명으로 하여금 파일의 정보를 짐작할 수 없다. 그래서 파일 내부 정보를 주기 위해 아래처럼 -o 지정하고 싶은 파일명라는 것을 컴파일시 같이 입력해준다. 이는 Clang의 명령줄 인자로써 실행옵션 중 하나이다. 생성되는 파일명을 지정하게 해준다.

clang -o hello hello.c


또 다른 예제를 살펴보자.

#include <cs50.h>
#include <stdio.h>

int main(void)
{
    string name = get_string("What's your name?\n");
    printf("hello, %s\n", name);
}
  • cs50.h

    여기서 우리는 cs50.h라는 라이브러리를 사용했다. 여기에는 string이라는 데이터 타입도 존재하고, get_string()이라는 데이터도 선언되어있다.

  • 변수 namestring, %s

    name은 사용자에게 입력을 받은 이름을 저장하는 변수이고, string은 이름을 저장한 변수의 종류이다. printf()에 있는 %s는 형식 지정자이다.

    그리고 이 모든 건 cs50.hstringget_string을 선언해서 가능한 일이다.

고작 해봤자 cs50라이브러리를 사용한 프로그램을 설명한 것이지만, 앞으로 사용할 수많은 함수가 사용되는 방식은 이와 같다. CS50 라이브러리를 사용한 프로그램을 컴파일 할 때는 clang에 또 하나의 프로그램(-lcs50)이 필요했다.

clang -o hello hello.c -lcs50

이는 clang에게 CS50 라이브러리에 있는 모든 0과 1들을 여기에 연결하라는 의미이다.

make hello

make를 이용해 clang보다 더 간단히 할 수도 있다. make 프로그램을 이용하면 이 모든 컴파일 과정을 자동을 처리할 수 있다.

makeclang을 사용해서 프로그램을 실행할 때 아래 네 개의 단계를 거친다.

  • 전처리
  • 컴파일링
  • 어셈블링
  • 링킹

1.1 전처리(Precompile)

#include <cs50.h>
#include <stdio.h>

int main(void)
{
    string name = get_string("What's your name?\n");
    printf("hello, %s\n", name);
}

위와 같은 소스 코드가 있을 때, 맨 위 두 줄은 두 라이브러리 파일을 추가하라고 말하고 있다. clang이나 make를 이용해서 이 프로그램을 실행하게 되면 #으로 시작하는 이 두 줄은 해당 파일(라이브러리)의 실제 코드로 대체된다. #include는 전처리기에게 다른 파일(라이브러리)의 내용을 코드에 포함하라고 알려주기 때문이다.

...
string get_string(string prompt);
int printf (string format, ...);
...

clangcs50.h에 직접 들어가 해당하는 코드를 위 소스 코드에 붙여넣는 것이다. 바로 밑에 줄인 stdio.h도 마찬가지이다. 즉, #으로 시작한 두 줄은 위 코드로 대체될 것이다. (간단히 나타낸 것이다) 실제로는 위아래로 수많은 코드들이 추가되었을 것이다.

즉, 전처리는 실질적인 컴파일이 이루어지기 전에 무언가를 실행하라고 알려주는 것이다.

1.2 컴파일(Compile)

...
string get_string(string prompt);
int printf (string format, ...);
...

int main(void)
{
    string name = get_string("What's your name?\n");
    printf("hello, %s\n", name);
}

컴파일링은 사실 소스 코드를 머신 코드로 바꾸는 단계로 앞으로도 이렇게 불리겠지만, 사실은 한 단계를 뜻한다. 프로그램이 컴파일을 할 때, 전처리 단계를 먼저 거친다. 1단계인 전처리 단계를 거치고 나면 위 코드처럼 된다. (맨 위 네개줄에 있는 색깔은 무시해달라….)

이렇게 전처리기가 전처리한 소스 코드를 생성하고 나면 그 다음 단계는 컴파일이다. 위 코드는 clang과 같이 컴파일러라고 불리는 프로그램은 C 코드를 어셈블리어라는 저수준 프로그래밍 언어로 컴파일한다.

title

이를 어셈블리 코드라고 하는데 사실 수십년 전까지만 해도 사람들은 이런 코드로 프로그래밍을 했다. 어셈블리는 C보다 연산의 종류가 훨씬 적지만, 여러 연산들이 함께 사용되면 C에서 할 수 있는 모든 것들을 수행할 수 있다.

title

이는 매우 어려워 보이지만, 노란색으로 강조된 부분만 보면 익숙한 부분도 존재한다. main이 위에 있고, 중간에 보면 get_string도 있고, printf()도 저 아래 존재한다. 즉, 코드가 clang에 의해 컴파일되면 C로 작성된 소스 코드는 어셈블리 코드라는 중간단계로 바뀌고 컴퓨터의 뇌인 CPU가 실제로 이해할 수 있는 언어에 조금 더 가까워진다.

title

노란색으로 강조된 부분을 명령어라고 부르는데 인텔이나 AMD와 같이 CPU를 만드는 여러 회사들의 CPU가 실제로 이해하는 것은 이러한 아주아주 기초적인 수준의 명령어들이다. 이러한 명령어 역시 메모리에서 뭔가를 옮기거나, 복사하거나, 읽거나 혹은 화면에 표시하는 일을 하지만, C보다 훨씬 이해하기 어려운 방식으로 수행한다.

컴파일이라는 용어는 소스 코드에서 오브젝트 코드로 변환하는 전체 과정을 통틀어 일컫기도 하지만, 구체적으로 전처리한 소스 코드를 어셈블리 코드로 변환시키는 단계를 말하기도 한다.

1.3 어셈블(Assemble)

소스 코드가 어셈블리 코드로 변환되면, 컴퓨터의 중앙처리 장치가 프로그램을 어떻게 수행해야 하는지 알 수 있는 명령어 형태인 연속된 0과 1로 이루어진 머신 코드(오브젝트 코드)로 바꿔야한다. 그게바로 clang이 수행하는 어셈블이다.

소스 코드에서 오브젝트 코드로 컴파일 되어야 할 파일이 딱 한 개라면, 컴파일 작업은 여기서 끝이 납니다. 그러나 그렇지 않은 경우에는 링크라 불리는 단계가 추가됩니다.

예시로 들어진 코드는 여러 파일들과 연관이 되어있다. cs50이나 stdio.h와 같은 라이브러리 파일 등이 말이다. 즉, clang은 3개의 다른 파일들을 컴파일해야한다. 만약 이를 위해 우리가 clang을 3번 실행해야 한다면, 아주 귀찮을 것이다. 다행이 딱 한번의 clang으로 모든 것이 해결된다. 즉, 예시 코드에는 링크라는 단계가 필요하다.

만약 프로그램이 (math.hcs50.h와 같은 라이브러리를 포함해) 여러 개의 파일로 이루어져 있어 하나의 오브젝트 파일로 합쳐져야 한다면 링크라는 컴파일의 마지막 단계를 거치게된다. 링크는 모든 0과 1들을 하나의 큰 파일로 합치는 것이다. clang hello.c를 터미널에 입력했을 때 새로 생기는 a.out처럼 말이다. 이때 C언어로 작성한 파일이나 CS50.c와 같은 소스코드는 컴퓨터의 하드 드라이브 어딘가에 있다. stdio.c 역시 하드 드라이브 어딘가에 있는데, 사실 사용하는 것은 stdio.c 안에 있는 printf.c라는 파일이다. 이렇게 작성한 코드, 라이브러리 두개(cs50과 stdio)를 각각 0과 1로 어셈블링하고, 이 세 개의 다른 오브젝트 코드 파일을 실행 가능한 하나의 오브젝트 코드 파일로 합친다.

아주 복잡하지만, 이게 지난 수십년간 소프트웨어 작성을 위해 사람들이 발전시켜온 것이다. 처음에는 0과 1로 프로그래밍을 하다가, 사람들이 작성하기에 조금 더 쉬운 어셈블리 언어를 거쳐, C나 C++, 파이썬과 같은 언어들이 생격난 것이다.


이 네 단계를 거치면 최종적으로 실행 가능한 파일이 완성왼다. 이제 이 모든 단계를 통틀어 컴파일이라고 하겠다. 코드를 컴파일 하겠다고 하면 위의 단계가 순차적으로 일어나고 있다고 생각해도 된다.



2. 디버깅

2.1 버그와 디버깅

title

이 디버깅이라는 단어의 유래가 된 어떤 일화가 있다. Grace Hopper이라는 아주 유명한 컴퓨터 과학자가 수년전 동료들과 함께 Mark 2 시스템이라는 것을 작성한 노트가 있다. Harverd Science Center에 가보면 건물 1층에 마주 큰 기계가 있는데 그것이 Mark1 이다. 그리고 Mark2는 말 그대로 벌려가 들어가 작동에 문제를 준다. 아마, 나방이었을 것이다. Grace Hopper는 이를 기록하며 벌레(bug)가 발견된 첫 사례라고 적었다. 물론 이전 사람들은 어떤 시스템의 문제를 가리켜 버그라고 표현 했지만, Mark2 안에 있었던 벌레, 이것이 바로 컴퓨터 과학자들 사이에서 내려오는 최초의 버그이다.

즉, 버그(bug)는 우리가 의도하지 않는, 코드에 들어있는 오류이다. 버그로 인해 프로그램의 실행에 실패하거나 프로그래머가 원하는 대로 동작하지 않게 된다. 버그를 만들고 싶지 않겠지만 모든 프로그래머들은 버그와 마주하게 되어있다. 디버깅(debugging)코드에 있는 버그를 식별하고 고치는 과정이다. 프로그래머는 디버거라고 불리는 프로그램을 사용하여 디버깅을 하게 된다.

2.2 디버깅의 기본

프로그램은 일반적으로 인간보다 훨씬 빠르게 연산을 수행한다. 그래서 프로그램을 실행시켜보는 것만으로는 무엇이 잘못됐는지 찾아내기 어렵다. 디버거는 프로그램을 특정 행에서 멈출 수 있게 해주기 때문에 버그를 찾는데 도움이 된다. 프로그래머는 멈춰진 그 지점에서 무슨 일이 일어나는지 볼 수 있기 때문이다. 프로그램이 멈추는 특정 지점중지점이라고 한다. 또한 프로그래머가 프로그램을 한번에 한 행씩 실행할 수 있게 해줍니다. 이로써 프로그래머는 프로그램이 내리는 모든 결정들을 단계별로 따라갈 수 있게 됩니다.

### 2.3 help50

아래 코드를 컴파일하고 실행한다고 생각해봅시다.

int main(void)
{
    printf("hello, world\n");
}

make 프로그램을 이용하여 컴파일해보면 “implicitly declaring library function ‘printf’” 이라는 에러 메시지가 나타날 것다. 이런 에러 메시지를 이해하기 힘들 때 사용하는 것이 help50 프로그램이다.

help50 make 파일이름

위와 같이 make 앞에 help50 을 붙여서 실행하면 다시 컴파일시 생기는 오류를 해석해준다.문제의 원인은 printf 함수를 사용하기 위해서 stdio.h 라이브러리를 포함해야 한다는 것이었죠.

2.4 printf

프로그램을 사용해서 해결할 수 없는 문제도 있다.

#include <stdio.h>

int main(void)
{
    for (int i = 0; i <= 10; i++)
    {
        printf("#\n");
    }
}

위 코드는 #을 10개 출력하기 위한 코드이다. 이 코드를 컴파일 하고 실행해보면 에러는 발생하지 않지만, 우리 의도와는 다르게 #이 11개나 출력된다. 왜 그럴까? 디버깅의 다른 방법으로 직접 의심이 가는 변수를 출력해서 확인해 볼 수 있다.

#include <stdio.h>

int main(void)
{
    for (int i = 0; i <= 10; i++)
    {
        printf("i is now %i: ", i);
        printf("#\n");
    }
}

우리가 출력하고 싶은 #과 함께 변수 i를 출력하겠다. 그 결과를 출력해 보면, i가 0 부터 시작하기 때문에 for루프의 i <= 10이라는 조건은 실제로 11번이 실행된다. 따라서 이를 i < 10 으로 수정해주면 우리 의도대로 #이 10번 출력되겠죠

2.5 debug50

CS50 IDE를 사용하면 debug50이라는 프로그램도 사용할 수 있다.

title

위와 같이 소스 코드에 직접 브레이크포인트를 지정하고 소스파일을 컴파일한 후에 debug50 파일명 으로 실행하면, 오른쪽 패널을 통해 변수의 값을 확인하거나 브레이크포인트부터 한 줄씩 코드를 실행한다.

디버깅 종료를 위해서는 Ctrl + c를 누르면 된다.




© 2020. by RIVER

Powered by RIVER