본문으로 이동
  • 현재 모든 문서는 작성 중입니다. 보시는 데 불편함이 있더라도 양해 부탁드립니다.

운영체제 기본/Linux의 ELF 로딩/ELF 세그먼트와 주소 공간

~ldmsys
관리자 (토론 | 기여)님의 2026년 10월 31일 (토) 08:46 판 (Linux v6.12 ELF 실행 이미지와 사용자 진입 설명)
(차이) ← 이전 판 | 최신판 (차이) | 다음 판 → (차이)

ELF 로딩 목차 · 이전: execve와 실행 이미지 교체 · 다음: 초기 스택과 사용자 모드 진입

ELF(Executable and Linking Format) 로딩은 실행 파일 전체를 연속된 물리 메모리에 복사하는 작업이 아니다. 커널은 파일의 프로그램 헤더를 읽고, 각 구간을 프로세스의 어느 가상 주소에 어떤 권한으로 배치할지 정한다. 실제 파일 페이지를 메모리에 가져오는 일은 상당 부분 첫 접근 때까지 미뤄진다.

이 절은 Linux v6.12의 x86-64 네이티브 ELF 실행을 기준으로 Linux의 ELF 로딩 중 주소 공간을 만드는 과정을 설명한다. BIOS가 커널을 시작한 방식과, 커널이 사용자 실행 파일을 배치하는 방식은 별개의 단계이다.

ELF 헤더와 프로그램 헤더

ELF 파일의 첫머리에는 파일 종류와 대상 아키텍처 등을 기술하는 ELF 헤더가 있다. e_type은 ET_EXEC, ET_DYN 같은 형식을, e_machine은 대상 명령어 집합을 나타낸다. e_entry는 ELF가 지정한 진입 주소이고, e_phoff, e_phentsize, e_phnum은 프로그램 헤더 테이블의 파일 내 위치, 항목 크기, 항목 수를 나타낸다.

load_elf_binary()는 ELF 매직, 지원하는 실행 형식과 아키텍처 등을 확인하고 load_elf_phdrs()를 통해 프로그램 헤더를 읽는다. 프로그램 헤더는 실행 시 필요한 정보를 제공하며, PT_LOAD는 메모리에 배치할 구간, PT_INTERP는 ELF 인터프리터의 경로, PT_GNU_STACK은 스택 실행 권한에 관한 요구를 기술한다.[1]

여기서 섹션(section)과 세그먼트(segment)를 구분해야 한다. .text, .rodata, .data, .bss 같은 섹션은 주로 컴파일·링크·디버깅 관점에서 파일을 나누는 단위이다. 실행 시 커널이 매핑의 근거로 삼는 것은 프로그램 헤더의 세그먼트이다. 하나의 PT_LOAD에는 여러 섹션이 들어갈 수 있고, 커널은 .text라는 이름을 찾아 그것만 복사하지 않는다. 실행에 필요한 프로그램 헤더가 유효하다면 섹션 헤더 테이블이 없는 실행 파일도 가능하다.[2]

ELF file
+------------------------+
| ELF header             | --> e_entry, e_phoff, e_phnum
+------------------------+
| Program header table   | --> PT_LOAD, PT_INTERP, ...
+------------------------+
| File bytes             | --> mapped by PT_LOAD
| .text / .rodata / ...   |
+------------------------+
| Section header table   | --> linker/debugger view
+------------------------+

PT_LOAD가 지정하는 대응 관계

하나의 PT_LOAD는 파일의 바이트 구간과 가상 메모리 구간 사이의 관계를 기술한다.

필드 의미
p_offset 세그먼트 데이터가 시작하는 파일 오프셋
p_vaddr ELF가 지정하는 세그먼트의 가상 주소. 재배치되는 이미지에는 load_bias가 더해진다.
p_filesz 파일에 실제로 저장된 데이터의 길이
p_memsz 메모리에서 필요한 전체 길이. p_filesz보다 작아서는 안 된다.
p_flags PF_R, PF_W, PF_X로 표현하는 읽기·쓰기·실행 요구
p_align 파일과 가상 주소의 정렬 관계. 0과 1은 추가 정렬 요구가 없음을 나타내며, 그 밖에는 유효한 2의 거듭제곱 정렬을 사용한다.

개념적인 대응은 다음과 같다. 여기서 B는 해당 ELF 이미지의 load_bias이다.

File interval:
  [p_offset, p_offset + p_filesz)
          |
          v
Virtual interval initialized from file:
  [B + p_vaddr, B + p_vaddr + p_filesz)

Virtual interval initialized to zero:
  [B + p_vaddr + p_filesz, B + p_vaddr + p_memsz)

페이지 테이블은 바이트가 아니라 페이지 단위로 매핑하므로 실제 매핑 시작과 끝은 페이지 경계에 맞춰진다. 파일 오프셋과 가상 주소의 페이지 내 오프셋이 서로 맞아야 파일 바이트를 올바른 위치에 대응시킬 수 있다. Linux의 elf_map()은 가상 주소를 아래쪽 페이지 경계로 내리고, p_offset에서도 p_vaddr의 페이지 내 오프셋만큼 뺀 파일 위치를 사용하며, 매핑 길이는 위쪽 페이지 경계로 올린다. ELF의 정렬 규칙은 임의의 파일 위치와 임의의 가상 주소를 그대로 이어 붙일 수 없다는 제약을 반영한다.[1]

예를 들어 페이지 크기를 0x1000으로 두고, p_offset=0x2000, p_vaddr=0x402000, p_filesz=0x1800, p_memsz=0x3800, B=0인 쓰기 가능한 세그먼트를 생각하자. 파일 데이터는 0x402000부터 0x403800 직전까지 대응된다. 파일 매핑은 페이지 경계인 0x404000까지 걸치지만, 0x403800 이후는 프로그램의 초기 데이터가 아니라 영 초기화 영역이다. 세그먼트의 논리적인 끝은 0x405800이며, 마지막 페이지의 매핑은 0x406000까지 이어질 수 있다.

Virtual address       Content
0x402000 +------------------------------+
         | File-backed initialized data |
0x403000 +------------------------------+
         | File data                    |
0x403800 +------------------------------+
         | Zeroed partial-page tail     |
0x404000 +------------------------------+
         | Anonymous zero-fill pages    |
0x405800 +------------------------------+  logical segment end
         | Page-granularity remainder   |
0x406000 +------------------------------+

BSS는 파일에서 복사해 오는가

초깃값이 0인 전역·정적 변수에 필요한 공간은 파일에 같은 양의 0을 저장하지 않아도 된다. ELF는 p_memsz를 p_filesz보다 크게 만들어 이 요구를 표현한다. 흔히 이를 BSS라고 부르지만, 커널은 .bss라는 섹션 이름이 아니라 이 두 크기의 차이를 보고 처리한다.

Linux v6.12의 elf_load()는 이 영역을 두 부분으로 다룬다.[1]

  • 파일 데이터가 끝나는 마지막 페이지의 나머지: 파일 매핑에 포함된 페이지 안에서 데이터 끝 이후를 padzero()로 지운다. 파일의 같은 페이지에 뒤따르는 다른 바이트가 있을 수 있으므로, 단순히 파일 페이지를 대응시켰다는 이유만으로 이 구간이 0이라고 가정할 수 없다. 이 쓰기는 페이지 폴트나 비공개 페이지 생성을 유발할 수 있다.
  • 그 뒤의 완전한 페이지들: 페이지 경계로 올린 영 초기화 시작점부터 세그먼트 끝을 포함하는 페이지까지 vm_brk_flags()로 익명 메모리 영역을 만든다. 이는 파일의 뒤쪽 바이트를 계속 매핑하는 것이 아니다. 익명 메모리의 영 초기화 의미는 가상 메모리 계층이 보장하며, 모든 물리 페이지를 이 시점에 즉시 할당한다는 뜻도 아니다.

p_filesz가 0인 세그먼트라면 복사할 파일 내용 자체가 없으므로 익명 영역을 만드는 경로로 처리한다. 반대로 p_memsz == p_filesz라면 세그먼트에 별도의 영 초기화 확장 구간이 없다. 위 설명은 일반적인 쓰기 가능한 데이터 세그먼트를 중심으로 한 것이다. 구현은 쓰기 불가능한 세그먼트의 padzero() 실패를 다르게 취급하고, 익명 꼬리 매핑에는 vm_brk_flags()의 권한 규칙이 적용되므로, 모든 기형 ELF에 대해 파일 부분과 꼬리 부분의 권한이 그대로 일치한다고 일반화하지 않는다.

파일 권한과 페이지 접근 권한

실행 파일을 열고 실행할 자격이 있는지 확인하는 일과, 매핑된 페이지에 어떤 접근을 허용하는지는 서로 다른 문제이다. 파일의 실행 권한은 실행 요청을 받아들일지를 판단하는 조건이며, PF_R, PF_W, PF_X는 세그먼트 매핑의 보호 속성으로 변환된다. make_prot()는 이를 PROT_READ, PROT_WRITE, PROT_EXEC 등으로 옮기며 아키텍처별 조정도 거친다.

일반적인 실행 파일은 코드에 읽기·실행, 데이터에 읽기·쓰기를 요청한다. x86-64에서는 사용자 접근 여부, 쓰기 허용 여부, 실행 금지 속성 등이 페이지 테이블의 보호와 연결된다. 페이지에 실행 권한이 없는데 명령어를 가져오거나, 쓰기 불가능한 페이지에 쓰기를 시도하면 보호 위반이 발생한다. 세부 원리는 보호와 가상 메모리에서 다룬다.

ELF 로더의 파일 매핑은 MAP_PRIVATE를 사용한다. 따라서 프로세스가 쓰기 가능한 데이터에 값을 저장한다고 해서 실행 파일의 원본 바이트가 덮어써지지는 않는다. 파일 페이지를 공유하다가 쓰기가 필요한 시점에 사본을 만드는 COW 동작과 연결되며, BSS의 부분 페이지 영 초기화도 이 비공개 매핑이라는 성질 안에서 이해해야 한다.

ET_EXEC, ET_DYN과 load_bias

ET_EXEC 실행 파일은 통상 링크 시 지정된 가상 주소를 기준으로 배치된다. x86-64의 이 경로에서는 주 실행 이미지의 load_bias가 0이며, 첫 PT_LOAD에 MAP_FIXED_NOREPLACE를 사용해 요청 위치의 충돌을 확인한다. 이후 세그먼트는 계산된 배치에 따라 매핑한다. 이 말은 프로세스의 스택이나 공유 라이브러리까지 모두 고정 주소라는 뜻은 아니다.

ET_DYN은 실행 시 배치 기준을 정할 수 있는 형식이다. 공유 라이브러리뿐 아니라 PIE(Position-Independent Executable) 실행 파일도 이 형식을 사용한다. 따라서 ET_DYN이면 라이브러리이고 실행 파일은 아니다라는 분류는 틀리다. 정해진 load_bias는 같은 이미지의 모든 세그먼트에 공통으로 더해지므로 세그먼트 사이의 상대적 거리는 유지된다.

Linux v6.12는 PT_INTERP가 있는 ET_DYN과 없는 ET_DYN의 배치 경로를 구분한다. 전자는 ELF_ET_DYN_BASE를 기준으로 필요하면 주소 공간 배치 난수화(ASLR)를 적용하고 정렬을 조정한다. 후자는 mmap 영역의 배치 선택을 활용하며, 큰 정렬 요구가 있으면 추가 계산을 수행한다. 동적 로더를 직접 실행하는 경우뿐 아니라 인터프리터 없는 정적 PIE도 고려해야 하므로, 인터프리터가 없는 모든 ET_DYN을 동적 로더라고 단정해서는 안 된다.[1]

total_mapping_size()는 PT_LOAD들의 전체 주소 범위를 계산한다. ET_DYN의 첫 배치에서는 이 크기를 고려해 다른 매핑과의 충돌을 피할 공간을 찾고, 첫 세그먼트에 필요하지 않은 나머지 임시 매핑은 해제한다. 이것은 실행 파일 전체를 물리 메모리에 읽어 들인다는 뜻이 아니라, 뒤따르는 세그먼트들을 일관된 위치에 배치하기 위한 주소 범위 계산이다.

ASLR도 무조건 적용되는 성질이 아니다. 시스템의 randomize_va_space 설정, 태스크의 ADDR_NO_RANDOMIZE 성격, 실행 파일 형식과 아키텍처 구현이 영향을 준다. 세그먼트 배치를 바꾸는 load_bias 계산과, 동적 링커가 데이터 안의 주소를 고치는 재배치 처리는 서로 다른 층의 작업이다.

매핑 완료와 페이지 준비 완료는 다르다

elf_map()은 vm_mmap()을 이용한다. 핵심 결과는 가상 주소 구간, 파일 오프셋, 보호 속성을 가진 매핑이 만들어지는 것이다. 이때 실행 코드와 데이터의 모든 페이지에 물리 페이지가 이미 연결되어 있어야 하는 것은 아니다.

ELF loader
    |
    v
File-backed VMA created
    |
    v
First instruction fetch or data access
    |
    v
Page fault, if no usable PTE exists
    |
    +--> page cache hit: use resident file page
    |
    +--> page cache miss: obtain file data
    |
    v
Install PTE and retry access

헤더 읽기, 인자·환경 문자열 작성, BSS의 부분 페이지 지우기처럼 로딩 도중 실제 메모리 접근이 이루어지는 부분도 있다. 그러므로 “exec 동안은 아무 페이지도 읽지 않는다”와 “exec가 파일 전체를 미리 읽는다”는 설명은 모두 부정확하다. 코드의 첫 명령어를 가져오는 순간에도 페이지 폴트가 날 수 있으며, 페이지 캐시에 내용이 이미 있다면 저장 장치 I/O 없이 처리될 수 있다. 구체적인 파일 폴트 경로는 첫 접근과 페이지 폴트로 이어진다.

이 단계가 끝나면 프로그램이 실행될 주소 공간의 골격이 마련된다. 그 주소 공간에서 어디부터 실행하고 어떤 초기 스택을 제공할지는 초기 스택과 사용자 모드 진입의 주제이다.

참고 문헌

  1. ↑ 1.0 1.1 1.2 1.3 Linux v6.12, fs/binfmt_elf.c, load_elf_phdrs(), load_elf_binary(), elf_map(), elf_load().
  2. ↑ Linux man-pages, elf(5), ELF 헤더와 프로그램 헤더.