main() 함수가 실행되기 전의 여정

2 weeks ago 9

  • 프로그램이 실행되기 전, 커널이 execve 시스템 호출을 통해 프로세스를 생성하고 초기화하는 과정을 탐구한 기술 분석
  • 이 호출은 실행 파일 경로, 인자, 환경 변수를 전달하며, 커널은 이를 기반으로 ELF 형식의 실행 파일을 로드
  • ELF 파일은 코드, 데이터, 심볼, 동적 링크 정보 등을 포함하며, 커널은 이를 해석해 메모리 매핑과 스택 초기화를 수행
  • 이후 커널은 _start 엔트리포인트로 제어를 넘기며, 언어별 런타임이 초기화된 뒤에야 사용자 정의 main 함수가 호출됨
  • 이 과정은 운영체제, 컴파일러, 런타임의 협업 구조를 보여주며, 시스템 수준에서 프로그램 실행이 어떻게 이루어지는지 이해하는 데 중요함

프로그램 실행의 시작점: execve 호출

  • Linux에서 프로그램 실행은 execve 시스템 호출을 통해 시작됨
    • execve(const char *filename, char *const argv[], char *const envp[]) 형태로, 실행 파일 이름, 인자 목록, 환경 변수 목록을 전달
    • 커널은 이를 통해 어떤 프로그램을 어떤 환경에서 실행할지 결정
  • 고수준 언어에서는 이 호출이 표준 라이브러리의 프로세스 실행 API로 감싸져 있음
    • 예: Rust의 std::process::Command는 내부적으로 execve를 호출
    • 쉘의 PATH 탐색과 유사하게, 명령 이름을 전체 경로로 변환하는 과정을 수행
  • Shebang(#!) 이 있는 스크립트의 경우, 커널은 지정된 인터프리터를 사용해 프로그램을 실행
    • 예: #!/usr/bin/python3 → Python 인터프리터로 실행

ELF: 실행 파일의 구조

  • Linux의 실행 파일은 ELF(Executable and Linkable Format) 형식을 따름
    • ELF는 코드, 데이터, 심볼, 재배치 정보 등을 포함하는 표준 실행 파일 포맷
    • 다른 OS는 Mach-O(macOS), PE(Windows) 등 별도 포맷 사용
  • ELF 헤더에는 파일의 구조와 메모리 배치 정보가 포함됨
    • 예시 항목: ELF Magic, Class, Entry point address, Program headers, Section headers
    • Entry point address는 프로그램이 처음 실행될 명령어의 주소
  • 예시 ELF 헤더에서는 RISC-V 아키텍처용 ELF32 실행 파일로, 0x10358 주소가 엔트리포인트로 지정됨

ELF 내부 구성 요소

  • ELF 파일은 여러 섹션(section) 으로 구성되어 있음
    • .text: 실행 코드
    • .data: 초기화된 전역 변수
    • .bss: 초기화되지 않은 전역 변수
    • .plt: 공유 라이브러리 호출용 테이블
    • .symtab, .strtab: 심볼 및 문자열 테이블
  • PLT(Procedure Linkage Table) 는 공유 라이브러리 함수 호출을 지원
    • 예: libc의 printf, malloc 등
    • ELF의 PT_INTERP 섹션은 동적 링커(interpreter)를 지정
  • 커널은 ELF를 읽어 로드 가능한 섹션을 메모리에 배치하고, 필요한 경우 ASLR, NX bit 등 보안 기능을 적용

심볼 테이블과 런타임 링크

  • ELF의 심볼 테이블(symtab) 은 함수와 변수의 주소 정보를 포함
    • 예시: _start, main, __libc_start_main 등의 엔트리 존재
    • 단순한 “Hello, World!” 프로그램도 2300개 이상의 심볼을 포함할 수 있음
  • 이는 대부분 표준 라이브러리와 런타임 초기화 코드에서 비롯됨
    • musl이나 glibc 같은 libc 구현체가 연결되어 있기 때문
  • 커널은 ELF의 각 섹션을 로드한 뒤, 인터프리터(동적 링커) 로 제어를 넘김
    • 인터프리터는 재배치(relocation), 주소 무작위화(ASLR), 실행 권한 설정(NX bit) 등을 처리

스택 초기화 과정

  • 커널은 프로그램 실행 전 스택(stack) 을 직접 구성해야 함
    • 스택은 지역 변수, 함수 호출 프레임, 인자 전달 등에 사용
  • execve 호출 시 전달된 argv, envp는 스택에 저장됨
    • 프로그램은 이를 통해 명령행 인자와 환경 변수에 접근
  • 커널은 또한 ELF 보조 벡터(auxv) 를 스택에 포함
    • 페이지 크기, ELF 메타데이터, 시스템 정보 등 30여 개 항목 포함
    • 예: AT_PAGESZ는 메모리 페이지 크기(예: 4KiB)를 지정
  • RISC-V 에뮬레이터 예시에서는 스택 포인터(sp)를 높은 주소에서 시작해 인자, 환경 변수, 보조 벡터를 역순으로 쌓음

엔트리포인트와 _start 함수

  • ELF의 엔트리포인트는 _start 함수의 주소로 지정됨
    • _start는 커널이 제어를 넘기는 최초의 사용자 공간 코드
  • 대부분의 언어는 _start에서 런타임 초기화를 수행한 뒤 main을 호출
    • 예: Rust의 std::rt::lang_start, C의 __libc_start_main
  • Rust 예시에서는 #![no_std], #![no_main] 속성을 사용해 런타임 없이 직접 _start를 정의 가능
    • _start 내에서 스택에서 argc, argv, envp를 읽고 main 포인터를 호출
  • 언어별 런타임은 전역 생성자, 스레드 로컬 저장소, 예외 처리 등 언어 특화 초기화 작업을 수행

main() 호출 전까지의 전체 흐름

  • 전체 과정은 다음과 같이 요약됨
    1. execve 호출 → 커널이 ELF 파일 로드
    2. ELF 해석 → 코드/데이터 섹션 매핑, 인터프리터 지정
    3. 스택 구성 → 인자, 환경 변수, 보조 벡터 저장
    4. 엔트리포인트 _start 실행
    5. 런타임 초기화 후 main() 호출
  • 이 일련의 과정은 운영체제 커널, ELF 포맷, 언어 런타임의 협력 구조를 보여줌
  • 실제 Linux 커널은 주소 공간, 프로세스 테이블, 그룹 관리 등 추가적인 내부 로직을 포함하지만, 본 글은 그 전 단계의 핵심 흐름을 설명

결론 및 교정

  • main() 이전의 실행 과정은 커널 수준의 초기화와 런타임 설정의 결합체
  • 단순한 “Hello, World!” 프로그램조차 복잡한 ELF 구조와 런타임 초기화를 거쳐 실행됨
  • 글의 초기 버전에서는 일부 섹션 로딩 로직을 커널에 귀속시켰으나, 실제로는 ELF 인터프리터의 역할임이 교정됨
  • 이 분석은 시스템 프로그래밍, 컴파일러, OS 아키텍처 이해에 유용한 기초 자료로 기능

Read Entire Article