운영체제 기본/Linux의 파일 메모리 매핑/첫 접근과 페이지 폴트: 두 판 사이의 차이

파일 폴트의 데이터 공급을 페이지 캐시 문서에 연결
BIOS GRUB 부팅 경로 보강과 각론 형식 통일
 
(같은 사용자의 중간 판 하나는 보이지 않습니다)
3번째 줄: 3번째 줄:
== 매핑된 주소를 처음 읽는 순간 ==
== 매핑된 주소를 처음 읽는 순간 ==


앞 글에서 <code>mmap()</code>은 파일의 일부를 프로세스 주소 공간에 연결하는 VMA를 만들었다. 여기서는 Linux v6.12의 x86-64, 전통적인 IDT 예외 진입, 일반 ext4 파일의 buffered 경로를 기준으로 그 주소를 읽는다. 매핑은 <code>PROT_READ</code>와 <code>MAP_PRIVATE</code>로 만들었고 <code>MAP_POPULATE</code>와 DAX는 사용하지 않는다. 아래 흐름은 보통 크기 페이지의 PTE가 아직 없는 경우다.
앞 글에서 <code>mmap()</code>은 파일의 일부를 프로세스 주소 공간에 연결하는 VMA를 만들었다. 여기서는 Linux v6.12의 x86-64와 RISC-V RV64를 대표 사례로 그 주소를 읽는다. x86-64는 IDT 예외 진입, RISC-V는 S-mode 커널의 트랩 진입을 기준으로 한다. 파일은 두 경우 모두 일반 ext4 파일이며, 페이지 캐시를 이용하는 buffered 경로다. 매핑은 <code>PROT_READ</code>와 <code>MAP_PRIVATE</code>로 만들었으며, 읽으려는 주소의 PTE가 아직 없는 경우를 따라간다.


<pre>
<pre>
17번째 줄: 17번째 줄:
== CPU 예외에서 VMA 검사까지 ==
== CPU 예외에서 VMA 검사까지 ==


present PTE가 없으면 x86-64 CPU는 페이지 폴트 예외 <code>#PF</code>를 발생시킨다. 예외 벡터는 14이고 fault 주소는 CR2에 남는다. [[운영체제 기본/장치 제어와 입출력#처리 진입점과 IDT|IDT]]에서 본 진입 장치를 사용하지만, 이 사건은 장치가 비동기적으로 보낸 완료 인터럽트가 아니라 지금 실행한 명령 때문에 발생한 동기 예외다.
두 CPU 모두 필요한 주소 변환이 없으면 동기 예외를 일으킨다. 커널은 폴트 주소와 접근 종류를 알아내고, VMA가 그 접근을 허용하는지 검사한 뒤 공통 함수 <code>handle_mm_fault()</code>에 복구를 요청한다. '''예외를 받는 장치와 정보의 표현은 아키텍처별이고, 파일 페이지를 준비하는 핵심 경로는 공통이다.'''


<pre>
<pre>
load bytes[i]
load bytes[i]
   → #PF, vector 14
  |
   → asm_exc_page_fault
  +-- x86-64: #PF -> IDT -> exc_page_fault
  |            -> handle_page_fault -> do_user_addr_fault
  |
  +-- RV64: page fault -> handle_exception
                -> do_page_fault -> handle_page_fault
  |
  v
VMA lookup + access check
  |
  v
handle_mm_fault -> file page -> install PTE -> retry load
</pre>
 
이 예외는 장치가 비동기적으로 보낸 완료 인터럽트가 아니다. 지금 실행한 load 때문에 발생한다. 두 소스 트리에 <code>handle_page_fault</code>라는 같은 이름이 등장하지만 서로 다른 아키텍처별 함수다.
 
=== x86-64: #PF, CR2, 오류 코드 ===
 
사용 가능한 변환이 없으면 x86-64 CPU는 페이지 폴트 예외 <code>#PF</code>를 발생시킨다. 예외 벡터는 14이고 폴트 주소는 <code>CR2</code>에 남는다. CPU가 전달한 오류 코드는 페이지가 존재하지 않는 경우와 보호 위반, 읽기와 쓰기, 사용자 모드 접근 등의 정보를 구별한다. 이 장의 경우는 사용자 모드에서 아직 연결되지 않은 페이지를 읽는 경우다.
 
<pre>
load bytes[i]
   → #PF, vector 14, CR2 = 폴트 주소, 오류 코드
   → IDT → asm_exc_page_fault
   → exc_page_fault
   → exc_page_fault
   → handle_page_fault
   → handle_page_fault
   → do_user_addr_fault
   → do_user_addr_fault
  → handle_mm_fault
</pre>
v6.12의 [[운영체제 기본/장치 제어와 입출력#처리 진입점과 IDT|IDT]]는 해당 예외를 <code>asm_exc_page_fault</code>로 연결한다. C 쪽 <code>exc_page_fault</code>는 이 진입 경로에서 CR2를 읽고 <code>handle_page_fault()</code>를 호출한다. 사용자 주소 처리는 <code>do_user_addr_fault()</code>로 이어진다.<ref name="idt">[https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/x86/kernel/idt.c?h=v6.12 Linux v6.12 arch/x86/kernel/idt.c], 페이지 폴트 IDT 항목.</ref><ref name="x86fault">[https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/x86/mm/fault.c?h=v6.12 Linux v6.12 arch/x86/mm/fault.c], exc_page_fault, handle_page_fault, do_user_addr_fault와 오류 코드 검사.</ref>
=== RISC-V RV64: scause, stval, sepc ===
RISC-V에서는 <code>stvec</code>가 지정하는 트랩 진입점으로 제어가 넘어간다. S-mode 커널의 트랩 진입에서 <code>scause</code>는 원인을, <code>stval</code>은 페이지 폴트 주소를, <code>sepc</code>는 중단된 명령의 주소를 제공한다. 페이지 폴트 원인 번호는 명령어 가져오기 12, load 13, store/AMO 15로 나뉜다. 따라서 이 예제의 읽기는 원인 13인 load page fault다. x86의 페이지 폴트 오류 코드 비트를 그대로 적용하는 설명은 맞지 않는다.<ref name="rv-csr">[https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/riscv/include/asm/csr.h?h=v6.12 Linux v6.12 arch/riscv/include/asm/csr.h], EXC_*_PAGE_FAULT, CSR_SCAUSE, CSR_STVAL, CSR_SEPC와 S-mode 별칭.</ref>
<pre>
load bytes[i]
  → load page fault: scause = 13, stval = 폴트 주소, sepc = load 주소
  → handle_exception
  → excp_vect_table의 load page fault 항목
  → do_page_fault                [arch/riscv/kernel/traps.c]
  → handle_page_fault            [arch/riscv/mm/fault.c]
  → handle_mm_fault              [mm/memory.c]
</pre>
</pre>


v6.12의 IDT는 해당 예외를 <code>asm_exc_page_fault</code>로 연결하며, C 쪽 <code>exc_page_fault</code>는 전통적인 진입 경로에서 CR2를 읽고 <code>handle_page_fault()</code>를 호출한다. 사용자 주소에 대한 처리는 <code>do_user_addr_fault()</code>로 이어진다.<ref name="idt">[https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/x86/kernel/idt.c?h=v6.12 Linux v6.12 arch/x86/kernel/idt.c], 페이지 폴트 IDT 항목.</ref><ref name="x86fault">[https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/x86/mm/fault.c?h=v6.12 Linux v6.12 arch/x86/mm/fault.c], exc_page_fault, handle_page_fault, do_user_addr_fault.</ref>
<code>handle_exception</code>은 트랩 레지스터 값을 <code>pt_regs</code>의 <code>cause</code>, <code>badaddr</code>, <code>epc</code> 등에 저장한다. 예외 표가 선택하는 <code>do_page_fault()</code>는 <code>handle_page_fault()</code>를 부른다. 이 함수는 저장된 원인과 주소를 사용하고, <code>access_error()</code>에서 VMA의 읽기·쓰기·실행 조건을 확인한다.<ref name="rv-entry">[https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/riscv/kernel/entry.S?h=v6.12 Linux v6.12 arch/riscv/kernel/entry.S], handle_exception, excp_vect_table, ret_from_exception.</ref><ref name="rv-traps">[https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/riscv/kernel/traps.c?h=v6.12 Linux v6.12 arch/riscv/kernel/traps.c], do_page_fault.</ref><ref name="rv-fault">[https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/riscv/mm/fault.c?h=v6.12 Linux v6.12 arch/riscv/mm/fault.c], handle_page_fault, access_error.</ref>
 
=== VMA 검사 뒤 공통 처리로 합류 ===


커널은 먼저 fault 주소를 포함하는 VMA를 찾아 접근 권한을 확인한다. v6.12에는 <code>lock_vma_under_rcu()</code>를 이용한 빠른 경로가 있고, 사용할 수 없거나 재시도가 필요하면 <code>lock_mm_and_find_vma()</code> 경로로 넘어간다. 매핑이 없거나 허용되지 않은 접근이라면 정상적인 파일 읽기 복구를 진행하지 않고 신호 처리로 이어질 수 있다. 유효한 읽기라면 <code>handle_mm_fault()</code>로 실제 주소 변환 복구를 요청한다.<ref name="x86fault" />
두 아키텍처의 처리 함수 모두 폴트 주소를 포함하는 VMA를 찾아 권한을 검사한다. 매핑이 없거나 허용되지 않은 접근이면 정상적인 파일 읽기 복구 대신 신호 처리로 이어질 수 있다. 유효한 읽기라면 <code>handle_mm_fault()</code>로 진행한다.<ref name="x86fault" /><ref name="rv-fault" />


== 파일 읽기 폴트로 분기하기 ==
== 파일 읽기 폴트로 분기하기 ==
47번째 줄: 88번째 줄:
<code>do_pte_missing()</code>는 익명 메모리와 파일 매핑을 구분한다. 이 예제는 파일 매핑이므로 <code>do_fault()</code>로 가며, 쓰기 fault가 아니므로 <code>do_read_fault()</code>를 선택한다. <code>MAP_PRIVATE</code>라고 해서 읽는 순간 파일 전체를 사본으로 만드는 것은 아니다. 지금은 쓰기 시 복사가 아닌 읽기 경로다.<ref name="memory">[https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/mm/memory.c?h=v6.12 Linux v6.12 mm/memory.c], handle_mm_fault, __handle_mm_fault, handle_pte_fault, do_pte_missing, do_fault, do_read_fault, finish_fault, set_pte_range.</ref>
<code>do_pte_missing()</code>는 익명 메모리와 파일 매핑을 구분한다. 이 예제는 파일 매핑이므로 <code>do_fault()</code>로 가며, 쓰기 fault가 아니므로 <code>do_read_fault()</code>를 선택한다. <code>MAP_PRIVATE</code>라고 해서 읽는 순간 파일 전체를 사본으로 만드는 것은 아니다. 지금은 쓰기 시 복사가 아닌 읽기 경로다.<ref name="memory">[https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/mm/memory.c?h=v6.12 Linux v6.12 mm/memory.c], handle_mm_fault, __handle_mm_fault, handle_pte_fault, do_pte_missing, do_fault, do_read_fault, finish_fault, set_pte_range.</ref>


여기에는 중요한 갈림길이 있다. <code>do_read_fault()</code>는 조건이 맞으면 먼저 <code>do_fault_around()</code>를 시도한다. ext4의 <code>map_pages</code> 콜백은 <code>filemap_map_pages()</code>이며, 이미 준비된 파일 페이지를 주변 주소까지 매핑할 수 있다. 여기서 해결되면 <code>filemap_fault()</code>는 호출하지 않는다. 모든 파일 폴트가 다음 경로를 거치지는 않는다.<ref name="memory" /><ref name="ext4">[https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/fs/ext4/file.c?h=v6.12 Linux v6.12 fs/ext4/file.c], ext4_file_vm_ops와 ext4_file_mmap.</ref>
 


== 파일 데이터 준비와 PTE 설치 ==
== 파일 데이터 준비와 PTE 설치 ==


fault-around로 해결하지 못한 일반 경로에서는 <code>__do_fault()</code>가 <code>vma-&gt;vm_ops-&gt;fault(vmf)</code>를 호출한다. 앞 글에서 ext4가 등록한 이 함수 포인터의 대상이 <code>filemap_fault()</code>다. 가상 메모리와 파일 데이터 준비의 연결점이다.<ref name="memory" /><ref name="ext4" />
캐시에 바로 매핑할 페이지가 없는 경우에는 <code>__do_fault()</code>가 <code>vma-&gt;vm_ops-&gt;fault(vmf)</code>를 호출한다. 앞 글에서 ext4가 등록한 이 함수 포인터의 대상이 <code>filemap_fault()</code>다. 가상 메모리와 파일 데이터 준비의 연결점이다.<ref name="memory" /><ref name="ext4" />


파일 데이터가 이미 준비되어 있을 수도 있고, 저장장치에서 읽어 올 필요가 있을 수도 있다. 필요한 작업을 기다리는 동안 현재 태스크가 잠들 수 있다는 점은 [[운영체제 기본/프로세스와 스레드#기다리는 작업에 CPU를 주지 않는 이유|입출력 대기]]에 연결된다. 잠금을 놓았다가 다시 얻어 fault 처리를 재시도할 수도 있다. 파일 데이터는 [[운영체제 기본/Linux의 파일시스템/페이지 캐시#read는 복사하고 mmap은 페이지 테이블로 연결한다|페이지 캐시]]를 통해 공급된다.<ref name="filemap">[https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/mm/filemap.c?h=v6.12 Linux v6.12 mm/filemap.c], filemap_fault와 filemap_map_pages.</ref>
파일 데이터가 이미 준비되어 있을 수도 있고, 저장장치에서 읽어 올 필요가 있을 수도 있다. 필요한 작업을 기다리는 동안 현재 태스크가 잠들 수 있다는 점은 [[운영체제 기본/프로세스와 스레드#기다리는 작업에 CPU를 주지 않는 이유|입출력 대기]]에 연결된다. 잠금을 놓았다가 다시 얻어 fault 처리를 재시도할 수도 있다. 파일 데이터는 [[운영체제 기본/Linux의 파일시스템/페이지 캐시#read는 복사하고 mmap은 페이지 테이블로 연결한다|페이지 캐시]]를 통해 공급된다.<ref name="filemap">[https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/mm/filemap.c?h=v6.12 Linux v6.12 mm/filemap.c], filemap_fault와 filemap_map_pages.</ref>
57번째 줄: 98번째 줄:
정상적으로 파일 페이지를 얻은 경로에서는 <code>finish_fault()</code>가 페이지 테이블 잠금 아래 상태를 다시 확인하고 <code>set_pte_range()</code>를 통해 PTE를 설치한다. PTE는 해당 페이지와 보호 속성을 연결한다. 예외 처리가 성공적으로 끝나 사용자 실행으로 돌아오면 중단됐던 load 명령이 다시 실행된다. 이번에는 주소 변환이 가능하므로 그 바이트를 읽는다.<ref name="memory" />
정상적으로 파일 페이지를 얻은 경로에서는 <code>finish_fault()</code>가 페이지 테이블 잠금 아래 상태를 다시 확인하고 <code>set_pte_range()</code>를 통해 PTE를 설치한다. PTE는 해당 페이지와 보호 속성을 연결한다. 예외 처리가 성공적으로 끝나 사용자 실행으로 돌아오면 중단됐던 load 명령이 다시 실행된다. 이번에는 주소 변환이 가능하므로 그 바이트를 읽는다.<ref name="memory" />


== read와 다른 점, 여전히 남는 조건 ==
이미 캐시에 페이지가 준비되어 있다면 더 짧은 경로를 사용할 수 있다. <code>do_read_fault()</code>는 조건이 맞으면 먼저 <code>do_fault_around()</code>를 시도한다. ext4의 <code>map_pages</code> 콜백은 <code>filemap_map_pages()</code>이며, 이미 준비된 파일 페이지를 주변 주소까지 매핑할 수 있다. 여기서 해결되면 <code>filemap_fault()</code>는 호출하지 않는다. <ref name="memory" /><ref name="ext4">[https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/fs/ext4/file.c?h=v6.12 Linux v6.12 fs/ext4/file.c], ext4_file_vm_ops와 ext4_file_mmap.</ref>
 
=== PTE 형식은 달라도 복구의 목적은 같다 ===
 
공통 코드가 사용하는 PTE라는 이름이 하드웨어 비트 형식까지 같다는 뜻은 아니다. x86-64는 present 비트와 읽기/쓰기, 사용자 접근 등의 속성을 사용하고, RISC-V는 V(valid), R, W, X, U 등의 비트로 유효성과 접근 조건을 표현한다. 페이지 테이블 단계와 실제 엔트리 조작은 아키텍처별 구현에 맡긴다. 이 장에서 중요한 공통 결과는 '''해당 프로세스의 가상 주소가 준비된 파일 페이지를 허용된 권한으로 가리키게 되는 것'''이다.<ref name="x86-pte">[https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/x86/include/asm/pgtable_types.h?h=v6.12 Linux v6.12 arch/x86/include/asm/pgtable_types.h], 페이지 테이블 속성 비트.</ref><ref name="rv-pte">[https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/riscv/include/asm/pgtable-bits.h?h=v6.12 Linux v6.12 arch/riscv/include/asm/pgtable-bits.h], _PAGE_PRESENT, _PAGE_READ, _PAGE_WRITE, _PAGE_EXEC, _PAGE_USER.</ref>
 
RISC-V의 정상적인 페이지 폴트 복구는 시스템 콜의 <code>ecall</code>처럼 <code>epc</code>를 다음 명령으로 증가시키지 않는다. 저장된 fault 명령 위치로 복귀해 같은 load를 다시 수행한다. x86-64도 성공한 페이지 폴트 처리 뒤에는 중단된 load를 다시 실행한다. 레지스터 복원과 사용자 모드 복귀 방식은 [[운영체제 기본/Linux의 ELF 로딩/초기 스택과 사용자 모드 진입#사용자 모드 진입: x86-64와 RISC-V|두 아키텍처의 사용자 모드 진입]]에서 다룬다. 커널 안에서 잠금이나 입출력 때문에 폴트 처리를 재시도하는 일과, 사용자 모드로 돌아가 CPU 명령을 다시 실행하는 일은 구별한다.<ref name="rv-traps" /><ref name="rv-entry" /><ref name="rv-fault" /><ref name="x86fault" />
 
== read와 mmap의 비용과 수명 ==


일반적인 buffered <code>read()</code>는 시스템 호출이 요청한 파일 데이터를 사용자 버퍼로 복사해 전달한다. 반면 이 <code>mmap()</code> 경로는 파일 데이터가 있는 페이지를 사용자 가상 주소에 연결하고 CPU load로 읽게 한다. 별도 버퍼 복사를 피하더라도 저장장치 입출력이 없거나 모든 복사가 사라지거나 언제나 더 빠르지는 않다. 폴트와 페이지 테이블 관리 비용도 접근 패턴에 좌우된다.
일반적인 buffered <code>read()</code>는 시스템 콜(System Call)이 요청한 파일 데이터를 사용자 버퍼로 복사해 전달한다. 반면 이 <code>mmap()</code> 경로는 파일 데이터가 있는 페이지를 사용자 가상 주소에 연결하고 CPU load로 읽게 한다. 별도 버퍼 복사를 피하더라도 저장장치 입출력이 없거나 모든 복사가 사라지거나 언제나 더 빠르지는 않다. 폴트와 페이지 테이블 관리 비용도 접근 패턴에 좌우된다.


매핑 시작 파일 오프셋은 페이지 크기에 맞게 정렬하고, 읽을 바이트가 매핑 길이와 파일 데이터 범위 안에 있는지 확인한다. <code>mmap()</code> 성공은 파일이 이후에도 같은 길이라는 보장이 아니다. 다른 실행 흐름이 파일을 줄여 해당 페이지가 파일 끝 밖으로 밀려나면, VMA 범위 안의 주소라도 <code>SIGBUS</code>가 발생할 수 있다. 이는 단순한 VMA 부재와 다르다.<ref name="filemap" />
매핑 시작 파일 오프셋은 페이지 크기에 맞게 정렬하고, 읽을 바이트가 매핑 길이와 파일 데이터 범위 안에 있는지 확인한다. <code>mmap()</code> 성공은 파일이 이후에도 같은 길이라는 보장이 아니다. 다른 실행 흐름이 파일을 줄여 해당 페이지가 파일 끝 밖으로 밀려나면, VMA 범위 안의 주소라도 <code>SIGBUS</code>가 발생할 수 있다. 이는 단순한 VMA 부재와 다르다.<ref name="filemap" />