Linux에서 execve 호출부터 ELF 로딩, 스택과 보조 벡터(auxv) 설정, 엔트리포인트(_start)와 언어별 런타임 초기화를 거쳐 main()이 실행되기까지의 과정을 간결하게 정리한다.
얼마 전 재미 삼아 RISC‑V 기반 유저스페이스 시뮬레이터를 만들었다. 그 과정에서 커널이 어떤 프로그램을 실행하라는 요청을 받는 순간과 우리 프로그램의 main 함수 첫 줄이 실제로 실행되는 순간 사이에 무슨 일이 벌어지는지, 처음 의도보다 훨씬 더 많은 것을 배우게 됐다. 여기 그 토끼굴의 요약을 남긴다.
첫 번째 질문: 운영체제 커널은 정확히 언제 “프로그램을 실행하라”고 요청받을까? 적어도 Linux에서는 그 답이 execve 시스템 호출(“syscall”)이다. 간단히 살펴보자.
int execve(const char *filename, char *const argv[], char *const envp[]);
사실 꽤 직관적이다! 실행 파일의 이름, 인자 목록, 환경 변수 목록을 전달한다. 이는 커널에게 프로그램을 어디서, 어떻게 로드해야 하는지 신호를 보낸다.
많은 프로그래밍 언어는 내부적으로 결국 execve를 호출하는 명령 실행 인터페이스를 제공한다. 예를 들어 Rust에서는 다음과 같다.
use std::process::Command;
Command::new("ls").arg("-l").spawn();
이런 상위 추상화에서는 표준 라이브러리가 셸이 PATH 환경 변수를 통해 명령을 해석하듯, 명령 이름을 전체 경로로 변환해 준다. 반면 커널은 올바른 실행 파일의 경로를 기대한다.
인터프리터에 대한 메모: 실행 파일이 셰뱅(
#!)으로 시작하면 커널은 그 셰뱅에 지정된 인터프리터로 프로그램을 실행한다. 예를 들어#!/usr/bin/python3는 Python 인터프리터로,#!/bin/bash는 Bash 셸로 프로그램을 실행한다.
실행 파일은 어떻게 생겼을까? Linux에서는 커널이 파싱할 수 있는 ELF다. 다른 운영체제는 다른 포맷을 쓴다(예: macOS는 Mach-O, Windows는 PE). 여기서는 길게 파지 않고 간단히만 보자. ELF는 원래의 a.out 포맷에서 발전했으며, 여러분이 쓸 대부분의 프로그램을 표현할 만큼 충분히 강력하다. 다음은 ELF 파일 헤더의 예다.
% readelf -h main # main은 ELF 파일
ELF Header:
Magic: 7f 45 4c 46 01 01 01 03 00 00 00 00 00 00 00 00
Class: ELF32
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - GNU
ABI Version: 0
Type: EXEC (Executable file)
Machine: RISC-V
Version: 0x1
Entry point address: 0x10358
Start of program headers: 52 (bytes into file)
Start of section headers: 675776 (bytes into file)
Flags: 0x1, RVC, soft-float ABI
Size of this header: 52 (bytes)
Size of program headers: 32 (bytes)
Number of program headers: 7
Size of section headers: 40 (bytes)
Number of section headers: 32
Section header string table index: 31
여기서 중요한 부분은 다음과 같다.
45 4c 46은 ASCII로 “ELF”!ELF 파일마다 항목과 값은 다르지만, 우리가 여기서 보는 건 전반적인 구조다.
여기저기 “RISC‑V”가 보이듯, 이는 RV32 아키텍처(앞서 언급한 에뮬레이터 대상)로 컴파일·링크한 ELF 파일이다. 그래서 “ELF32”, “RVC” 플래그, “RISC‑V” 머신 타입이 나온다.
ELF 파일은 코드, 데이터, 심볼 등 프로그램 실행에 필요한 모든 것을 담고 있다. readelf에 -a 플래그를 주면 더 볼 수 있다. 우리가 신경 쓸 것만 뽑아보면:
Section Headers:
[Nr] Name Type Addr Off Size
[ 0] NULL 00000000 000000 000000
[ 1] .note.ABI-tag NOTE 00010114 000114 000020
[ 2] .rela.plt RELA 00010134 000134 00000c
[ 3] .plt PROGBITS 00010140 000140 000010
[ 4] .text PROGBITS 00010150 000150 03e652
[ 5] .rodata PROGBITS 0004e7b0 03e7b0 01b208
...
[16] .data PROGBITS 0007a008 069008 000dec
[17] .sdata PROGBITS 0007adf4 069df4 000004
[18] .bss NOBITS 0007adf8 069df8 002b6c
...
[29] .symtab SYMTAB 00000000 095124 009040
[30] .strtab STRTAB 00000000 09e164 006d10
이들 섹션에는 코드(.text), 데이터(.data), 전역 변수용 공간(.bss), 공유 라이브러리 함수 접근을 위한 셈(PLT, .plt) 등 많은 것이 담긴다(디버깅용 심볼 테이블, 재배치 테이블 등도 있지만 여기서는 다루지 않는다).
겉보기에 .text 섹션의 코드만 복사하면 끝날 것 같지만, 사실 커널 내부에는 각양각색의 프로그램을 온갖 조건에서 실행하게 해 주는 방대한 기계 장치가 있다.
예를 들어 “PLT”(Procedure Linkage Table)는 libc 같은 “공유 라이브러리”의 함수를 프로그램과 함께 묶지 않고도 호출할 수 있게 해 준다(“정적 링크”가 아닌 “동적 링크”). ELF 파일의 동적 섹션에는 커널이 어떤 공유 라이브러리를 로드해야 하는지가 들어 있다.
libc는 C 표준 라이브러리로,printf,malloc같은 “유용한” 함수가 들어 있다.libc인터페이스를 구현한 구현체는 여러 가지가 있는데, 가장 흔한 것은glibc와musl이다. 이 글의 바이너리 대부분은 정적 링크가 더 쉬운 관계로musl에 맞춰 컴파일·링크했다.
심볼 테이블은 대략 다음과 같이 생겼다.
Symbol table '.symtab' contains 2308 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 00000000 0 NOTYPE LOCAL DEFAULT UND
1: 00010114 0 SECTION LOCAL DEFAULT 1 .note.ABI-tag
2: 00010134 0 SECTION LOCAL DEFAULT 2 .rela.plt
3: 00010140 0 SECTION LOCAL DEFAULT 3 .plt
4: 00010150 0 SECTION LOCAL DEFAULT 4 .text
...
1782: 00010358 30 FUNC GLOBAL HIDDEN 4 _start
...
1917: 00010430 52 FUNC GLOBAL DEFAULT 4 main
2201: 00010506 450 FUNC GLOBAL HIDDEN 4 __libc_start_main
...
아마 이렇게 묻고 싶을 것이다. “와, 2308개나? 도대체 어떤 괴물 프로그램이 그 많은 심볼을 필요로 하지?”
좋은 질문이다! 괴물은 이거다.
#include <stdio.h>
int main() {
printf("Hello, World!\n");
return 0;
}
그게 다다. musl을 사용해 링크했기 때문에 glibc에 비해 심볼 수가 약간 더 부푼 면이 있긴 하지만, 요지는 같다. 뒤에서 엄청나게 많은 일이 벌어지고 있다는 것이다.
커널의 역할은 각 섹션을 훑으며 “로드 가능” 표시가 된 섹션을 메모리에 올리고, PT_INTERP 섹션이 있으면 거기에 지정된 “인터프리터”로 점프하는 것이다. ELF의 경우, 이는 함수 포인터를 동적으로 “재배치”하고, 메모리 내 섹션을 이리저리 옮겨 보안 완화를 수행(ASLR — Address Space Layout Randomization), 섹션을 실행 불가로 표시(NX 비트 — 하드웨어 차원의 보안)하는 등의 일을 담당하는 ELF 인터프리터다. 궁극적으로 커널은 코드와 데이터를 메모리에 로드하고, 스택을 준비한 다음, 프로그램의 엔트리포인트로 점프한다.
악명 높은 스택! 우리 대부분은 스택을 당연하게 여긴다. 하지만 커널에게 스택은 저절로 생기는 마법 공간이 아니다 — 프로그램이 실행되기 전에 제대로 세팅되어야 한다.
상기하자면, 스택 공간은 보통 변수, 함수 인자, “프레임”(함수 지역 변수, 호출 트리 추적 등)을 비롯해 프로그램의 종류와 실행 방식에 따라 다양한 용도로 쓰인다.
가령 단순화를 위해, ELF 파일이 주소 0부터 메모리에 로드된다고 치자. 스택은 보통 메모리의 “반대쪽 끝”, 높은 주소 쪽에 놓이고, 낮은 주소 쪽으로 “아래로” 자라난다. 그 사이 공간은 힙이나 기타 데이터(공유 라이브러리, mmap된 파일 등)에 쓰인다. 물론 단순화고, 실제 의미론은 프로그램에 크게 좌우된다.
스택은 비어 있지도 않다! 위에서 본 execve의 argv와 envp를 기억하자. 이들은 스택을 통해 프로그램에 전달된다. 대부분의 언어에서 우리는 이를 각종 args와 env 도구로 접근한다. C에서는 직접, Rust(std::env)나 Python(sys.argv)에서는 좀 더 간접적으로.
커널은 또 “ELF 보조 벡터(auxiliary vector)”라는 것도 초기 스택에 넣어 둔다. 이 “auxv”에는 메모리 페이지 크기, ELF 파일 메타데이터, 기타 시스템 정보 같은 환경 정보가 들어 있다. 이건 중요하다! 예를 들어 musl은 auxv의 “페이지 크기” 항목을 이용해 malloc이 메모리를 더 효율적으로 요청·관리하도록 한다. 보조 벡터에는 30개가 넘는 항목이 있지만, 모든 프로그램이 전부 쓰는 것은 아니며(커널이 정의하지 않는 항목도 있다).
커널이라고 가정해 보자. 새 프로세스의 스택을 다음처럼 설정할 수 있다(내 RISC‑V 에뮬레이터에서, 커널 일부를 흉내 낸 코드를 발췌·단순화한 것이다).
// 스택을 위한 임의의 높은 주소를 고른다
let mut sp = 0xCFFF_F000u32; // sp = "stack pointer"
let mut stack_init: Vec<u32> = vec![]; // 스택은 처음엔 비어 있다.
stack_init.push(args.len()); // argc: 인자의 개수
for &arg in args.iter().rev() {
// 각 인자를 스택에 복사
sp -= arg.len() // 주소 공간에서 "아래로" 이동
mem.copy_to(sp, arg);
// 초기 벡터에 인자 포인터를 기록
stack_init.push(sp);
}
stack_init.push(0); // argv NULL 종단자
// 환경 변수도 비슷하다
for &e in env.iter().rev() {
sp -= e.len();
mem.copy_to(sp, e);
stack_init.push(sp);
}
stack_init.push(0); // envp NULL 종단자
// 보조 벡터(auxv) 설정
stack_init.push(libc_riscv32::AT_PAGESZ); // auxv의 키
stack_init.push(0x1000); // auxv의 값; 4 KiB 페이지 크기 지정
stack_init.push(libc_riscv32::AT_ENTRY);
stack_init.push(self.pc); // 주의: 여기에 다시 돌아올 것
// ...
// 포인터를 담은 초기 스택 벡터를 스택에 복사
sp -= (stack_init.len() * 4);
mem.copy_to(sp, &stack_init)
이 시점의 주소 공간이 어떻게 생겼는지는 다이어그램이 있으면 더 이해가 쉬울 것이다.
마침내 여러 번 언급했던 “엔트리포인트” 주소에 도달한다. 이는 프로세스에서 가장 먼저 실행할 명령의 주소다. 보통 _start라는 함수 아래 있다. glibc와 musl은 모두 _start 구현을 제공하지만, 직접 작성할 수도 있다. 다시 Rust 예시를 보자.
// 언어 런타임을 비활성화한다. 우리가 직접 만든다.
#![no_std]
#![no_main]
#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
loop {}
}
#[no_mangle]
pub extern "C" fn _start() -> ! {
// main을 "기다리는" 대신 곧바로 실행을 시작할 수 있다.
loop {}
}
프로그램에 따라 _start가 엔트리포인트와 main 사이의 유일한 다리일 수도 있지만, 대부분의 언어에는 먼저 초기화해야 하는 일종의 런타임이 있다. 예컨대 Rust에는 std::rt::lang_start가 있다. 이 시점에서 전역 생성자, 스레드 로컬 저장소, 기타 언어별 기능이 설정된다.
여기서부터는 이야기의 종착지다 — 이 지점 이후로는 언어별 내용이 훨씬 많아진다. 대부분의 언어는 각각의 런타임을 설정하고(C와 C++에도 “런타임”이 있다!), 결국 우리가 익숙한 표준 main 함수를 호출한다.
Rust에서는 생성되는 코드가 대략 다음처럼 생긴다.
// 사용자가 정의한 main 함수
fn main() { println!("Hello, world!"); }
// 생성된 _start 함수
fn _start() -> {
let argc = ...; // 스택에서 argc 읽기
let argv = ...; // 스택에서 argv 읽기
let envp = ...; // 스택에서 envp 읽기
let main_fn = main; // 사용자 main 함수에 대한 포인터
std::rt::lang_start(argc, argv, main_fn);
}
lang_start 함수는 여기에 정의되어 있으며 나머지를 처리한다.
C와 C++도 비슷하게 최소한의 셋업을 한다. Java나 Python처럼 전통적으로 “무거운” 런타임을 가진 언어들도 원리는 같지만, Rust/C/C++의 std::rt::lang_start에 대응하는 부분이 훨씬 많은 일을 할 뿐이다.
그리고 바로 그거다! 많은 세부를 생략했지만, main()이 호출되기 전에 무슨 일이 벌어지는지 대략 감을 잡는 데 도움이 되었기를 바란다. 실제 Linux 커널 내부에 가까운 복잡한 요소들 — 예를 들어 커널이 주소 공간을 어떻게 구성하는지, 프로세스 테이블과 각종 그룹 의미론은 어떤지 등 — 은 생략했지만, 그래도 입문용 길잡이로 쓸 만하길 바란다.
질문이나 정정이 있다면 언제든 연락해 달라!
이 글의 초기 버전은 섹션 로딩 로직의 일부를 커널의 역할로 잘못 기술했는데, 실제로는 ELF 인터프리터의 책임이다. 정정해 준 Hacker News 사용자 “fweimer”에게 감사드린다.