QUESTION
main()보다 먼저 실행되는 코드는 어디에서 오는가?
ELF loader는 section name을 보고 실행 파일을 적재하지 않는다. PT_LOAD program header가 지정한 file offset, virtual address, permission을 기준으로 VMA를 만들고, PT_INTERP가 있으면 dynamic linker도 함께 적재한다.
새 process image의 첫 userspace instruction은 main이 아니라 ELF entry point다. 일반적인 glibc 실행 파일에서는 crt1의 _start가 초기 stack에서 argc, argv를 꺼내 __libc_start_main에 넘긴다.
STRUCTURE
구조 그림
PT_LOAD가 실행 시 mapping을 만들고 section table은 이 배치를 직접 결정하지 않는다. 주소는 ASLR 때문에 실행마다 달라질 수 있다.
CALL PATH
호출 흐름
section table은 link/debug 정보에 가깝고, 실행 시 mapping은 program header가 결정한다. readelf -l 출력과 /proc/PID/maps를 서로 맞춰 본다.
함수 이름을 외우기 위한 그림이 아니다. 반환값, 파일 디스크립터, 메모리 매핑, 대기 큐 가운데 무엇이 다음 단계로 전달되는지 확인한다.
SOURCE COORDINATES
Linux 6.18.37 LTS 소스 위치
glibc 함수에서 멈추지 않고 syscall 구현과 커널 객체가 만나는 파일까지 내려간다. 링크는 동일한 태그의 원본 파일을 가리킨다.
| 파일 | 함수·구조체 | 여기서 볼 것 |
|---|---|---|
| fs/exec.c | do_execveat_common(), begin_new_exec() | 기존 주소 공간을 폐기하고 새 binary를 확정하는 지점 |
| fs/binfmt_elf.c | load_elf_binary(), create_elf_tables() | PT_LOAD mapping과 초기 stack 구성 |
| arch/x86/include/asm/processor.h | start_thread() | 새 instruction pointer와 stack pointer 설정 |
COMPLETE PROGRAM
실행 예제 원본
아래 코드는 설명을 위해 중간 줄을 생략한 의사 코드가 아니다. 파일로 빌드해 실행할 수 있는 최소 예제다.
cc -std=c17 -Wall -Wextra -O2 elf_start.c -o elf_start01#include <stdio.h>
02#include <stdlib.h>
03
04static void before_main(void) __attribute__((constructor));
05static void after_main(void) __attribute__((destructor));
06
07static void before_main(void)
08{
09 puts("constructor: runtime is ready");
10}
11
12static void after_main(void)
13{
14 puts("destructor: normal exit path");
15}
16
17int main(int argc, char **argv, char **envp)
18{
19 printf("main: argc=%d argv0=%s\n", argc, argv[0]);
20 printf("argv=%p envp=%p\n", (void *)argv, (void *)envp);
21 return EXIT_SUCCESS;
22}
CODE NOTES
코드 조각별 설명
__attribute__((constructor))linker가 함수 주소를 .init_array에 넣고 C runtime이 main 전에 배열을 순회한다. ELF entry point 자체가 이 함수로 바뀌는 것은 아니다.
__attribute__((destructor))정상적인 exit 경로에서 .fini_array를 통해 호출된다. _exit(), fatal signal, 전원 차단에는 실행되지 않는다.
puts("constructor이 시점에는 dynamic relocation과 libc 초기화가 끝났으므로 stdio를 사용할 수 있다.
int main(int argcargc/argv/envp 원본은 kernel이 만든 초기 stack에서 시작하지만, main 호출은 C runtime이 ABI에 맞춰 다시 구성한다.
return EXIT_SUCCESSmain의 반환은 __libc_start_main 내부에서 exit() 호출로 이어져 atexit handler와 stdio flush를 수행한다.
DETAILS
세부 동작
PT_LOAD가 VMA의 초안이다
p_offset와 p_vaddr는 page offset 관계가 맞아야 한다. loader는 file-backed 구간을 map하고 p_memsz가 p_filesz보다 큰 끝부분을 0으로 채워 .bss를 만든다.
W^X 정책은 program header permission과 mprotect relocation 과정에서 확인한다.
초기 stack에는 문자열만 있는 것이 아니다
argc, argv pointer array, envp pointer array 뒤에 auxiliary vector가 놓인다. AT_PHDR, AT_ENTRY, AT_RANDOM, AT_SYSINFO_EHDR 같은 항목을 libc와 dynamic linker가 소비한다.
getauxval()을 사용하면 /proc/self/auxv를 직접 parsing하지 않고 값을 확인할 수 있다.
exec 성공에는 반환 경로가 없다
execve가 성공하면 호출 process의 code, data, stack이 교체되므로 이전 instruction 다음 줄로 돌아오지 않는다. 실패했을 때만 -1과 errno가 돌아온다.
multithread process에서 호출하면 다른 thread는 사라지고 호출한 thread만 새 image의 초기 thread가 된다.
OBJECTS
객체와 수명
| 대상 | 언제 생기고 없어지는가 | 확인할 값 |
|---|---|---|
linux_binprm | exec 준비 중 임시로 존재하며 binary handler가 소비한다 | file, buf, argc/envc |
PT_LOAD VMA | exec 때 만들어지고 munmap/다음 exec/process exit까지 유지된다 | offset, protection, p_filesz/p_memsz |
initial stack | create_elf_tables가 만들고 _start와 libc가 소비한다 | argc, argv, envp, auxv |
FAILURE PATH
실패 조건과 오해하기 쉬운 부분
| 겉으로 보이는 현상 | 실제 원인 후보 | 확인 방법 |
|---|---|---|
| ENOEXEC | ELF magic/architecture/format 불일치 | file, readelf -h, kernel log |
| ENOENT인데 파일은 존재 | PT_INTERP가 가리키는 dynamic linker 없음 | readelf -l의 interpreter 확인 |
| main 전 crash | relocation, constructor, stack/ABI 문제 | LD_DEBUG, gdb starti, core dump |
LAB
직접 확인
- readelf -l 출력의 LOAD 주소와 실행 중 /proc/$PID/maps 주소를 PIE/비PIE 빌드로 비교한다.
- gdb에서 starti를 사용해 _start 첫 instruction에서 stack을 확인하고 x/32gx $rsp로 argc와 pointer 배열을 찾는다.
- _exit(0)를 호출하도록 바꿔 destructor와 stdio flush가 실행되지 않는지 확인한다.
./elf_start one tworeadelf -h -l ./elf_start && strace -f -e execve,mmap,mprotect ./elf_startPRIMARY REFERENCES