References
- Advanced C and C++ Compiling
Contents
- Shell, Kernel, Loader
- Program Loading Stage
- Executing Program Entry Point
이번 포스트에서는 프로그램을 실행할 때 발생하는 일련의 과정들에 대해 살펴보도록 하겠습니다. 여기서는 C/C++ 코드로 빌드되어 생성된 executable binary에 대해 설명합니다.
지난 포스트에 빌드된 바이너리가 어떤 식으로 구성되어 있는지 간단하게 살펴봤는데, 필요하시면 참조 바랍니다.
Shell
프로그램의 실행은 일반적으로 쉘(shell)을 통해 일어나며, 리눅스에는 sh, bash 등 다양한 쉘이 존재합니다.
사용자가 커맨드의 이름을 입력하고 엔터를 누르면 쉘은 입력된 이름이 내장 커맨드에 포함되어 있는지 찾습니다. 입력된 이름이 쉘이 지원하는 커맨드가 아니라면, 입력된 이름과 일치하는 바이너리를 찾기 시작합니다. 만약 full path가 아닌 프로그램 이름만을 입력한 경우, 쉘은 PATH라는 환경 변수에 지정된 각 폴더에서 실행 파일을 찾습니다. 이런 과정을 거쳐 executable binary의 전체 경로를 찾으면 쉘은 이 바이너리를 로드하고 실행하는 프로세스를 활성화합니다.
쉘이 수행하는 첫 번째 작업은 동일한 child process를 포크(fork)하여 자체 복사본을 만드는 것입니다. 기존의 메모리 맵을 복사하여 새로운 프로세스 메모리 맵을 생성하는 것은 이상하게 보일 수 있는데, 새로운 프로세스의 메모리 맵이 쉘의 메모리 맵이 서로 공통점이 없을 가능성이 높기 때문입니다. 하지만, 이러한 동작에는 이유가 있는데, 먼저 쉘의 모든 환경 변수를 새로운 프로세스에게 효과적으로 전달할 수 있습니다. 실제로 새로운 프로세스의 메모리 맵이 생성된 직후 원래 내용의 대부분은 지워지고(환경 변수를 전달하는 부분은 제외), 새로운 프로세스의 메모리 맵으로 덮어써져 실행할 준비를 하게 됩니다. 아래 그림은 이러한 아이디어를 보여줍니다.
자신의 프로세스 메모리 맵의 복사본을 생성한 뒤, 커널(kernel)은 환경 변수를 제외한 새롭게 생성된 메모리 맵을 깨끗히 합니다. 이 시점에서 로더(loader)가 빈 메모리 맵을 실행한 프로그램의 바이너리 파일의 내용을 채울 준비를 마치게 됩니다.
Kernel Role
쉘이 프로그램 실행 작업을 위임하면, 커널은 exec 종류의 함수를 호출합니다. 거의 대부분이 동일한 기능을 제공하지만, 실행 매개변수가 지정되는 방식에서 약간 다릅니다. 어떤 특정 exec 타입 함수가 선택되었는지에 관계없이 궁극적으로는 프로그램 실행의 실제 작업을 시작하는 sys_execve 함수를 호출합니다.
바로 다음 단계는 executable format을 식별합니다 (fs/exec.c 파일의 search_binary_handler). ELF 포맷으로 식별되면 작업은 load_elf_binary 함수(fs/binfmt_elf.c)로 이동합니다.
Executable의 포맷이 지원하는 포맷 중 하나로 식별되면, 실행을 위해 프로세스 메모리 맵을 준비하는 작업이 시작됩니다. 특히, 다음과 같은 이유로 쉘에 의해 생성된 child process는 쉘에서 커널로 전달됩니다.
- 커널은 sandbox (process environment)와 새 프로그램을 시작하는 데 사용할 수 있는 관련된 메모리를 얻습니다. 커널이 가장 먼저 할 일은 대부분의 메모리 맵을 완전히 지우는 것이고, 이후에 새 프로그램의 binary executable fileSharePoint로부터 읽은 데이터로 메모리 맵을 채우는 프로세스를 로더(loader)에 위임합니다.
- fork() 호출을 통해 쉘 프로세스를 복제하면 쉘에 정의된 환경 변수들이 child process로 전달되어 환경 변수의 상속 체인이 끊어지지 않도록 합니다.
Loader Role
Loader-Specific View of a Binary File (Sections vs . Segments)
로더의 세부적인 기능에 대해 살펴보기 전에 로더와 링커가 바이너리 파일의 내용에 대해 서로 다른 관점을 가지고 있다는 점을 알고 있는 것이 중요합니다.
링커는 다양한 특성(code, unitialized data, initialized data 등)의 다양한 섹션을 정확히 구별할 수 있는 정교한 모듈로 생각할 수 있습니다. Unresolved references를 해결하려면 내부 구조를 자세하게 알아야 합니다.
반면, 로더의 역할은 간단합니다. 로더가 수행하는 대부분의 작업은 링커가 생성한 섹션을 프로세스 메모리 맵에 복사하는 것이므로 작업을 완료하기 위해서 섹션의 내부 구조를 자세하게 알 필요가 없습니다. 대신, 섹션의 속성이 read-only인지, read-write인지, 그리고 실행할 준비를 마치기 전에 패치를 적용해야 하는지 여부를 알고 있어야 합니다.
dyanmic linking 과정에서의 로더 역할은 조금 더 복잡합니다
아래 그림처럼 로더는 링커에 의해 생성된 섹션을 common loading requirements에 의해 세그먼트로 그룹화합니다.
참고로 readelf 커맨드를 사용하면 링커의 섹션을 로더 세그먼트로 어떻게 그룹화했는지 볼 수 있습니다.
Program Loading Stage
바이너리 파일의 포맷이 식별되면 커널의 로더 모듈의 역할이 시작됩니다. 로더는 먼저 executable binary file에서 PT_INTERP 세그먼트를 찾으려고 시도하며, 이는 dynamic loading task를 서포트합니다. 아직 dynamic loading에 대해 살펴보지 않았기 때문에 먼저 프로그램이 정적으로 링크되어 있고, dynamic load가 필요하지 않다는 상황을 가정하겠습니다.
Static Build Example
Static build라는 용어는 dynamic linking dependecies가 전혀 없는 executable을 나타내는데 사용됩니다. 이와 같은 실행 파일을 만드는데 필요한 모든 외부 라이브러리는 정적으로 링크되어 있습니다. 결과적으로, 이렇게 얻은 바이너리는 완전히 protable하며, 어떠한 system shared library도 필요하지 않습니다. 하지만, Full portability(완전 이식성)의 대가로 실행 파일의 크기가 매우 커집니다.
Static build의 영향을 간단한 Hello, World 예제를 통해 살펴보겠습니다. 우선 아래와 같이 main.cpp 파일을 작성하고,
#include <stdio.h>
int main(int argc, char** argv)
{
printf("Hello, world\n");
return 0;
}
다음의 커맨드를 통해 동일한 main.cpp 소스 코드로 두 개의 어플리케이션을 각각 빌드해보겠습니다. 하나는 '-static' 링커 플래그가 사용됩니다.
gcc main.cpp -o regularBuild
gcc -static main.cpp -o staticBuild
두 실행 파일의 크기를 비교하면 static으로 빌드된 실행 파일의 크기가 훨씬 더 크다는 것을 확인할 수 있습니다.
다시 로더로 돌아와서 과정을 살펴보겠습니다. 로더는 프로그램의 바이너리 파일 세그먼트의 헤더를 읽고, 각 세그먼트의 주소와 바이트 길이를 결정합니다. 중요한 것은 이 단계에서는 로더는 여전히 프로그램 메모리 맵에 아무것도 쓰지 않는다는 것입니다. 이 단계에서 로더는 실행 파일의 세그먼트와 프로그램 메모리 맵 사이의 매핑을 전달하는 구조체(ex, vm_are_struct)를 설정하고 유지 관리합니다.
세그먼트의 실제 복사는 프로그램 실행이 시작된 이후에 발생합니다. 프로세스에게 부여된 물리적 메모리 페이지와 프로그램 메모리 맵 간의 가상 메모리 매핑이 설정되어 있으며, 페이지 요청(page request)를 통해 프로그램 세그먼트를 로드합니다. 이러한 정책으로 인해 런타임에서 실제로 필요한 프로그램의 부분만 로드됩니다.
Executing Program Entry Point
C/C++ 프로그래밍 관점에서 프로그램의 entry point는 main() 함수입니다. 하지만 프로그램의 실행 관점에서는 그렇지 않습니다. Execution flow가 main() 함수에 도달하기 전에 몇 가지 다른 함수들이 실행됩니다.
프로그램의 loading과 main() 함수의 첫 번째 코드 라인이 실행 사이에서는 일반적으로 어떤 일이 발생하는지 살펴보겠습니다.
The Loader Finds the Entry Point
프로그램을 로드한 후, 즉, 프로그램의 청사진을 준비하고 실행을 위해 필요한 섹션을 메모리에 복사한 후, 로더는 ELF 헤더에서 e_entry 필드의 값을 빠르게 살펴봅니다. 이 값은 실행이 시작될 프로그램 메모리 주소를 포함합니다.
Executable binary file를 역어셈하면 일반적으로 e_entry 값이 코드(.text) 섹션의 첫 번째 주소 외에 전달하는 것이 아무 것도 없습니다. 이 프로그램 메모리 주소는 일반적으로 _start 함수의 원형을 나타냅니다.
아래는 .text 섹션을 역어셈한 결과의 일부입니다.
The Role of _start() Function
_start 함수의 역할은 다음에 실행될 __libc_start_main 함수를 위해 입력 인자를 준비하는 것입니다. 이 함수의 프로토타입은 다음과 같습니다.
int __libc_start_main(int (*main) (int, char * *, char * *), /* address of main function */
int argc, /* number of command line args */
char * * ubp_av, /* command line arg array */
void (*init) (void), /* address of init function */
void (*fini) (void), /* address of fini function */
void (*rtld_fini) (void), /* address of dynamic linker fini function */
void (* stack_end) /* end of the stack address */
);
실제로 이 함수를 호출하기 이전에 모든 명령어들은 호출에 필요한 인수들을 순서대로 쌓는 것 뿐입니다.
The Role of __libc_start_main() Function
이 함수는 프로그램이 실행될 환경을 준비하는 과정에서 핵심 역할을 수행합니다. 프로그램 실행 중에 필요한 환경 변수를 설정할 뿐만 아니라 다음의 역할들도 수행합니다.
- 프로그램의 스레딩을 시작
- main() 함수가 시작되기 전에 초기화 작업을 수행하는 _init() 함수를 호출. GCC 컴파일러는 __attribute__ ((constructor)) 키워드를 통해 프로그램이 시작되기 전에 완료되기를 원하는 루틴의 커스텀 디자인을 지원합니다.
- 프로그램 종료 후, cleanup을 위해 호출되는 _fini() 와 _rtld_fini() 함수를 등록. 일반적으로 _fini()의 동작은 _init()과 반대입니다. __attribute__ ((destructor)) 키워드를 통해 사용자 정의 디자인을 지원합니다.
- 마지막으로, 모든 준비 작업들이 완료된 이후, __libc_start_main()은 main() 함수를 호출하여 프로그램을 실행
Stack and Calling Conventions
일반적인 프로그램의 흐름은 일련의 함수 호출입니다. 일반적으로 main 함수는 적어도 하나의 함수를 호출하며, 이는 차례로 수많은 다른 함수들을 호출할 수 있습니다.
스택이라는 개념은 함수 호출 메커니즘의 기본입니다. 아마 잘 아는 내용들이라 자세히 다루지는 않고, 대신, 함수와 관련된 몇 가지 중요한 내용들에 대해서 언급만 하고 넘어가도록 하겠습니다.
- 프로세스 메모리 맵은 필요한 스택을 위해 특정 영역을 예약합니다.
- 런타임에 사용되는 스택 메모리의 크기는 다양합니다. 함수의 호출 시퀀스가 클수록 스택 메모리가 더 많이 사용됩니다.
- 스택 메모리는 무한하지 않습니다. 대신 사용 가능한 스택 메모리의 양은 allocation을 위해 사용할 수 있는 메모리(힙)의 양과 바인딩됩니다.
함수가 호출하는 함수에 인수를 전달하는 방법은 흥미로운 주제입니다. 변수를 함수에 전달하는 매우 정교한 메커니즘들이 다양하게 설계되어 특정 어셈블리 언어 루틴이 생성되는데, 이러한 스택 구현 메커니즘을 일반적으로 calling convention이라고 부릅니다.
사실, cdecl, stdcall, fastcall, thiscall과 같이 x86 아키텍처용의 여러 가지 calling convention들이 개발되었습니다. 각각은 다양한 설계 관점에서 특정 시나리오에 맞게 조정됩니다. 저도 자세히 알고 있는 부분은 아니라서 이 주제에 대해서는 깊게 살펴보지는 않겠습니다.
'프로그래밍 > C & C++' 카테고리의 다른 글
[C/C++] 정적 라이브러리 (0) | 2022.11.11 |
---|---|
[C/C++] Static vs Dynamic 라이브러리 (0) | 2022.11.08 |
[C/C++] 간단한 프로그램 컴파일/링크 과정 (1) | 2022.11.03 |
[C++] std::string 최적화 (1) | 2022.04.24 |
[C++] Error Handling (2) (0) | 2022.03.06 |
댓글