모두의 코드
씹어먹는 C++ - <20 - 4. 코드 부터 실행 파일 까지 - main 으로의 여정>
이번 강좌에서는
main 함수가 호출되는 경로
전역 객체들의 생성자는 어떻게 호출되는지
링커 스크립트
crt 라이브러리들
에 대해서 다루어 보겠습니다.
안녕하세요! 지난 강의에서 링킹이 어떠한 과정으로 이루어지는지 배웠습니다. 그렇다면 이번 강의에서는 생성된 ELF 파일을 실행 할 때 어떠한 경로로 main 함수가 호출되는 것인지 살펴겠습니다.
참고 사항
아래 서술하는 모든 내용은 리눅스 상에서 GCC 컴파일러 기준으로 ELF 형식의 실행 파일을 생성함을 기준으로 작성된 것입니다. 윈도우의 경우 이와 다를 수 있습니다.
세상에서 제일 간단한 C++ 프로그램
이 세상에서 가장 간단한 C++ 프로그램을 만들어봅시다.
int main() {}
GCC 컴파일러를 사용해서 만들어진 ELF 실행 파일의 크기를 보면
$ ls -l test -rwxrwxr-x 1 jaebum jaebum 17392 Nov 29 22:35 test
놀랍게도 무려 17392 바이트나 된다는 것을 알 수 있습니다. 도대체 이 완성된 실행 파일에 뭐가 들어있길래 이렇게 큰 용량을 차지할까요? 한 번 objdump 로 실행파일이 어떠한 코드로 이루어져 있는지 살펴보겠습니다.
objdump -S test test: file format elf64-x86-64 Disassembly of section .init: 0000000000001000 <_init>: 1000: f3 0f 1e fa endbr64 1004: 48 83 ec 08 sub $0x8,%rsp 1008: 48 8b 05 d9 2f 00 00 mov 0x2fd9(%rip),%rax # 3fe8 <__gmon_start__> 100f: 48 85 c0 test %rax,%rax 1012: 74 02 je 1016 <_init+0x16> 1014: ff d0 callq *%rax 1016: 48 83 c4 08 add $0x8,%rsp 101a: c3 retq Disassembly of section .plt: 0000000000001020 <.plt>: 1020: ff 35 a2 2f 00 00 pushq 0x2fa2(%rip) # 3fc8 <_GLOBAL_OFFSET_TABLE_+0x8> 1026: f2 ff 25 a3 2f 00 00 bnd jmpq *0x2fa3(%rip) # 3fd0 <_GLOBAL_OFFSET_TABLE_+0x10> 102d: 0f 1f 00 nopl (%rax) Disassembly of section .plt.got: 0000000000001030 <__cxa_finalize@plt>: 1030: f3 0f 1e fa endbr64 1034: f2 ff 25 bd 2f 00 00 bnd jmpq *0x2fbd(%rip) # 3ff8 <__cxa_finalize@GLIBC_2.2.5> 103b: 0f 1f 44 00 00 nopl 0x0(%rax,%rax,1) Disassembly of section .text: 0000000000001040 <_start>: 1040: f3 0f 1e fa endbr64 1044: 31 ed xor %ebp,%ebp 1046: 49 89 d1 mov %rdx,%r9 1049: 5e pop %rsi 104a: 48 89 e2 mov %rsp,%rdx 104d: 48 83 e4 f0 and $0xfffffffffffffff0,%rsp 1051: 50 push %rax 1052: 54 push %rsp 1053: 4c 8d 05 56 01 00 00 lea 0x156(%rip),%r8 # 11b0 <__libc_csu_fini> 105a: 48 8d 0d df 00 00 00 lea 0xdf(%rip),%rcx # 1140 <__libc_csu_init> 1061: 48 8d 3d c1 00 00 00 lea 0xc1(%rip),%rdi # 1129 <main> 1068: ff 15 72 2f 00 00 callq *0x2f72(%rip) # 3fe0 <__libc_start_main@GLIBC_2.2.5> 106e: f4 hlt 106f: 90 nop 0000000000001070 <deregister_tm_clones>: 1070: 48 8d 3d 99 2f 00 00 lea 0x2f99(%rip),%rdi # 4010 <__TMC_END__> 1077: 48 8d 05 92 2f 00 00 lea 0x2f92(%rip),%rax # 4010 <__TMC_END__> 107e: 48 39 f8 cmp %rdi,%rax 1081: 74 15 je 1098 <deregister_tm_clones+0x28> 1083: 48 8b 05 4e 2f 00 00 mov 0x2f4e(%rip),%rax # 3fd8 <_ITM_deregisterTMCloneTable> 108a: 48 85 c0 test %rax,%rax 108d: 74 09 je 1098 <deregister_tm_clones+0x28> 108f: ff e0 jmpq *%rax 1091: 0f 1f 80 00 00 00 00 nopl 0x0(%rax) 1098: c3 retq 1099: 0f 1f 80 00 00 00 00 nopl 0x0(%rax) 00000000000010a0 <register_tm_clones>: 10a0: 48 8d 3d 69 2f 00 00 lea 0x2f69(%rip),%rdi # 4010 <__TMC_END__> 10a7: 48 8d 35 62 2f 00 00 lea 0x2f62(%rip),%rsi # 4010 <__TMC_END__> 10ae: 48 29 fe sub %rdi,%rsi 10b1: 48 89 f0 mov %rsi,%rax 10b4: 48 c1 ee 3f shr $0x3f,%rsi 10b8: 48 c1 f8 03 sar $0x3,%rax 10bc: 48 01 c6 add %rax,%rsi 10bf: 48 d1 fe sar %rsi 10c2: 74 14 je 10d8 <register_tm_clones+0x38> 10c4: 48 8b 05 25 2f 00 00 mov 0x2f25(%rip),%rax # 3ff0 <_ITM_registerTMCloneTable> 10cb: 48 85 c0 test %rax,%rax 10ce: 74 08 je 10d8 <register_tm_clones+0x38> 10d0: ff e0 jmpq *%rax 10d2: 66 0f 1f 44 00 00 nopw 0x0(%rax,%rax,1) 10d8: c3 retq 10d9: 0f 1f 80 00 00 00 00 nopl 0x0(%rax) 00000000000010e0 <__do_global_dtors_aux>: 10e0: f3 0f 1e fa endbr64 10e4: 80 3d 25 2f 00 00 00 cmpb $0x0,0x2f25(%rip) # 4010 <__TMC_END__> 10eb: 75 2b jne 1118 <__do_global_dtors_aux+0x38> 10ed: 55 push %rbp 10ee: 48 83 3d 02 2f 00 00 cmpq $0x0,0x2f02(%rip) # 3ff8 <__cxa_finalize@GLIBC_2.2.5> 10f5: 00 10f6: 48 89 e5 mov %rsp,%rbp 10f9: 74 0c je 1107 <__do_global_dtors_aux+0x27> 10fb: 48 8b 3d 06 2f 00 00 mov 0x2f06(%rip),%rdi # 4008 <__dso_handle> 1102: e8 29 ff ff ff callq 1030 <__cxa_finalize@plt> 1107: e8 64 ff ff ff callq 1070 <deregister_tm_clones> 110c: c6 05 fd 2e 00 00 01 movb $0x1,0x2efd(%rip) # 4010 <__TMC_END__> 1113: 5d pop %rbp 1114: c3 retq 1115: 0f 1f 00 nopl (%rax) 1118: c3 retq 1119: 0f 1f 80 00 00 00 00 nopl 0x0(%rax) 0000000000001120 <frame_dummy>: 1120: f3 0f 1e fa endbr64 1124: e9 77 ff ff ff jmpq 10a0 <register_tm_clones> 0000000000001129 <main>: 1129: f3 0f 1e fa endbr64 112d: 55 push %rbp 112e: 48 89 e5 mov %rsp,%rbp 1131: b8 00 00 00 00 mov $0x0,%eax 1136: 5d pop %rbp 1137: c3 retq 1138: 0f 1f 84 00 00 00 00 nopl 0x0(%rax,%rax,1) 113f: 00 0000000000001140 <__libc_csu_init>: 1140: f3 0f 1e fa endbr64 1144: 41 57 push %r15 1146: 4c 8d 3d a3 2c 00 00 lea 0x2ca3(%rip),%r15 # 3df0 <__frame_dummy_init_array_entry> 114d: 41 56 push %r14 114f: 49 89 d6 mov %rdx,%r14 1152: 41 55 push %r13 1154: 49 89 f5 mov %rsi,%r13 1157: 41 54 push %r12 1159: 41 89 fc mov %edi,%r12d 115c: 55 push %rbp 115d: 48 8d 2d 94 2c 00 00 lea 0x2c94(%rip),%rbp # 3df8 <__do_global_dtors_aux_fini_array_entry> 1164: 53 push %rbx 1165: 4c 29 fd sub %r15,%rbp 1168: 48 83 ec 08 sub $0x8,%rsp 116c: e8 8f fe ff ff callq 1000 <_init> 1171: 48 c1 fd 03 sar $0x3,%rbp 1175: 74 1f je 1196 <__libc_csu_init+0x56> 1177: 31 db xor %ebx,%ebx 1179: 0f 1f 80 00 00 00 00 nopl 0x0(%rax) 1180: 4c 89 f2 mov %r14,%rdx 1183: 4c 89 ee mov %r13,%rsi 1186: 44 89 e7 mov %r12d,%edi 1189: 41 ff 14 df callq *(%r15,%rbx,8) 118d: 48 83 c3 01 add $0x1,%rbx 1191: 48 39 dd cmp %rbx,%rbp 1194: 75 ea jne 1180 <__libc_csu_init+0x40> 1196: 48 83 c4 08 add $0x8,%rsp 119a: 5b pop %rbx 119b: 5d pop %rbp 119c: 41 5c pop %r12 119e: 41 5d pop %r13 11a0: 41 5e pop %r14 11a2: 41 5f pop %r15 11a4: c3 retq 11a5: 66 66 2e 0f 1f 84 00 data16 nopw %cs:0x0(%rax,%rax,1) 11ac: 00 00 00 00 00000000000011b0 <__libc_csu_fini>: 11b0: f3 0f 1e fa endbr64 11b4: c3 retq Disassembly of section .fini: 00000000000011b8 <_fini>: 11b8: f3 0f 1e fa endbr64 11bc: 48 83 ec 08 sub $0x8,%rsp 11c0: 48 83 c4 08 add $0x8,%rsp 11c4: c3 retq
생각보다 실행 파일에 많은 코드들이 들어가 있습니다. 실제로 우리가 작성한 코드는 딱 이부분 하나 입니다.
0000000000001129 <main>: 1129: f3 0f 1e fa endbr64 112d: 55 push %rbp 112e: 48 89 e5 mov %rsp,%rbp 1131: b8 00 00 00 00 mov $0x0,%eax 1136: 5d pop %rbp 1137: c3 retq 1138: 0f 1f 84 00 00 00 00 nopl 0x0(%rax,%rax,1) 113f: 00
그렇다면 나머지 부분은 도대체 뭘까요? 일단 위에서 보시다 시피 main
함수에선 다른 정의된 함수들을 호출하지 않기 때문에 다른 함수들이 도무지 왜 필요할까요? 그런데 여기엔 사실 비밀이 있습니다. 바로 프로그램 실행 시에 가장 먼저 호출되는 함수가 main
이 아니기 때문이죠!
가장 먼저 시작되는 함수는 main 이 아니다
여태까지 프로그램 시작 시에 가장 먼저 호출되는 함수가 main
이라고 알고 계셨곗지만 이는 사실이 아닙니다. main
함수를 실행하기 전에 여러가지 준비해야 될 것들이 많기 때문이죠. 가장 먼저 호출되어야 할 함수의 위치는 실행 파일 정보에 써져 있습니다.
$ readelf -h test ELF Header: Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 Class: ELF64 Data: 2's complement, little endian Version: 1 (current) OS/ABI: UNIX - System V ABI Version: 0 Type: DYN (Shared object file) Machine: Advanced Micro Devices X86-64 Version: 0x1 Entry point address: 0x1040 Start of program headers: 64 (bytes into file) Start of section headers: 14608 (bytes into file) Flags: 0x0 Size of this header: 64 (bytes) Size of program headers: 56 (bytes) Number of program headers: 13 Size of section headers: 64 (bytes) Number of section headers: 29 Section header string table index: 28
위는 ELF 실행 파일의 정보를 보여주는 readelf
라는 유틸리티를 사용한 것입니다. 위 처럼 우리가 만든 실행 파일의 여러가지 정보를 보여주고 있는데, 여기서 주목할 부분은 Entry point address 라고 쓰여져 있는 부분입니다.
우리가 프로그램을 실행하면 프로그램 로더는 ELF 파일의 Entry point address 를 읽고 해당 위치로 RIP
를 이동시킵니다. 그렇다면 위 프로그램의 0x1040
에는 뭐가 있을까요?
0000000000001040 <_start>: 1040: f3 0f 1e fa endbr64 1044: 31 ed xor %ebp,%ebp 1046: 49 89 d1 mov %rdx,%r9 1049: 5e pop %rsi 104a: 48 89 e2 mov %rsp,%rdx 104d: 48 83 e4 f0 and $0xfffffffffffffff0,%rsp 1051: 50 push %rax 1052: 54 push %rsp 1053: 4c 8d 05 56 01 00 00 lea 0x156(%rip),%r8 # 11b0 <__libc_csu_fini> 105a: 48 8d 0d df 00 00 00 lea 0xdf(%rip),%rcx # 1140 <__libc_csu_init> 1061: 48 8d 3d c1 00 00 00 lea 0xc1(%rip),%rdi # 1129 <main> 1068: ff 15 72 2f 00 00 callq *0x2f72(%rip) # 3fe0 <__libc_start_main@GLIBC_2.2.5> 106e: f4 hlt 106f: 90 nop
바로 _start
함수가 있는 것을 볼 수 있습니다. 그렇습니다. 사실 main 이 아니라 _start
함수가 프로그램 시작 시 가장 먼저 호출되는 함수라고 보시면 됩니다.
_start
함수 자체에서는 그닥 별로 하는 것이 없습니다. 0x1068 에서 __libc_start_main
함수를 호출하기 위해 전달할 인자들을 준비해주는 것 뿐입니다. __libc_start_main
함수는 C++ 로 작성된 프로그램을 시작하기 위해서 필요한 여러가지 환경들을 준비하는 함수 입니다. 하는 일로;
프로그램의
Effective User Id
가Real User Id
와 다를 때 필요한 보안 관련 체크를 수행쓰레드 사용을 위한 준비
등등 여러가지 일들을 하고 있습니다. 전체 코드를 보고 싶으신 분들은 여기 에서 보시면 됩니다. 물론 라이브러리 코드라 읽기 썩 쉽지만은 않습니다.
__libc_start_main
는 아래와 같이 생겼습니다.
int __libc_start_main(int *(main)(int, char **, char **), int argc, char **ubp_av, void (*init)(void), void (*fini)(void), void (*rtld_fini)(void), void(*stack_end));
이 때 위 처럼 첫 번째 인자로 호출할 main
함수를 전달하고 있고, 네 번째 인자 (init) 에 초기화를 해줄 함수를 전달하고 있는데, 우리의 경우 보시다시피, __libc_csu_init
함수를 초기화 함수로 전달되고 있습니다.
__libc_csu_init
함수의 코드를 보면 간단합니다. 아래처럼 __init_array_start
부터 __init_array_end
까지 정의되어 있는 라는 함수 포인터 배열에 있는 함수들을 하나씩 돌아가면서 호출하고 있을 뿐입니다.
extern void (*__init_array_start[])(int, char **, char **) attribute_hidden; void __libc_csu_init(int argc, char **argv, char **envp) { const size_t size = __init_array_end - __init_array_start; for (size_t i = 0; i < size; i++) { (*__init_array_start[i])(argc, argv, envp); } }
한 가지 재미있는 점은 __init_array_start
과 __init_array_end
의 값은 코드 어디에서도 제공되는 것이 아닙니다 (__init_array_start = 0x1234
와 같은 코드를 볼 수 없다는 것입니다). 이 변수들의 값은 링커에 의해서 직접 제공됩니다. 따라서 만약에 우리가 임의로 __init_array_start
라는 이름의 배열을 정의한다면 프로그램 실행 시에 위 for loop
이 제대로 작동하지 않겠죠.
아무튼 __init_array_start
에 등록된 함수들을 통해서 main
함수 시작 전에 필요한 함수들을 호출할 수 있게 됩니다. 예를 들어서 아래와 같은 코드를 생각해봅시다.
#include <iostream> class A { public: A() { std::cout << "A constructor \n"; } }; A a; int main() { std::cout << "main! \n"; }
성공적으로 컴파일 하였다면
실행 결과
A constructor main!
와 같이 나옵니다. 위 코드에서 사용되고 있는 전역 객체는 두 가지가 있습니다. 하나는 우리가 정의한 A 클래스의 a 라는 이름의 전역 변수가 있고, 그리고 std::cout
이 있습니다. 이 두 객체들은 main
호출 전에 반드시 초기화가 이루어져야 합니다.
어떤 식으로 초기화가 이루어지는지 확인하기 위해서는 __init_array_start
와 __init_array_end
가 어떠한 값을 가지는지 알아야 합니다. 그런데 위에서 말했듯이 해당 심볼들의 값은 링커에 의해서 정의된다고 했었죠? 이를 위해선 링커가 어떠한 식으로 심볼들을 배치하는지 알아야 합니다.
링커 스크립트 (Linker script)
링커 스크립트(Linker script)는 링커가 심볼들을 실행 파일 내부에 여러 섹션에 배치할 때 어떤 식으로 배치할지 알려주는 스크립트 입니다. GCC 의 링커의 경우 우리가 따로 명시 하지 않을 경우디폴트로 사용하고 있는 스크립트가 있습니다. 이를 보기 위해서는 아래와 같이 링커에 verbose
옵션을 주면 됩니다.
$ gcc test.cc -g -o test -Wl,--verbose .... (생략) .... SECTIONS { .... (생략) .... .init_array : { PROVIDE_HIDDEN (__init_array_start = .); KEEP (*(SORT_BY_INIT_PRIORITY(.init_array.*) SORT_BY_INIT_PRIORITY(.ctors.*))) KEEP (*(.init_array EXCLUDE_FILE (*crtbegin.o *crtbegin?.o *crtend.o *crtend?.o ) .ctors)) PROVIDE_HIDDEN (__init_array_end = .); }
링커 스크립트 문법은 간단합니다. (정확한 문법을 이해하고 싶으신 분들은 여기를 참조해주세요.) 먼저 원하는 섹션 이름을 명시한 뒤에, 중괄호 안에 어떠한 심볼들을 배치할 지 써 넣으면 됩니다.
그 다음에
PROVIDE_HIDDEN (__init_array_start = .);
.
은 현재의 위치라는 의미를 가집니다. 따라서 __init_array_start
에는 init_array
섹션의 시작 주소값이 들어가게 되고, 마찬가지로 __init_array_end
에는 init_array
섹션의 마지막 주소값이 들어가겠죠. 아무튼 __init_array_start
와 __init_array_end
의 위치를 알고 싶다면 실행 파일에서 init_array
를 보면 된다는 것입니다.
자 이제 init_array
에 뭐가 있는지 볼까요?
$ objudmp -s test Contents of section .init_array: 3d88 80110000 00000000 04120000 00000000 ................
objdump 에 -s
옵션을 주면 각 섹션들의 내용을 볼 수 있습니다. 리틀 엔디안임을 감안해서 보자면, 첫 번째 원소로 0x1180
이 있고, 두 번째 원소로 0x1204
가 있습니다. 첫 번째 원소는 frame_dummy
를 호출하는 것인데 이는 나중에 살펴보기로 하고, 두 번째 원소인 0x1204
를 호출하는 것을 봅시다. 0x1204
에는 뭐가 있냐면;
0000000000001204 <_GLOBAL__sub_I_a>: 1204: f3 0f 1e fa endbr64 1208: 55 push %rbp 1209: 48 89 e5 mov %rsp,%rbp 120c: be ff ff 00 00 mov $0xffff,%esi 1211: bf 01 00 00 00 mov $0x1,%edi 1216: e8 90 ff ff ff callq 11ab <_Z41__static_initialization_and_destruction_0ii> 121b: 5d pop %rbp 121c: c3 retq 121d: 90 nop
위 처럼 _Z41__static_initialization_and_destruction_0ii
함수를 호출하는 부분이 있습니다. 이름만 보아도, C++ 컴파일러가 프로그램 내에 전역 및 static
객체들의 초기화를 위해서 만들어준 함수 임을 알 수 있죠. 그럼 해당 함수로 가봅시다.
00000000000011ab <_Z41__static_initialization_and_destruction_0ii>: 11ab: f3 0f 1e fa endbr64 11af: 55 push %rbp 11b0: 48 89 e5 mov %rsp,%rbp 11b3: 48 83 ec 10 sub $0x10,%rsp 11b7: 89 7d fc mov %edi,-0x4(%rbp) 11ba: 89 75 f8 mov %esi,-0x8(%rbp) 11bd: 83 7d fc 01 cmpl $0x1,-0x4(%rbp) 11c1: 75 3e jne 1201 <_Z41__static_initialization_and_destruction_0ii+0x56> 11c3: 81 7d f8 ff ff 00 00 cmpl $0xffff,-0x8(%rbp) 11ca: 75 35 jne 1201 <_Z41__static_initialization_and_destruction_0ii+0x56> extern wostream wclog; /// Linked to standard error (buffered) #endif //@} // For construction of filebuffers for cout, cin, cerr, clog et. al. static ios_base::Init __ioinit; 11cc: 48 8d 3d 7f 2f 00 00 lea 0x2f7f(%rip),%rdi # 4152 <_ZStL8__ioinit> 11d3: e8 b8 fe ff ff callq 1090 <_ZNSt8ios_base4InitC1Ev@plt> 11d8: 48 8d 15 29 2e 00 00 lea 0x2e29(%rip),%rdx # 4008 <__dso_handle> 11df: 48 8d 35 6c 2f 00 00 lea 0x2f6c(%rip),%rsi # 4152 <_ZStL8__ioinit> 11e6: 48 8b 05 0b 2e 00 00 mov 0x2e0b(%rip),%rax # 3ff8 <_ZNSt8ios_base4InitD1Ev@GLIBCXX_3.4> 11ed: 48 89 c7 mov %rax,%rdi 11f0: e8 7b fe ff ff callq 1070 <__cxa_atexit@plt> A a; 11f5: 48 8d 3d 55 2f 00 00 lea 0x2f55(%rip),%rdi # 4151 <a> 11fc: e8 1d 00 00 00 callq 121e <_ZN1AC1Ev> } 1201: 90 nop 1202: c9 leaveq 1203: c3 retq
보시다 시피, 먼저 ios_base 에 정의된 Init
함수를 호출함으로써 cout, cin
등등의 입출력 객체들을 정의하는 것을 볼 수 있고 (0x11d3), 밑에 0x11fc
에서 A 의 생성자인 _ZN1AC1Ev
를 호출하고 있습니다. (맹글링된 이름임을 염두!) 실제로 _ZN1AC1Ev
에 가보면
000000000000121e <_ZN1AC1Ev>: A() { std::cout << "A constructor \n"; } 121e: f3 0f 1e fa endbr64 1222: 55 push %rbp 1223: 48 89 e5 mov %rsp,%rbp 1226: 48 83 ec 10 sub $0x10,%rsp 122a: 48 89 7d f8 mov %rdi,-0x8(%rbp) 122e: 48 8d 35 d0 0d 00 00 lea 0xdd0(%rip),%rsi # 2005 <_ZStL19piecewise_construct+0x1> 1235: 48 8d 3d 04 2e 00 00 lea 0x2e04(%rip),%rdi # 4040 <_ZSt4cout@@GLIBCXX_3.4> 123c: e8 3f fe ff ff callq 1080 <_ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc@plt> 1241: 90 nop 1242: c9 leaveq 1243: c3 retq 1244: 66 2e 0f 1f 84 00 00 nopw %cs:0x0(%rax,%rax,1) 124b: 00 00 00 124e: 66 90 xchg %ax,%ax
위 처럼 A constructor 을 출력하고 있습니다.
register_tm_clones
앞서 init_array
에 첫 번째 원소로 frame_dummy
의 주소값이 있었습니다. frame_dummy
에 뭐가 있냐면;
0000000000001180 <frame_dummy>: 1180: f3 0f 1e fa endbr64 1184: e9 77 ff ff ff jmpq 1100 <register_tm_clones>
그냥 위와 같이 register_tm_clones
함수를 호출하고 있습니다. register_tm_clones
는 C 와 C++ 에서 Transactional Memory (TM)
를 지원하기 위해서 필요한 여러가지 작업들을 수행합니다. TM 이란 한 블록 안에 있는 일련의 작업들을 atomic
하게 실행하기 위한 도구인데, 예를 들어서
int f() { static int i = 0; synchronized { // begin synchronized block std::cout << i << " -> "; ++i; // each call to f() obtains a unique value of i std::cout << i << '\n'; return i; // end synchronized block } }
와 같이 __synchronized
블록을 정의한다면 해당 블록 안에서 일어나는 일은 모두 atomic
하게 일어납니다. 다시말해 f()
를 여러 쓰레드에서 실행하였을 경우 출력 결과가 0 -> 1, 1 -> 2, ..
으로만 나온다는 것이죠. 따라서 mutex
와 같은 다른 동기화 장치 없이도 아주 편하게 작업을 atomic
하게 할 수 있습니다.
하지만 현재 해당 표준은 아직 C++ 에서 정식으로 지원하지 않고 있지만 GCC 컴파일러의 경우 fgnu-tm
옵션을 주면 사용 가능합니다. 만약에 TM 을 사용하지 않는다면 register_tm_clones
함수는 아무런 작업도 수행하지 않습니다.
컴파일러는 어디서 이 정보를 가지고 오는 것일까?
그럼 한 가지 궁금한 것이 있습니다. 컴파일러는 도대체 어떻게 이러한 식으로 실행파일을 구성할 수 있는 것일까요? 이를 위해서는 실제로 컴파일러와 링커가 실행 파일을 생성할 때 어떠한 라이브러리 파일들을 사용하고 있는지 봐야 합니다. 이를 위해서 GCC 에 -v
옵션을 줘서 컴파일을 해봅시다.
$ g++ test.cc -g -v -o test ... (생략) ... COLLECT_GCC_OPTIONS='-g' '-v' '-o' 'test' '-shared-libgcc' '-mtune=generic' '-march=x86-64' /usr/lib/gcc/x86_64-linux-gnu/9/collect2 -plugin /usr/lib/gcc/x86_64-linux-gnu/9/liblto_plugin.so -plugin-opt=/usr/lib/gcc/x86_64-linux-gnu/9/lto-wrapper -plugin-opt=-fresolution=/tmp/ccvHYI13.res -plugin-opt=-pass-through=-lgcc_s -plugin-opt=-pass-through=-lgcc -plugin-opt=-pass-through=-lc -plugin-opt=-pass-through=-lgcc_s -plugin-opt=-pass-through=-lgcc --build-id --eh-frame-hdr -m elf_x86_64 --hash-style=gnu --as-needed -dynamic-linker /lib64/ld-linux-x86-64.so.2 -pie -z now -z relro -o test /usr/lib/gcc/x86_64-linux-gnu/9/../../../x86_64-linux-gnu/Scrt1.o /usr/lib/gcc/x86_64-linux-gnu/9/../../../x86_64-linux-gnu/crti.o /usr/lib/gcc/x86_64-linux-gnu/9/crtbeginS.o -L/usr/lib/gcc/x86_64-linux-gnu/9 -L/usr/lib/gcc/x86_64-linux-gnu/9/../../../x86_64-linux-gnu -L/usr/lib/gcc/x86_64-linux-gnu/9/../../../../lib -L/lib/x86_64-linux-gnu -L/lib/../lib -L/usr/lib/x86_64-linux-gnu -L/usr/lib/../lib -L/usr/lib/gcc/x86_64-linux-gnu/9/../../.. /tmp/cc11vpo2.o --verbose -lstdc++ -lm -lgcc_s -lgcc -lc -lgcc_s -lgcc /usr/lib/gcc/x86_64-linux-gnu/9/crtendS.o /usr/lib/gcc/x86_64-linux-gnu/9/../../../x86_64-linux-gnu/crtn.o
와우. 보시다 시피 엄청나게 많은 파일들이 링크되어서 들어가고 있음을 알 수 있습니다. -o
다음에 오는 파일들이 링킹 되는 파일들이라 보시면 됩니다. 이 때 -L
의 경우 그냥 -lstdc++
와 같이 명확한 경로가 지정되지 않은 라이브러리 파일들을 찾을 디렉토리 위치를 지정해주는 것이라 보시면 됩니다. 따라서 정확히 링크되고 있는 파일들을 뽑아보면 다음과 같습니다.
Scrt1.o
crti.o
crtbeginS.o
libstdc++
(C++ 라이브러리)libm
(수학 관련 라이브러리)libgcc_s
libgcc
libc
(C 라이브러리)libgcc_s
crtendS.o
crtn.o
생각보다 많은 수의 파일들이 링킹되고 있음을 알 수 있습니다. 먼저 살펴볼 라이브러리 파일의 경우 crt
로 시작하는 부류입니다. crt
는 C Runtime
의 약자로, C 로 작성된 프로그램이 실행될 수 있도록 준비해주는 역할을 합니다.
crt1.o
가장 먼저 crt1
부터 봅시다. crt1
에는 _start
가 정의되어 있고, libc
를 사용하기 위한 준비를 수행하고 있습니다. 실제로 그 내용을 objdump 로 까보면;
objdump -S ./x86_64-linux-gnu/crt1.o ./x86_64-linux-gnu/crt1.o: file format elf64-x86-64 Disassembly of section .text: 0000000000000000 <_start>: 0: f3 0f 1e fa endbr64 4: 31 ed xor %ebp,%ebp 6: 49 89 d1 mov %rdx,%r9 9: 5e pop %rsi a: 48 89 e2 mov %rsp,%rdx d: 48 83 e4 f0 and $0xfffffffffffffff0,%rsp 11: 50 push %rax 12: 54 push %rsp 13: 4c 8b 05 00 00 00 00 mov 0x0(%rip),%r8 # 1a <_start+0x1a> 1a: 48 8b 0d 00 00 00 00 mov 0x0(%rip),%rcx # 21 <_start+0x21> 21: 48 8b 3d 00 00 00 00 mov 0x0(%rip),%rdi # 28 <_start+0x28> 28: ff 15 00 00 00 00 callq *0x0(%rip) # 2e <_start+0x2e> 2e: f4 hlt 2f: 90 nop 0000000000000030 <_dl_relocate_static_pie>: 30: f3 0f 1e fa endbr64 34: c3 retq
위 처럼 _start
가 정의되어 있음을 알 수 있습니다. 참고로 우리가 실제로 링크한 파일은 Scrt1.o
인데, 이는 그냥 crt1
과 거의 동일한데, PIE (Position Indepenent Executable) 실행 파일을 작성하는데 사용한다고 보시면 됩니다. 참고로 PIE 란, 이전에 다루었던 공유 라이브러리 처럼 메모리 임의의 위치에 로드 되어도 실행 가능한 파일이라 보시면 됩니다. 제가 사용하는 운영체제 (우분투 20.04) 에선 기본으로 GCC 가 모든 실행 파일을 PIE 형태로 만들게 되어 있습니다.
crtbegin.o
그 다음으로 링크되는 파일로 crtbegin.o
이 있습니다. 이 파일은 __libc_start_main
에서 호출되는 여러가지 초기화 관련 함수들을 정의하고 있습니다.
objdump -S crtbegin.o 0: b8 00 00 00 00 mov $0x0,%eax 5: 48 3d 00 00 00 00 cmp $0x0,%rax b: 74 13 je 20 <deregister_tm_clones+0x20> d: b8 00 00 00 00 mov $0x0,%eax 12: 48 85 c0 test %rax,%rax 15: 74 09 je 20 <deregister_tm_clones+0x20> 17: bf 00 00 00 00 mov $0x0,%edi 1c: ff e0 jmpq *%rax 1e: 66 90 xchg %ax,%ax 20: c3 retq 21: 66 66 2e 0f 1f 84 00 data16 nopw %cs:0x0(%rax,%rax,1) 28: 00 00 00 00 2c: 0f 1f 40 00 nopl 0x0(%rax) 0000000000000030 <register_tm_clones>: 30: be 00 00 00 00 mov $0x0,%esi 35: 48 81 ee 00 00 00 00 sub $0x0,%rsi 3c: 48 89 f0 mov %rsi,%rax 3f: 48 c1 ee 3f shr $0x3f,%rsi 43: 48 c1 f8 03 sar $0x3,%rax 47: 48 01 c6 add %rax,%rsi 4a: 48 d1 fe sar %rsi 4d: 74 11 je 60 <register_tm_clones+0x30> 4f: b8 00 00 00 00 mov $0x0,%eax 54: 48 85 c0 test %rax,%rax 57: 74 07 je 60 <register_tm_clones+0x30> 59: bf 00 00 00 00 mov $0x0,%edi 5e: ff e0 jmpq *%rax 60: c3 retq 61: 66 66 2e 0f 1f 84 00 data16 nopw %cs:0x0(%rax,%rax,1) 68: 00 00 00 00 6c: 0f 1f 40 00 nopl 0x0(%rax) 0000000000000070 <__do_global_dtors_aux>: 70: f3 0f 1e fa endbr64 74: 80 3d 00 00 00 00 00 cmpb $0x0,0x0(%rip) # 7b <__do_global_dtors_aux+0xb> 7b: 75 13 jne 9은 8d: 5d pop %rbp 8e: c3 retq 8f: 90 nop 90: c3 retq 91: 66 66 2e 0f 1f 84 00 data16 nopw %cs:0x0(%rax,%rax,1) 98: 00 00 00 00 9c: 0f 1f 40 00 nopl 0x0(%rax) 00000000000000a0 <frame_dummy>: a0: f3 0f 1e fa endbr64 a4: eb 8a jmp 30 <register_tm_clones>
objdump 로 까보면 실제로 frame_dummy
나 register_tm_clones
와 같은 앞서 __libc_csu_init
에서 호출되는 함수들이 정의되어 있는 것을 볼 수 있습니다.
그 외에 crtn.o
과 crtend.S
에는 소멸자 관련 함수들이 정의되어 있습니다.
libgcc
마지막으로 살펴볼 라이브러리는 libgcc
입니다. libgcc
의 경우 GCC 에서 컴파일 되는 코드들에 반드시 링크되어야 하는 라이브러리 입니다. GCC 를 통해 컴파일 된 코드는 특정 작업을 위해서 해당 라이브러리에 정의되어 있는 함수들을 호출할 수 있습니다. 최적화 레벨 옵션이 켜져있을 경우 libgcc
의 함수를 호출하는 대신 해당 코드로 아예 치환해버릴 수 있겠지만 최적화 옵션을 끌 경우 libgcc
안에 정의되어 있는 함수를 호출하게 될 것입니다.
libgcc
에 어떠한 루틴들이 정의되어 있는지 궁금하신 분들은 여기 를 참조하시기 바랍니다.
덧붙여 초기화 시 필요한 라이브러리들을 좀 더 자세히 알고 싶다면 여기 를 참조해주세요!
마무리
이것으로 4 개의 강의를 거쳐 우리가 작성한 소스 코드에서 어떠한 방식으로 실행 파일이 생성되는지 살펴보았습니다. 특히 마지막 두 강좌는 GCC 컴파일러의 ELF 실행 파일 생성과정에 국한된 것으로 다른 환경 (예를 들어 윈도우나 맥) 과 다른 형태의 실행 파일 (예를 들어 윈도우의 PE 파일) 은 다른 형태의 실행 파일을 구성할 것입니다. 하지만 내가 다른 환경에서 작업한다고 해서 여태까지 다룬 내용이 무의미 한 것은 아닙니다! 그래도 큰 틀에서 작동하는 방식은 비슷하기 때문에 다른 방식들을 이해하는데에는 큰 무리가 없으리라 생각합니다.
댓글을 불러오는 중입니다..