1P by GN⁺ 9일전 | ★ favorite | 댓글 1개
  • 프로그램이 실행되기 전, 커널이 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) 는 공유 라이브러리 함수 호출을 지원
    • 예: libcprintf, 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 아키텍처 이해에 유용한 기초 자료로 기능
Hacker News 의견
  • ELF 파일의 동적 링크 과정에 대해 설명함
    커널은 ELF의 PT_LOAD 세그먼트를 매핑하고, PT_INTERP로 지정된 동적 링커(ld.so) 를 로드한 뒤 제어를 넘김
    이후 동적 링커가 스스로 재배치(relocation)하고 필요한 공유 객체를 mmap/mprotect로 로드함
    이 구조는 스크립트의 shebang(#!) 메커니즘과 유사하다고 비유함

    • 커널은 섹션 정보에는 전혀 관심이 없고, 오직 PT_LOAD 세그먼트만 처리함
      예전에 objcopy로 ELF에 임의 파일을 삽입하려다 커널이 로드하지 않아 혼란스러웠던 경험을 공유함
      결국 직접 프로그램 헤더 테이블 패치 도구를 만들었고, mold 링커에도 이 기능이 추가되었다고 함
      관련 글: Self-contained Lone Lisp Applications
    • 작성자가 이전에 내용을 잘못 수정해 올렸음을 인정하고 수정하겠다고 함
    • 리눅스에서는 로더가 사용자 공간에서 동작하는데, 왜 더 다양한 로더가 없는지 항상 궁금했다고 함
  • 전체 코드를 main() 이전 혹은 main() 없이 패킹하는 실험을 했다고 함
    관련 글: Packing a codebase into a single function

    • 읽어보니 의외로 단순하고 취약하지 않아서 흥미로웠다고 함
      모든 함수를 main(100+n, ...) 형태로 바꾸면 된다고 농담함
  • 이 주제에 흥미가 있다면 자신이 만든 cpu.land를 참고하라고 함
    메모리 레이아웃보다는 멀티태스킹과 코드 로딩 과정을 다룸

    • cpu.land를 정말 좋아한다고 감사 인사를 전함
  • C 프로젝트 중 표준 라이브러리를 피하고 Linux syscall만 직접 호출하는 경우가 얼마나 될지 궁금하다고 함
    이렇게 코드를 짜는 게 훨씬 재미있다고 느낌

    • 직접 syscall을 쓰는 건 오히려 비효율적이라고 주장함
      ALSA, DRM 같은 기능은 커널 syscall 대신 시스템 라이브러리를 통해 접근하는 게 이점이 많음
      이 방식이 이식성과 유지보수성 면에서 Windows 스타일 접근보다 낫다고 설명함
    • Windows에서는 Win32 API만 사용하면 C 런타임을 링크하지 않아도 된다고 덧붙임
    • 자신도 예전에 liblinux 프로젝트를 만들어 syscall만으로 프로그램을 작성했었다고 함
      지금은 Linux의 nolibc 헤더가 잘 되어 있어 중단했지만,
      현재는 syscall 기반의 Lisp 인터프리터 언어를 개발 중이라고 함
      시스템 호출로 직접 Linux 유저 스페이스를 구성하는 실험이라 매우 흥미로운 여정이었다고 함
    • 이식성을 유지하려 하지만, 파일 디스크립터는 너무 편리해서 포기하기 어렵다고 함
    • 많은 드라이버 코드가 실제로 syscall만 사용한다고 덧붙임
  • ELF 인터프리터(ld.so)가 초기 ELF 세그먼트를 매핑한 후 모든 로딩을 담당한다고 설명함
    execve는 PT_LOAD 세그먼트를 매핑하고 aux vector를 스택에 채운 뒤
    ELF 인터프리터의 엔트리 포인트로 점프함
    커널은 PLT/GOT에 대해 아무것도 모름

  • 대학에서 이 주제를 가르치는 사람으로서, 학생들이 메모리 다이어그램 때문에 혼란스러워한다고 함
    교재는 주소가 높을수록 위쪽에 그려지지만, 실제 Linux 프로세스는
    낮은 주소가 위, 높은 주소가 아래로 출력됨
    /proc/<pid>/maps를 보면 스크롤을 아래로 내릴수록 주소가 커짐
    즉, “heap은 위로 자라고(stack은 아래로 자란다)”는 표현은 숫자상의 방향일 뿐,
    시각적으로는 오히려 반대임
    IDE처럼 아래로 갈수록 주소가 커지는 식으로 그리면 훨씬 직관적이라고 제안함

    • 스택은 어쨌든 스택 포인터가 감소하면서 자라므로 “아래로 자란다”는 표현이 여전히 맞다고 함
      다만 시각화는 가로 방향으로 하는 게 더 자연스럽다고 제안함
    • 자신도 예전에 같은 혼란을 겪었고, 리틀엔디언 주소 표기가 헷갈렸다고 회상함
    • 실제 사물의 쌓이는 방향을 생각하면 “스택이 아래로 자란다”는 표현이 직관적이지 않다고 반박함
  • 오래된 PIC16 마이크로컨트롤러로 이런 실험을 하는 걸 좋아한다고 함
    스택 포인터, 타이머, 변수 설정 등을 직접 다루는 게 재미있다고 느낌

  • shebang(#!) 관련 경험을 공유함
    Java 애플리케이션이 실행 스크립트를 찾지 못한다는 오류를 냈는데,
    실제 문제는 스크립트의 shebang 경로가 잘못된 것이었음
    로컬에서는 잘 실행됐지만, 원격 서버의 인터프리터 경로가 달라서 생긴 문제였음

    • 이건 Java만의 문제가 아니라, ENOENT 오류가 발생하는 모든 프로그램에서 생길 수 있다고 함
      strace로 실행하면 어떤 syscall에서 오류가 났는지 바로 확인 가능하다고 조언함
    • shebang의 구조를 분석한 글을 공유함: What the #! means
    • 커널에서 shebang을 지원하려면 CONFIG_BINFMT_SCRIPT=y 설정이 필요하다고 덧붙임
  • 디버깅 중에 메인 바이너리의 재배치 순서가 언제 적용되는지 항상 헷갈린다고 함
    링커가 자신의 심볼을 해결하기 전인지 후인지가 마치 블랙매직 같다고 표현함

  • 마크다운 내 “lang_start function (defined here)” 부분의 링크가 깨져 있음을 지적함