서론
팀장님과 컴파일러에 대한 얘기 중 심볼
이라는 키워드를 알게되었습니다.
시간이 여유롭지 않아, 당시에 내용을 정리하지 못하였는데 기억이 사라지는 것을 느끼고 지금이라도 정리하고자 합니다.
C++ 컴파일의 결과물
C++ 을 컴파일하게되면 출력물로 OS에서 실행할 수 있는 기계어 코드
로 만들어진 실행 파일이 생성됩니다.
이 실행 파일의 확장자는 운영체제(OS)에 따라 다를 수 있는데, 기본적으로 Windows는 .exe
확장자의 실행 파일을, Linux및 Unix 계열의 운영 체제는 .out
이라는 확장자의 실행파일이 만들어지는 것이 보편적인 경우 입니다.
물론 컴파일러에 따라서 옵션으로 컴파일 시 어떤 확장자의 파일을 만들어 낼 것인지 정할 수 있게 해주는 컴파일러도 존재합니다.
컴파일러의 옵션에 따른 확장자 선택의 예
g++ -o myprogram.exe mysourcefile.cpp
위에서 이 실행 파일들은 기계어 코드
로 만들어졌다고 말씀 드렸는데, 좀 더 상세히 살펴보면 기계 명령어
와 심볼
로 이뤄져 있습니다.
기계 명령어
기계 명령어
는 프로그램이 실행될 때 컴퓨터의 프로세서에 의해 실행되는 저수준(low level)의 코드입니다.
이러한 기계 명령어는 산술 계산 수행
,메모리 조작
,코드의 다른 부분으로의 분기
와 같은 작업에 해당합니다.
기계 명령어는 일반적으로 니모닉 코드(mnemonic)
와 숫자 피연산자의 조합
을 사용하여 개별 명령어를 나타내는 어셈블리 언어
로 작성되는데
이렇게 작성된 어셈블리 언어를 어셈블러가 기계 명령어
로 변환해줍니다.
우리가 일반적으로 알고있는 컴파일 과정은
개발자가 작성한 소스코드
> 어셈블리어 코드
> 기계 코드
의 과정을 거치는데.
당연히 기계 명령어를 만들어주는 어셈블리 언어는 개발자가 작성한 소스코드
를 기반으로 합니다.
소스코드 TO 어셈블리어 코드 예시
// 개발자가 작성한 소스코드
#include <iostream>
using namespace std;
int main()
{
cout << "Hello, world!" << endl;
return 0;
}
위 소스코드를 어셈블리로 변환하면 아래와 같습니다.
; Assembly code generated by the C++ compiler
; Set up the stack and registers
push rbp
mov rbp, rsp
sub rsp, 16
; Call the cout object's operator<< function to print "Hello, world!"
mov edi, OFFSET FLAT:.LC0
call operator<<(std::basic_ostream<char, std::char_traits<char> >&, char const*)
; Call the cout object's operator<< function to print a newline
mov edi, OFFSET FLAT:.LC1
call operator<<(std::basic_ostream<char, std::char_traits<char> >&, char const*)
; Return from main with a value of 0
xor eax, eax
leave
ret
니모닉 코드 (또는 니모닉 심볼)
위에서 니모닉 코드
와 숫자 피연산자
가 함께 어셈블리 언어에서 기계 명령어
를 나타낸다고 설명했습니다.
이중에서 니모닉 코드는 프로세서가 수행할 수 있는 특정 작업을 나타내는 짧고 상징적인 이름
입니다.
예를 들어 위 예시코드의 mov
, call
,push
등 이 니모닉 코드라 할 수 있는데, 각 니모닉 코드 뒤에는 일반적으로 코드가 나타내는 작업의 특정 세부 정보를 지정
하는 하나 이상의 숫자 피연산자
가 따라 옵니다.
예를 들어 보면 mov
다음에는 이동할 값의 소스
와 대상을 지정하는 두 개의 피연산자
가 올 수 있고,
call
다음에는 호출할 함수의 주소
를 지정하는 단일 피연산자
가 올 수 있습니다.
위 예시의 일부분을 확인해보면 이 말이 사실인 걸 확인할 수 있습니다.
; Call the cout object's operator<< function to print "Hello, world!"
mov edi, OFFSET FLAT:.LC0
call operator<<(std::basic_ostream<char, std::char_traits<char> >&, char const*)
지금 까지 기계어 코드
를 이루는 기계 명령어
와 심볼
중에 기계 명령어
를 알아보았습니다.
이어서 우리가 정말 알고자 했던 심볼을 알아보겠습니다.
opcode
여기서 잠깐! 니모닉 코드는 특정 작업
을 나타내는 짧고 상징적인 이름이라 하였는데 어셈블리 코드에서 특정 작업
은 opcode
라고 불립니다.
이 opcode의 중요한 특징은 사람이 읽을 수 없는 숫자 값
으로 표시된다는 것입니다.
실제 opcode 는 아래와 같이 생겼습니다.
0xB8, 0x05, 0x00, 0x00, 0x00
0x03, 0x05, 0x00, 0x00, 0x00, 0x00
이걸 보자마자 “아~ 이러 이러한 작업을 하는 코드구나” 라고 이해하는 분은 없으실거라 생각합니다(계시다면 괴물..)
이렇게 사람이 읽기 힘든 숫자 값
을 그나마~~ 사람이 읽을 수 있도록 변경해준 것이 니모닉 코드
입니다.
예시를 니모닉 코드로 변환 할 경우 아래와 같이 볼 수 있습니다.
mov eax, 0x5
add eax, 0x3
여담으로 사실 우리의 C++ 을 컴파일하는 과정에서 생성되는 어셈블리 코드
에는 니모닉 코드가 존재하지 않습니다.
혹자들은 이렇게 말할 수 있습니다.
“엥? visual studio 로 디버깅하면 니모닉 코드가 보이는데요? 니모닉 코드가 존재하는거 아닌가요?”
네 visual studio 로 디버깅하면 니모닉 코드를 볼 수 있습니다.
하지만 이는 어셈블리 코드
에 포함된 것이 아닌 visual studio의 기능인 디스어셈블러(역어셈블러)
가 사람이 읽기 힘든 opcode
를 읽을 수 있도록 니모닉 코드로 변환하여 보여주는 것입니다.
심볼
기계어 코드
에서 심볼
이라 하면 일반적으로 소스코드
에서 사용되는 함수
,변수
, 및 개체의 이름
을 나타냅니다.
이러한 심볼은 일련의 과정을 통하여 메모리의 해당 값
또는 함수에 대한 참조
로 대체됩니다.
그렇기 때문에 우리의 프로세스는 변수에 저장된 값을 읽기 위하여 값이 저장된 메모리 주소
를 직접 사용하거나,
또는 함수를 실행시키기 위해 함수가 정의된 메모리의 주소
를 직접 기억해뒀다가 이를 이용하여 함수를 실행하지 않고,
심볼이라는 이름
만으로 변수의 값이 저장된 또는 함수가 정의된 메모리의 주소
에 접근할 수 있습니다.
그렇다면 소스코드에서 심볼이라 할 수 있는 부분을 직접 찾아보겠습니다.
#include <iostream>
using namespace std;
const int MAX_SIZE = 100;
int main()
{
int array[MAX_SIZE];
for (int i = 0; i < MAX_SIZE; i++)
{
array[i] = i * i;
}
cout << "The first 10 squares are:" << endl;
for (int i = 0; i < 10; i++)
{
cout << i << ": " << array[i] << endl;
}
return 0;
}
위 예시에서 심볼
을 찾아보면 아래와 같습니다.
MAX_SIZE
: 변수 의 최대 크기를 나타내는 상수입니다.array
: 정수 배열을 나타내는 변수입니다.i
: 루프 카운터로 사용되는 변수입니다.main
: 프로그램의 진입점을 나타내는 함수입니다.cout
: 콘솔에 메시지를 인쇄하는 데 사용되는 표준 출력 스트림을 나타내는 개체입니다.
어찌되었든 심볼을 이용하여 간단하게 메모리 주소에 접근
하기 위해서는 해당 심볼
이 어느 메모리 주소를 가리키는지가 어딘가에는 정의되어있어야 합니다.
심볼이 어느 메모리 주소를 가르키는지 정의되어있지 않다면 현실적으로 심볼을 이용하여 원하는 메모리 주소를 찾아가는건 불가능하기 때문입니다.
그럼 과연 어디에 심볼 과 메모리 주소의 맵핑
이 정의되어 있을까요?
심볼 테이블
자 이때 나오는 것이 심볼 테이블
입니다.
심볼
과메모리 주소
의 맵핑 관계를 저장하고 있는 데이터구조입니다.
우리의 프로세스가 원하는 함수를 실행시키고자 할 때의 순서는 다음과 같습니다.
- 심볼 테이블에서
심볼
을 조회한다. - 조회된
심볼
의메모리 주소
를 확인한다. - 함수 호출을 위한 스택에 해당
메모리 주소
를 배치한다.
자 그럼 프로세스가 동작하기 위해서는 심볼 테이블의 존재가 필수로 보입니다.
그러면 이 심볼 테이블은 언제 만들어지고 어디에 저장될까요?
심볼 테이블 생성 시점 및 저장
심볼 테이블
은 일반적으로 소스 코드를 기계 코드로 변환하는 컴파일 프로세스
중에 생성됩니다.
컴파일러는 소스코드를 분석하여 코드에 사용된 심볼을 식별하고, 각 심볼을 해당 메모리 주소에 매핑하면서 심볼 테이블을 생성하게 됩니다.
심볼 테이블이 생성되면 심볼 테이블 파일
또는 심볼 파일
이라는 별도의 파일
에 저장됩니다.
이 파일에는 메모리 주소에 대한 심볼 매핑
과 데이터 유형
및 범위
와 같은 심볼에 대한 기타 정보
가 포함되어 있습니다.
심볼 테이블확장자는 사용된 컴파일러에 따라서 다를 수 있습니다.
.pdb (프로그램 데이터베이스)
Microsoft Visual C++ 컴파일러는 .pdb(프로그램 데이터베이스)
파일 형식을 사용하여 심볼 테이블 정보를 저장합니다.
이 파일 형식을 사용하면 심볼 테이블
, 유형 정보
및 소스 파일 이름
을 비롯한 복잡한 디버깅 정보를 저장
할 수 있습니다.
.sym
GNU Compiler Collection(GCC)은 .sym
파일 형식을 사용하여 심볼 테이블 정보를 저장합니다.
이 파일 형식은 메모리 주소에 대한 심볼 매핑
과 데이터 유형
및 범위
와 같은 심볼에 대한 기타 정보를 저장하는 간단한 텍스트 기반 형식
입니다.
물론 이외에도 다른 확장자로 심볼 테이블을 저장할 수도 있습니다.
요약
자 지금까지 심볼에 대하여 알아보았습니다.
요약하자면 심볼
이란 소스코드
에서 사용되는 함수
,변수
, 및 개체의 이름
을 나타내며, 프로세스는 이 심볼을 통하여 실제 함수를 실행, 변수의 값에 대한 접근을 합니다.
이 심볼과함수가 정의된 메모리 주소, 변수의 값이 저장된 메모리 주소
의 매핑 관계는 컴파일 단계
에서 만들어지며 심볼 테이블
이라는 별도의 파일(.pdb 또는 .sym)에 저장됩니다.
최종적으로 프로세스는 심볼 테이블
에서 심볼
을 조회하여 맵핑된 메모리 주소
에 접근하게 됩니다.