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

운영체제 기본/Linux의 ELF 로딩/ELF 세그먼트와 주소 공간: 두 판 사이의 차이

~ldmsys
Linux v6.12 ELF 실행 이미지와 사용자 진입 설명
 
BIOS GRUB 부팅 경로 보강과 각론 형식 통일
 
1번째 줄: 1번째 줄:
[[운영체제 기본/Linux의 ELF 로딩|ELF 로딩 목차]] · 이전: [[운영체제 기본/Linux의 ELF 로딩/execve와 실행 이미지 교체|execve와 실행 이미지 교체]] · 다음: [[운영체제 기본/Linux의 ELF 로딩/초기 스택과 사용자 모드 진입|초기 스택과 사용자 모드 진입]]
[[운영체제 기본/Linux의 ELF 로딩|ELF 로딩 목차]] · 이전: [[운영체제 기본/Linux의 ELF 로딩/execve와 실행 이미지 교체|execve와 실행 이미지 교체]] · 다음: [[운영체제 기본/Linux의 ELF 로딩/초기 스택과 사용자 모드 진입|초기 스택과 사용자 모드 진입]]


ELF(Executable and Linking Format) 로딩은 실행 파일 전체를 연속된 물리 메모리에 복사하는 작업이 아니다. 커널은 파일의 프로그램 헤더를 읽고, 각 구간을 프로세스의 어느 가상 주소에 어떤 권한으로 배치할지 정한다. 실제 파일 페이지를 메모리에 가져오는 일은 상당 부분 첫 접근 때까지 미뤄진다.
실행 파일은 디스크에 저장된 바이트이고, CPU는 메모리 주소를 통해 명령어를 읽는다. ELF 로더의 역할은 실행 파일의 내용을 프로세스의 가상 주소 공간에 연결하여 실행할 준비를 만드는 것이다. 파일 전체를 물리 메모리에 복사하는 것보다는 '''파일의 어느 부분을 어느 가상 주소에서 사용할지 정한다'''고 이해하는 편이 정확하다.


이 절은 Linux v6.12의 x86-64 네이티브 ELF 실행을 기준으로 [[운영체제 기본/Linux의 ELF 로딩|Linux의 ELF 로딩]] 중 주소 공간을 만드는 과정을 설명한다. BIOS가 커널을 시작한 방식과, 커널이 사용자 실행 파일을 배치하는 방식은 별개의 단계이다.
여기서는 먼저 정적 링크된 <code>ET_EXEC</code> 실행 파일을 기준으로 설명한다. <code>ET_EXEC</code>는 ELF 파일의 종류이고, 아래에서 다루는 <code>PT_LOAD</code>는 그 파일 안의 프로그램 헤더 종류이다. 기준 구현은 Linux v6.12 x86-64이다.


== ELF 헤더와 프로그램 헤더 ==
== ELF 헤더에서 배치 정보를 찾는다 ==


ELF 파일의 첫머리에는 파일 종류와 대상 아키텍처 등을 기술하는 ELF 헤더가 있다. <code>e_type</code>은 <code>ET_EXEC</code>, <code>ET_DYN</code> 같은 형식을, <code>e_machine</code>은 대상 명령어 집합을 나타낸다. <code>e_entry</code>는 ELF가 지정한 진입 주소이고, <code>e_phoff</code>, <code>e_phentsize</code>, <code>e_phnum</code>은 프로그램 헤더 테이블의 파일 내 위치, 항목 크기, 항목 수를 나타낸다.
ELF 파일의 맨 앞에는 ELF 헤더가 있다. 커널의 <code>load_elf_binary()</code>는 이 파일이 지원하는 ELF 형식과 아키텍처인지 확인하고, 헤더가 가리키는 프로그램 헤더 테이블을 읽는다. ELF 헤더의 <code>e_entry</code>는 실행을 시작할 주소이며, <code>e_phoff</code>와 <code>e_phnum</code>은 프로그램 헤더 테이블의 파일 위치와 항목 수를 알려 준다.<ref name="elf">[https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/fs/binfmt_elf.c?h=v6.12 Linux v6.12, fs/binfmt_elf.c], <code>load_elf_binary()</code>, <code>load_elf_phdrs()</code>, <code>elf_map()</code>, <code>elf_load()</code>.</ref>


<code>load_elf_binary()</code>는 ELF 매직, 지원하는 실행 형식과 아키텍처 등을 확인하고 <code>load_elf_phdrs()</code>를 통해 프로그램 헤더를 읽는다. 프로그램 헤더는 실행 시 필요한 정보를 제공하며, <code>PT_LOAD</code>는 메모리에 배치할 구간, <code>PT_INTERP</code>는 ELF 인터프리터의 경로, <code>PT_GNU_STACK</code>은 스택 실행 권한에 관한 요구를 기술한다.<ref name="elf-src">[https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/fs/binfmt_elf.c?h=v6.12 Linux v6.12, fs/binfmt_elf.c], <code>load_elf_phdrs()</code>, <code>load_elf_binary()</code>, <code>elf_map()</code>, <code>elf_load()</code>.</ref>
프로그램 헤더 중 <code>PT_LOAD</code>는 메모리에 배치할 구간을 나타낸다. 실행 파일은 보통 코드, 읽기 전용 데이터, 쓰기 가능한 데이터처럼 성질이 다른 구간을 여러 <code>PT_LOAD</code>로 나눈다. 커널은 각 항목을 읽어 해당 구간을 가상 주소 공간에 배치한다.
 
여기서 '''섹션(section)과 세그먼트(segment)를 구분해야 한다'''. <code>.text</code>, <code>.rodata</code>, <code>.data</code>, <code>.bss</code> 같은 섹션은 주로 컴파일·링크·디버깅 관점에서 파일을 나누는 단위이다. 실행 시 커널이 매핑의 근거로 삼는 것은 프로그램 헤더의 세그먼트이다. 하나의 <code>PT_LOAD</code>에는 여러 섹션이 들어갈 수 있고, 커널은 <code>.text</code>라는 이름을 찾아 그것만 복사하지 않는다. 실행에 필요한 프로그램 헤더가 유효하다면 섹션 헤더 테이블이 없는 실행 파일도 가능하다.<ref>[https://man7.org/linux/man-pages/man5/elf.5.html Linux man-pages, elf(5)], ELF 헤더와 프로그램 헤더.</ref>


<pre>
<pre>
ELF file
ELF file (static ET_EXEC)
+------------------------+
+----------------------------------------+
| ELF header             | --> e_entry, e_phoff, e_phnum
| ELF header                             |
+------------------------+
|   e_entry = 0x401000                  |
| Program header table   | --> PT_LOAD, PT_INTERP, ...
|  e_phoff: offset of program headers  |
+------------------------+
+----------------------------------------+
| File bytes             | --> mapped by PT_LOAD
| Program headers                        |
| .text / .rodata / ...  |
|   PT_LOAD R-X                          |
+------------------------+
|    offset=0x1000, vaddr=0x401000      |
| Section header table  | --> linker/debugger view
|    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        |
+----------------------------------------+
</pre>
</pre>


== PT_LOAD가 지정하는 대응 관계 ==
이때 섹션과 세그먼트를 구분해야 한다. <code>.text</code>, <code>.data</code>, <code>.bss</code> 같은 섹션은 컴파일·링크 과정에서 사용하는 구분이다. 실행 시 커널이 배치의 기준으로 사용하는 것은 프로그램 헤더의 세그먼트이다. 하나의 세그먼트에 여러 섹션이 포함될 수도 있으며, 커널이 <code>.text</code>라는 이름을 찾아 복사하는 것은 아니다.
 
== PT_LOAD는 파일과 가상 주소를 연결한다 ==


하나의 <code>PT_LOAD</code>는 파일의 바이트 구간과 가상 메모리 구간 사이의 관계를 기술한다.
<code>PT_LOAD</code>의 핵심 필드는 다음과 같다.


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


개념적인 대응은 다음과 같다. 여기서 <code>B</code>는 해당 ELF 이미지의 <code>load_bias</code>이다.
지금의 <code>ET_EXEC</code> 예에서는 파일의 <code>p_offset</code>부터 <code>p_filesz</code>바이트가 가상 주소 <code>p_vaddr</code>부터 보이도록 배치한다. <code>p_memsz</code>가 더 크면 그 차이만큼은 0으로 시작하는 메모리가 필요하다.
 
위 그림의 실행 파일을 페이지 크기 <code>0x1000</code>인 주소 공간에 배치하면 다음과 같다. 코드 세그먼트는 파일과 메모리의 크기가 같고, 데이터 세그먼트는 메모리에서 더 큰 공간을 요구한다.


<pre>
<pre>
File interval:
File offset                    Virtual address
  [p_offset, p_offset + p_filesz)
0x1000 +------------------+     0x401000 +------------------+
          |
      | Code, 0x1000    | ----------> | Code, R-X        |
          v
0x2000 +------------------+     0x402000 +------------------+
Virtual interval initialized from file:
      | Data, 0x0800    | ----------> | Data, RW-       |
  [B + p_vaddr, B + p_vaddr + p_filesz)
0x2800 +------------------+    0x402800 +------------------+
 
                                      | Zero-filled BSS  |
Virtual interval initialized to zero:
                              0x404000 +------------------+
  [B + p_vaddr + p_filesz, B + p_vaddr + p_memsz)
</pre>
 
페이지 테이블은 바이트가 아니라 페이지 단위로 매핑하므로 실제 매핑 시작과 끝은 페이지 경계에 맞춰진다. 파일 오프셋과 가상 주소의 페이지 내 오프셋이 서로 맞아야 파일 바이트를 올바른 위치에 대응시킬 수 있다. Linux의 <code>elf_map()</code>은 가상 주소를 아래쪽 페이지 경계로 내리고, <code>p_offset</code>에서도 <code>p_vaddr</code>의 페이지 내 오프셋만큼 뺀 파일 위치를 사용하며, 매핑 길이는 위쪽 페이지 경계로 올린다. ELF의 정렬 규칙은 임의의 파일 위치와 임의의 가상 주소를 그대로 이어 붙일 수 없다는 제약을 반영한다.<ref name="elf-src" />
 
예를 들어 페이지 크기를 <code>0x1000</code>으로 두고, <code>p_offset=0x2000</code>, <code>p_vaddr=0x402000</code>, <code>p_filesz=0x1800</code>, <code>p_memsz=0x3800</code>, <code>B=0</code>인 쓰기 가능한 세그먼트를 생각하자. 파일 데이터는 <code>0x402000</code>부터 <code>0x403800</code> 직전까지 대응된다. 파일 매핑은 페이지 경계인 <code>0x404000</code>까지 걸치지만, <code>0x403800</code> 이후는 프로그램의 초기 데이터가 아니라 영 초기화 영역이다. 세그먼트의 논리적인 끝은 <code>0x405800</code>이며, 마지막 페이지의 매핑은 <code>0x406000</code>까지 이어질 수 있다.


<pre>
Entry: 0x401000
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 +------------------------------+
</pre>
</pre>


== BSS는 파일에서 복사해 오는가 ==
코드는 가상 주소 <code>0x401000</code>부터 <code>0x402000</code> 직전까지 대응된다. 초기값이 있는 데이터는 <code>0x402000</code>부터 <code>0x402800</code> 직전까지이고, 그 뒤 <code>0x404000</code> 직전까지는 0으로 시작해야 한다. 파일 오프셋은 파일 안의 위치이고, 가상 주소는 프로그램이 사용하는 주소이며, 둘 중 어느 것도 물리 메모리 주소를 뜻하지 않는다.
 
초깃값이 0인 전역·정적 변수에 필요한 공간은 파일에 같은 양의 0을 저장하지 않아도 된다. ELF는 <code>p_memsz</code>를 <code>p_filesz</code>보다 크게 만들어 이 요구를 표현한다. 흔히 이를 BSS라고 부르지만, 커널은 <code>.bss</code>라는 섹션 이름이 아니라 이 두 크기의 차이를 보고 처리한다.
 
Linux v6.12의 <code>elf_load()</code>는 이 영역을 두 부분으로 다룬다.<ref name="elf-src" />


* '''파일 데이터가 끝나는 마지막 페이지의 나머지''': 파일 매핑에 포함된 페이지 안에서 데이터 끝 이후를 <code>padzero()</code>로 지운다. 파일의 같은 페이지에 뒤따르는 다른 바이트가 있을 수 있으므로, 단순히 파일 페이지를 대응시켰다는 이유만으로 이 구간이 0이라고 가정할 수 없다. 이 쓰기는 페이지 폴트나 비공개 페이지 생성을 유발할 수 있다.
실제 메모리 매핑은 페이지 단위이므로 시작과 끝을 페이지 경계에 맞춰 처리한다. 파일 오프셋과 가상 주소의 페이지 내 위치가 맞아야 같은 바이트가 올바르게 대응된다. Linux의 <code>elf_map()</code>은 이 정렬을 반영해 <code>vm_mmap()</code>으로 파일 매핑을 만든다.<ref name="elf" />
* '''그 뒤의 완전한 페이지들''': 페이지 경계로 올린 영 초기화 시작점부터 세그먼트 끝을 포함하는 페이지까지 <code>vm_brk_flags()</code>로 익명 메모리 영역을 만든다. 이는 파일의 뒤쪽 바이트를 계속 매핑하는 것이 아니다. 익명 메모리의 영 초기화 의미는 가상 메모리 계층이 보장하며, 모든 물리 페이지를 이 시점에 즉시 할당한다는 뜻도 아니다.


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


== 파일 권한과 페이지 접근 권한 ==
초기값이 0인 전역·정적 변수에 필요한 공간을 파일에 모두 0으로 저장할 필요는 없다. <code>p_memsz</code>를 <code>p_filesz</code>보다 크게 두면 커널은 추가 구간을 0으로 준비한다. 흔히 BSS라고 부르는 부분이며, 커널은 섹션 이름이 아니라 이 두 크기의 차이를 사용한다.


실행 파일을 열고 실행할 자격이 있는지 확인하는 일과, 매핑된 페이지에 어떤 접근을 허용하는지는 서로 다른 문제이다. 파일의 실행 권한은 실행 요청을 받아들일지를 판단하는 조건이며, <code>PF_R</code>, <code>PF_W</code>, <code>PF_X</code>는 세그먼트 매핑의 보호 속성으로 변환된다. <code>make_prot()</code>는 이를 <code>PROT_READ</code>, <code>PROT_WRITE</code>, <code>PROT_EXEC</code> 등으로 옮기며 아키텍처별 조정도 거친다.
앞의 예에서 파일 데이터의 끝인 <code>0x402800</code>은 페이지 중간에 있다. 그래서 영 초기화는 두 부분으로 나뉜다.


일반적인 실행 파일은 코드에 읽기·실행, 데이터에 읽기·쓰기를 요청한다. x86-64에서는 사용자 접근 여부, 쓰기 허용 여부, 실행 금지 속성 등이 페이지 테이블의 보호와 연결된다. 페이지에 실행 권한이 없는데 명령어를 가져오거나, 쓰기 불가능한 페이지에 쓰기를 시도하면 보호 위반이 발생한다. 세부 원리는 [[운영체제 기본/보호|보호]]와 [[운영체제 기본/가상 메모리|가상 메모리]]에서 다룬다.
* <code>0x402800</code>부터 다음 페이지 경계인 <code>0x403000</code>까지는 이미 파일 매핑에 포함된 마지막 페이지의 나머지이다. <code>padzero()</code>로 이 부분을 지워, 파일의 뒤따르는 바이트가 초기값으로 보이지 않게 한다.
* <code>0x403000</code>부터 <code>0x404000</code> 직전까지는 파일에 연결할 내용이 없는 익명 메모리로 만든다. <code>elf_load()</code>는 <code>vm_brk_flags()</code>를 이용하며, 마지막 페이지는 세그먼트 끝을 포함하도록 확보한다.<ref name="elf" />


ELF 로더의 파일 매핑은 <code>MAP_PRIVATE</code>를 사용한다. 따라서 프로세스가 쓰기 가능한 데이터에 값을 저장한다고 해서 실행 파일의 원본 바이트가 덮어써지지는 않는다. 파일 페이지를 공유하다가 쓰기가 필요한 시점에 사본을 만드는 COW 동작과 연결되며, BSS의 부분 페이지 영 초기화도 이 비공개 매핑이라는 성질 안에서 이해해야 한다.
즉 BSS는 파일의 뒤쪽을 더 읽어 오는 영역이 아니다. 파일 데이터가 끝난 페이지의 일부는 직접 0으로 만들고, 나머지는 0으로 시작하는 익명 메모리의 성질을 이용한다. 이때 모든 물리 페이지를 즉시 할당해야 하는 것은 아니다.


== ET_EXEC, ET_DYN과 load_bias ==
== 접근 권한과 실제 페이지 준비 ==


<code>ET_EXEC</code> 실행 파일은 통상 링크 시 지정된 가상 주소를 기준으로 배치된다. x86-64의 이 경로에서는 주 실행 이미지의 <code>load_bias</code>가 0이며, 첫 <code>PT_LOAD</code>에 <code>MAP_FIXED_NOREPLACE</code>를 사용해 요청 위치의 충돌을 확인한다. 이후 세그먼트는 계산된 배치에 따라 매핑한다. 이 말은 프로세스의 스택이나 공유 라이브러리까지 모두 고정 주소라는 뜻은 아니다.
커널은 <code>p_flags</code>의 <code>PF_R</code>, <code>PF_W</code>, <code>PF_X</code>를 읽기·쓰기·실행에 해당하는 매핑 속성으로 바꾼다. 일반적으로 코드는 읽기·실행, 수정할 데이터는 읽기·쓰기로 배치한다. 이 속성은 페이지 테이블의 접근 제어로 이어지며, 허용되지 않은 접근은 보호 위반으로 처리된다. 이는 [[운영체제 기본/보호|보호]]와 [[운영체제 기본/가상 메모리|가상 메모리]]에서 설명한 원리가 실행 파일에 적용되는 사례이다.


<code>ET_DYN</code>은 실행 시 배치 기준을 정할 수 있는 형식이다. 공유 라이브러리뿐 아니라 PIE(Position-Independent Executable) 실행 파일도 이 형식을 사용한다. 따라서 '''ET_DYN이면 라이브러리이고 실행 파일은 아니다'''라는 분류는 틀리다. 정해진 <code>load_bias</code>는 같은 이미지의 모든 세그먼트에 공통으로 더해지므로 세그먼트 사이의 상대적 거리는 유지된다.
ELF의 파일 매핑은 사적 매핑인 <code>MAP_PRIVATE</code>를 사용한다. 프로세스가 데이터 값을 바꾸더라도 디스크의 실행 파일을 수정하지 않으며, 필요하면 해당 프로세스만의 페이지 사본을 만든다.


Linux v6.12는 <code>PT_INTERP</code>가 있는 <code>ET_DYN</code>과 없는 <code>ET_DYN</code>의 배치 경로를 구분한다. 전자는 <code>ELF_ET_DYN_BASE</code>를 기준으로 필요하면 주소 공간 배치 난수화(ASLR)를 적용하고 정렬을 조정한다. 후자는 mmap 영역의 배치 선택을 활용하며, 큰 정렬 요구가 있으면 추가 계산을 수행한다. 동적 로더를 직접 실행하는 경우뿐 아니라 인터프리터 없는 정적 PIE도 고려해야 하므로, 인터프리터가 없는 모든 <code>ET_DYN</code>을 동적 로더라고 단정해서는 안 된다.<ref name="elf-src" />
매핑을 만들었다는 것은 주소 구간과 그 의미를 정했다는 뜻이다. 모든 파일 페이지를 RAM에 읽어 왔다는 뜻은 아니다. 처음 명령어를 가져오거나 데이터를 읽을 때 아직 페이지가 연결되지 않았다면 페이지 폴트가 발생하고, 커널이 파일 페이지를 찾아 페이지 테이블을 채운 뒤 접근을 재개한다. 페이지 캐시에 내용이 있으면 저장 장치에서 다시 읽지 않아도 된다.


<code>total_mapping_size()</code>는 <code>PT_LOAD</code>들의 전체 주소 범위를 계산한다. <code>ET_DYN</code>의 첫 배치에서는 이 크기를 고려해 다른 매핑과의 충돌을 피할 공간을 찾고, 첫 세그먼트에 필요하지 않은 나머지 임시 매핑은 해제한다. 이것은 실행 파일 전체를 물리 메모리에 읽어 들인다는 뜻이 아니라, 뒤따르는 세그먼트들을 일관된 위치에 배치하기 위한 주소 범위 계산이다.
반대로 헤더 읽기나 마지막 페이지의 영 초기화처럼 로딩 중 실제 접근이 일어나는 부분도 있다. 따라서 “전부 미리 읽는다”와 “로딩 중에는 아무 페이지도 읽지 않는다”는 설명은 모두 부정확하다. 자세한 경로는 [[운영체제 기본/Linux의 파일 메모리 매핑/첫 접근과 페이지 폴트|첫 접근과 페이지 폴트]]에서 다룬다.


ASLR도 무조건 적용되는 성질이 아니다. 시스템의 <code>randomize_va_space</code> 설정, 태스크의 <code>ADDR_NO_RANDOMIZE</code> 성격, 실행 파일 형식과 아키텍처 구현이 영향을 준다. 세그먼트 배치를 바꾸는 <code>load_bias</code> 계산과, 동적 링커가 데이터 안의 주소를 고치는 재배치 처리는 서로 다른 층의 작업이다.
이제 실행 파일의 코드와 데이터를 사용할 주소 공간이 준비되었다. 다음은 [[운영체제 기본/Linux의 ELF 로딩/초기 스택과 사용자 모드 진입|초기 스택과 사용자 모드 진입]]에서 인자와 스택을 만들고 CPU가 실행을 시작할 상태를 준비하는 과정이다.


== 매핑 완료와 페이지 준비 완료는 다르다 ==
== 다른 주소에 배치하는 경우 ==
 
<code>elf_map()</code>은 <code>vm_mmap()</code>을 이용한다. 핵심 결과는 가상 주소 구간, 파일 오프셋, 보호 속성을 가진 매핑이 만들어지는 것이다. 이때 실행 코드와 데이터의 모든 페이지에 물리 페이지가 이미 연결되어 있어야 하는 것은 아니다.
 
<pre>
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
</pre>


헤더 읽기, 인자·환경 문자열 작성, BSS의 부분 페이지 지우기처럼 로딩 도중 실제 메모리 접근이 이루어지는 부분도 있다. 그러므로 “exec 동안은 아무 페이지도 읽지 않는다”와 “exec가 파일 전체를 미리 읽는다”는 설명은 모두 부정확하다. 코드의 첫 명령어를 가져오는 순간에도 페이지 폴트가 날 수 있으며, 페이지 캐시에 내용이 이미 있다면 저장 장치 I/O 없이 처리될 수 있다. 구체적인 파일 폴트 경로는 [[운영체제 기본/Linux의 파일 메모리 매핑/첫 접근과 페이지 폴트|첫 접근과 페이지 폴트]]로 이어진다.
지금까지는 <code>ET_EXEC</code>의 지정된 가상 주소를 그대로 사용하는 경우를 설명했다. <code>ET_DYN</code> 형식은 실행 시 다른 기준 주소에 배치할 수 있으며, 이때 세그먼트 주소와 진입 주소에 공통으로 더하는 값이 <code>load_bias</code>이다. 실행할 때마다 배치 주소를 달리하는 ASLR에도 이 방식이 사용된다. 다만 다른 위치에 배치할 수 있다는 성질이 ASLR에만 쓰이는 것은 아니다.


이 단계가 끝나면 프로그램이 실행될 주소 공간의 골격이 마련된다. 그 주소 공간에서 '''어디부터 실행하고 어떤 초기 스택을 제공할지'''는 [[운영체제 기본/Linux의 ELF 로딩/초기 스택과 사용자 모드 진입|초기 스택과 사용자 모드 진입]]의 주제이다.
이 추가 처리가 있어도 기본 원리는 같다. 배치 주소를 정하고, <code>PT_LOAD</code>에 따라 파일과 영 초기화 영역을 연결한 뒤, 초기 스택과 진입 상태를 준비한다. 동적 링커가 필요한 프로그램의 시작은 다음 절의 기본 실행 흐름을 설명한 뒤 살펴본다.


== 참고 문헌 ==
== 참고 문헌 ==
<references />
<references />

2026년 10월 31일 (토) 08:55 기준 최신판

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. ↑ 1.0 1.1 1.2 Linux v6.12, fs/binfmt_elf.c, load_elf_binary(), load_elf_phdrs(), elf_map(), elf_load().