운영체제 기본/Linux의 ELF 로딩/ELF 세그먼트와 주소 공간
ELF 로딩 목차 · 이전: execve와 실행 이미지 교체 · 다음: 초기 스택과 사용자 모드 진입
실행 파일은 디스크에 저장된 바이트이고, CPU는 메모리 주소를 통해 명령어를 읽는다. ELF 로더의 역할은 실행 파일의 내용을 프로세스의 가상 주소 공간에 연결하여 실행할 준비를 만드는 것이다. 파일 전체를 물리 메모리에 복사하는 것보다는 파일의 어느 부분을 어느 가상 주소에서 사용할지 정한다고 이해하는 편이 정확하다.
여기서는 먼저 정적 링크된 ET_EXEC 실행 파일을 기준으로 설명한다. ET_EXEC는 ELF 파일의 종류이고, 아래에서 다루는 PT_LOAD는 그 파일 안의 프로그램 헤더 종류이다. 기준 구현은 Linux v6.12 x86-64이다.
ELF 헤더에서 배치 정보를 찾는다
ELF 파일의 맨 앞에는 ELF 헤더가 있다. 커널의 load_elf_binary()는 이 파일이 지원하는 ELF 형식과 아키텍처인지 확인하고, 헤더가 가리키는 프로그램 헤더 테이블을 읽는다. ELF 헤더의 e_entry는 실행을 시작할 주소이며, e_phoff와 e_phnum은 프로그램 헤더 테이블의 파일 위치와 항목 수를 알려 준다.[1]
프로그램 헤더 중 PT_LOAD는 메모리에 배치할 구간을 나타낸다. 실행 파일은 보통 코드, 읽기 전용 데이터, 쓰기 가능한 데이터처럼 성질이 다른 구간을 여러 PT_LOAD로 나눈다. 커널은 각 항목을 읽어 해당 구간을 가상 주소 공간에 배치한다.
ELF file (static ET_EXEC) +----------------------------------------+ | ELF header | | e_entry = 0x401000 | | e_phoff: offset of program headers | +----------------------------------------+ | Program headers | | PT_LOAD R-X | | offset=0x1000, vaddr=0x401000 | | filesz=0x1000, memsz=0x1000 | | PT_LOAD RW- | | offset=0x2000, vaddr=0x402000 | | filesz=0x0800, memsz=0x2000 | +----------------------------------------+ | ... | | 0x1000: code bytes | | ... | | 0x2000: initialized data bytes | | 0x2800: end of initialized data | +----------------------------------------+
이때 섹션과 세그먼트를 구분해야 한다. .text, .data, .bss 같은 섹션은 컴파일·링크 과정에서 사용하는 구분이다. 실행 시 커널이 배치의 기준으로 사용하는 것은 프로그램 헤더의 세그먼트이다. 하나의 세그먼트에 여러 섹션이 포함될 수도 있으며, 커널이 .text라는 이름을 찾아 복사하는 것은 아니다.
PT_LOAD는 파일과 가상 주소를 연결한다
PT_LOAD의 핵심 필드는 다음과 같다.
| 필드 | 의미 |
|---|---|
p_offset |
데이터가 시작하는 파일 오프셋 |
p_vaddr |
데이터를 배치할 가상 주소 |
p_filesz |
파일에 저장된 데이터의 크기 |
p_memsz |
메모리에서 필요한 전체 크기 |
p_flags |
읽기·쓰기·실행 권한 |
p_align |
파일 위치와 가상 주소의 정렬 조건 |
지금의 ET_EXEC 예에서는 파일의 p_offset부터 p_filesz바이트가 가상 주소 p_vaddr부터 보이도록 배치한다. p_memsz가 더 크면 그 차이만큼은 0으로 시작하는 메모리가 필요하다.
위 그림의 실행 파일을 페이지 크기 0x1000인 주소 공간에 배치하면 다음과 같다. 코드 세그먼트는 파일과 메모리의 크기가 같고, 데이터 세그먼트는 메모리에서 더 큰 공간을 요구한다.
File offset Virtual address
0x1000 +------------------+ 0x401000 +------------------+
| Code, 0x1000 | ----------> | Code, R-X |
0x2000 +------------------+ 0x402000 +------------------+
| Data, 0x0800 | ----------> | Data, RW- |
0x2800 +------------------+ 0x402800 +------------------+
| Zero-filled BSS |
0x404000 +------------------+
Entry: 0x401000
코드는 가상 주소 0x401000부터 0x402000 직전까지 대응된다. 초기값이 있는 데이터는 0x402000부터 0x402800 직전까지이고, 그 뒤 0x404000 직전까지는 0으로 시작해야 한다. 파일 오프셋은 파일 안의 위치이고, 가상 주소는 프로그램이 사용하는 주소이며, 둘 중 어느 것도 물리 메모리 주소를 뜻하지 않는다.
실제 메모리 매핑은 페이지 단위이므로 시작과 끝을 페이지 경계에 맞춰 처리한다. 파일 오프셋과 가상 주소의 페이지 내 위치가 맞아야 같은 바이트가 올바르게 대응된다. Linux의 elf_map()은 이 정렬을 반영해 vm_mmap()으로 파일 매핑을 만든다.[1]
파일에 없는 BSS는 0으로 준비한다
초기값이 0인 전역·정적 변수에 필요한 공간을 파일에 모두 0으로 저장할 필요는 없다. p_memsz를 p_filesz보다 크게 두면 커널은 추가 구간을 0으로 준비한다. 흔히 BSS라고 부르는 부분이며, 커널은 섹션 이름이 아니라 이 두 크기의 차이를 사용한다.
앞의 예에서 파일 데이터의 끝인 0x402800은 페이지 중간에 있다. 그래서 영 초기화는 두 부분으로 나뉜다.
0x402800부터 다음 페이지 경계인0x403000까지는 이미 파일 매핑에 포함된 마지막 페이지의 나머지이다.padzero()로 이 부분을 지워, 파일의 뒤따르는 바이트가 초기값으로 보이지 않게 한다.0x403000부터0x404000직전까지는 파일에 연결할 내용이 없는 익명 메모리로 만든다.elf_load()는vm_brk_flags()를 이용하며, 마지막 페이지는 세그먼트 끝을 포함하도록 확보한다.[1]
즉 BSS는 파일의 뒤쪽을 더 읽어 오는 영역이 아니다. 파일 데이터가 끝난 페이지의 일부는 직접 0으로 만들고, 나머지는 0으로 시작하는 익명 메모리의 성질을 이용한다. 이때 모든 물리 페이지를 즉시 할당해야 하는 것은 아니다.
접근 권한과 실제 페이지 준비
커널은 p_flags의 PF_R, PF_W, PF_X를 읽기·쓰기·실행에 해당하는 매핑 속성으로 바꾼다. 일반적으로 코드는 읽기·실행, 수정할 데이터는 읽기·쓰기로 배치한다. 이 속성은 페이지 테이블의 접근 제어로 이어지며, 허용되지 않은 접근은 보호 위반으로 처리된다. 이는 보호와 가상 메모리에서 설명한 원리가 실행 파일에 적용되는 사례이다.
ELF의 파일 매핑은 사적 매핑인 MAP_PRIVATE를 사용한다. 프로세스가 데이터 값을 바꾸더라도 디스크의 실행 파일을 수정하지 않으며, 필요하면 해당 프로세스만의 페이지 사본을 만든다.
매핑을 만들었다는 것은 주소 구간과 그 의미를 정했다는 뜻이다. 모든 파일 페이지를 RAM에 읽어 왔다는 뜻은 아니다. 처음 명령어를 가져오거나 데이터를 읽을 때 아직 페이지가 연결되지 않았다면 페이지 폴트가 발생하고, 커널이 파일 페이지를 찾아 페이지 테이블을 채운 뒤 접근을 재개한다. 페이지 캐시에 내용이 있으면 저장 장치에서 다시 읽지 않아도 된다.
반대로 헤더 읽기나 마지막 페이지의 영 초기화처럼 로딩 중 실제 접근이 일어나는 부분도 있다. 따라서 “전부 미리 읽는다”와 “로딩 중에는 아무 페이지도 읽지 않는다”는 설명은 모두 부정확하다. 자세한 경로는 첫 접근과 페이지 폴트에서 다룬다.
이제 실행 파일의 코드와 데이터를 사용할 주소 공간이 준비되었다. 다음은 초기 스택과 사용자 모드 진입에서 인자와 스택을 만들고 CPU가 실행을 시작할 상태를 준비하는 과정이다.
다른 주소에 배치하는 경우
지금까지는 ET_EXEC의 지정된 가상 주소를 그대로 사용하는 경우를 설명했다. ET_DYN 형식은 실행 시 다른 기준 주소에 배치할 수 있으며, 이때 세그먼트 주소와 진입 주소에 공통으로 더하는 값이 load_bias이다. 실행할 때마다 배치 주소를 달리하는 ASLR에도 이 방식이 사용된다. 다만 다른 위치에 배치할 수 있다는 성질이 ASLR에만 쓰이는 것은 아니다.
이 추가 처리가 있어도 기본 원리는 같다. 배치 주소를 정하고, PT_LOAD에 따라 파일과 영 초기화 영역을 연결한 뒤, 초기 스택과 진입 상태를 준비한다. 동적 링커가 필요한 프로그램의 시작은 다음 절의 기본 실행 흐름을 설명한 뒤 살펴본다.
참고 문헌
- ↑ 1.0 1.1 1.2 Linux v6.12, fs/binfmt_elf.c,
load_elf_binary(),load_elf_phdrs(),elf_map(),elf_load().