소스코드는 이러한 과정을 통해서 컴파일 또는 빌드가 되게 됩니다.
gcc의 경우 -save-temps 옵션을 주고 빌드를 하게 되면 아래와 같이 여러 가지 빌드 중 생긴 파일이 나오게 됩니다.
test.c 는 소스파일입니다.
test.i 는 전처리기를 거친 소스파일입니다.
test.s 는 소스코드를 어셈블러가 어셈블리어로 컴파일 한 파일입니다.
test.o 는 어셈블리어를 기계어로 컴파일한 파일입니다.
a.out 은 test.o 를 실행 가능하게 만든 파일입니다.
전처리기
코드를 보면
#include <stdio.h>
#define test "test"
#pragma once()
같은 앞에 #이 붙은 것들을 전처리 문이라고 부릅니다.
전처리 문의 종류는 여러 가지가 있는데
- 파일 처리를 위한 전처리문 : #include
- 형태 처리를 위한 전처리문 : #define, #undef
- 조건 처리를 위한 전처리문 : #if, #ifdef, #else, #elif, #endif
- 에러 처리를 위한 전처리문 : #error
- 디버깅을 위한 전처리문 : #line
- 컴파일 옵션 처리를 위한 전처리 문 : #pragma
이렇게 있습니다. 하지만 이 글은 실행파일을 만드는 과정을 설명하는 글이기 때문에 include 만 간단히 보고 넘어가도록 하겠습니다.
나머지는 나중에 시간이 남게 된다면 한번 써보도록 하겠습니다.
#include
헤더 파일과 같은 외부 파일을 코드에 포함시키도록 합니다.
위와 같은 코드를 작성하고, gcc에서 -save-temps 옵션을 주고 빌드를 해보도록 하겠습니다. 그다음 test.i 파일을 확인해보겠습니다.
저희가 작성한 코드 위로 stdio.h 의 내용이 추가가 되었습니다.
이게 전처리기에서 #include 전처리문이 하는 역할입니다.
컴파일러
어휘 분석기 -> 구문 분석기 -> 의미 분석기 -> 중간 코드 -> 독립적 코드 최적화 -> 코드 생성기 -> 의존적 코드 최적화 -> 어셈블리어
■: 소스코드를 분석합니다.
■: 분석한 소스코드를 토태로 cpu에 독립적인 중간 코드를 만들고 최적화를 진행합니다.
■: 중간코드를 cpu에 의존적인 어셈블리어로 만들고 최적화를 진행합니다.
이렇게 코드 분석, 코드 생성, 최적화가 끝난 어셈블리어 코드는 어셈블러에서 기계어로 변경하게 됩니다.
어셈블러
어셈블러에서는 빌드하도록 지정된 각 ISA의 명령어 포맷에 따라서 기계어로 변경해줍니다.
위 사진은 ARMv7-M 아키텍처의 명령어 포맷 중 하나입니다.
하지만 어셈블러에서 기계어로 변경했다고 하지만 그 변경한 기계어에는 #include로 외부 파일을 참조한 경우
참조된 외부 함수나 코드는 포함되어 있지 않습니다.
그래서 어셈블러에서 나온 코드만으론 작동이 되지 않습니다.
아까 빌드한 코드에서 어셈블러가 어셈블리어를 기계어로 바꾼 파일인 test.o 를 실행시켜보면
이런 식으로 실행이 불가능한 것을 볼 수 있습니다.
그러면 안의 내용은 어떻게 되어 있는지 궁금하기 때문에 objdump 명령어를 사용하여 확인해보도록 하겠습니다.
printf 함수는 선언되어 있지 않고, 그냥 호출하는 callq 명령어만 있을 뿐입니다.
그러면 어셈블러에서 나온 object file 만 가지고서는 실행을 할 수 없기 때문에 #include로 참조한 라이브러리나 파일들을 다 같이 합쳐서 실행파일로 만들어 주는 친구가 있는데 그 친구의 이름이 링커입니다.
근데 생각해보니 분명 test.i 파일에서는 어셈블리어 코드에 stdio.h 파일의 내용이 포함되어 있었는데 왜 test.o 파일에는 stdio.h 파일의 내용이 포함되어 있지 않은걸까요?
test.i 파일에 포함되어 있는 내용은 소스코드를 분석할때 사용되고 실제로는 stdio.h 파일은 링커에서 연결됩니다.
그럼 test.s 는 test.i 를 어셈블리어로 변경한게 아니라 test.c 를 어셈블리어로 변경한게 되겠죠
링커
링커는 여러가지 목적 파일이나 라이브러리 파일들을 합쳐주는 역할을 하게 됩니다.
근데 어셈블러에서 한 번에 외부 파일, 라이브러리 참조까지 해서 만들면 될 거 같은데 왜 어셈블러는 object file 만 만들어주고 링커가 연결을 시켜주는 것일까요?
이유는 한번에 참조를 해서 만들면, 라이브러리 하나가 바뀌면 그 라이브러리를 참조한 모든 코드들을 다시 빌드해야 합니다.
그러면 빌드하는데 시간이 많이 들어가게 되기 때문에 링커를 이용해서 라이브러리가 바뀌면 링킹만 다시 해주면 되게 간단하게 만드는 것입니다.
그러면 링커는 어떻게 링킹을 해주는 것일까요
링커는 두가지 방법 중 하나로 링킹을 해줍니다.
동적 링크(Dynamic Link)와 정적 링크(Static Link)
동적 링크에는 *.dll *.so *.sa 파일이 사용됩니다.
정적 링크에서는 c 를 빌드하면 *.lib 파일이 보일 때가 있습니다.
이 .lib 파일이 실행파일에 포함되기 전의 라이브러리 파일입니다.
아까 빌드한 test.c를 file 명령어를 통해서 정보를 확인해보겠습니다.
dynamically linked라고 써져있는 게 보이실 겁니다. a.out 파일은 동적 링크를 통해서 링킹이 되었다는 걸 알 수 있습니다.
그러면 정적 링크와 동적 링크에는 장단점이 있을 텐데 어떤 장단점이 있는지 알아보도록 하겠습니다.
정적 링크
정적 링크를 하면 링커가 빌드를 할 때 프로그램이 필요로 하는 부분을 라이브러리에서 실행파일로 복사합니다.
장점
실행 파일에 이미 라이브러리가 포함되어 있기 때문에 라이브러리가 따로 필요가 없고, 의존성 문제도 생기지 않습니다.
그리고 라이브러리가 이미 포함되어 있기 때문에 실행 속도가 빨라집니다.
단점
실행 파일에 이미 라이브러리가 포함되어 있기 때문에 실행파일을 메모리에 올려야 하기 때문에 메모리 용량을 많이 잡아먹습니다. 그리고 실행파일 자체의 용량도 커지게 됩니다.
그리고 같은 프로그램을 여러 개 실행하게 되면 같은 라이브러리가 메모리에 여러 번 올라가게 됩니다. 그래서 용량을 매우 많이 잡아먹습니다.
라이브러리가 업데이트가 되거나 변경이 되게 되면 컴파일을 다시 해야 합니다. *중요
동적 링크
동적 링크를 하면 라이브러리가 실행이 될 때 링크됩니다.
stub이라는 걸 사용하여서 함수를 호출했을 때 라이브러리가 없으면 라이브러리를 메모리에 올리고, 자기 자신이 그 라이브러리에 함수의 주소로 대체됩니다.
장점
한 라이브러리는 메모리에 한 번만 올라가기 때문에 정적 링크에 비해서 메모리 사용량이 훨씬 적습니다.
라이브러리가 실행파일에 포함되어 있지 않기 때문에 실행파일의 용량이 줄어들게 됩니다.
라이브러리가 업데이트가 되거나 변경이 되어도 그 라이브러리만 바꾸면 됩니다. *중요
단점
라이브러리가 저장되어 있는 주소로 점프를 하기 때문에 약간의 overhead 가 생기고, 라이브러리 적재와 점프를 위한 불필요한 코드가 생기게 됩니다.