서론

팀장님과 컴파일러에 대한 얘기 중 심볼이라는 키워드를 알게되었습니다.

시간이 여유롭지 않아, 당시에 내용을 정리하지 못하였는데 기억이 사라지는 것을 느끼고 지금이라도 정리하고자 합니다.


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 : 콘솔에 메시지를 인쇄하는 데 사용되는 표준 출력 스트림을 나타내는 개체입니다.

어찌되었든 심볼을 이용하여 간단하게 메모리 주소에 접근 하기 위해서는 해당 심볼이 어느 메모리 주소를 가리키는지가 어딘가에는 정의되어있어야 합니다.

심볼이 어느 메모리 주소를 가르키는지 정의되어있지 않다면 현실적으로 심볼을 이용하여 원하는 메모리 주소를 찾아가는건 불가능하기 때문입니다.

그럼 과연 어디에 심볼 과 메모리 주소의 맵핑이 정의되어 있을까요?

심볼 테이블

자 이때 나오는 것이 심볼 테이블 입니다.

심볼메모리 주소의 맵핑 관계를 저장하고 있는 데이터구조입니다.

우리의 프로세스가 원하는 함수를 실행시키고자 할 때의 순서는 다음과 같습니다.

  1. 심볼 테이블에서 심볼 을 조회한다.
  2. 조회된 심볼메모리 주소 를 확인한다.
  3. 함수 호출을 위한 스택에 해당 메모리 주소를 배치한다.

자 그럼 프로세스가 동작하기 위해서는 심볼 테이블의 존재가 필수로 보입니다.

그러면 이 심볼 테이블은 언제 만들어지고 어디에 저장될까요?

심볼 테이블 생성 시점 및 저장

심볼 테이블은 일반적으로 소스 코드를 기계 코드로 변환하는 컴파일 프로세스중에 생성됩니다.

컴파일러는 소스코드를 분석하여 코드에 사용된 심볼을 식별하고, 각 심볼을 해당 메모리 주소에 매핑하면서 심볼 테이블을 생성하게 됩니다.

심볼 테이블이 생성되면 심볼 테이블 파일 또는 심볼 파일이라는 별도의 파일에 저장됩니다.

이 파일에는 메모리 주소에 대한 심볼 매핑데이터 유형범위와 같은 심볼에 대한 기타 정보가 포함되어 있습니다.

심볼 테이블확장자는 사용된 컴파일러에 따라서 다를 수 있습니다.

.pdb (프로그램 데이터베이스)

Microsoft Visual C++ 컴파일러는 .pdb(프로그램 데이터베이스) 파일 형식을 사용하여 심볼 테이블 정보를 저장합니다.

이 파일 형식을 사용하면 심볼 테이블, 유형 정보소스 파일 이름을 비롯한 복잡한 디버깅 정보를 저장할 수 있습니다.

.sym

GNU Compiler Collection(GCC)은 .sym파일 형식을 사용하여 심볼 테이블 정보를 저장합니다.

이 파일 형식은 메모리 주소에 대한 심볼 매핑데이터 유형범위와 같은 심볼에 대한 기타 정보를 저장하는 간단한 텍스트 기반 형식입니다.

물론 이외에도 다른 확장자로 심볼 테이블을 저장할 수도 있습니다.


요약

자 지금까지 심볼에 대하여 알아보았습니다.

요약하자면 심볼이란 소스코드에서 사용되는 함수,변수, 및 개체의 이름을 나타내며, 프로세스는 이 심볼을 통하여 실제 함수를 실행, 변수의 값에 대한 접근을 합니다.

이 심볼과함수가 정의된 메모리 주소, 변수의 값이 저장된 메모리 주소의 매핑 관계는 컴파일 단계에서 만들어지며 심볼 테이블이라는 별도의 파일(.pdb 또는 .sym)에 저장됩니다.

최종적으로 프로세스는 심볼 테이블에서 심볼을 조회하여 맵핑된 메모리 주소에 접근하게 됩니다.