Intro
Linux의 페이징에 대해 설명한 내용이다. 커널 소스 트리의 Documentation/mm/page_tables.rst를 번역함. 몇개의 구조는 수정되었다.
페이지 테이블
페이징 가상 메모리는 가상 메모리 개념과 함께 1962년 페란티 아틀라스 컴퓨터 (Ferranti Atlas Computer)에서 처음 발명되었으며, 이는 페이징 가상 메모리를 갖춘 최초의 컴퓨터였다. 이 기능은 이후 더 새로운 컴퓨터로 이전되었고 시간이 지나면서 모든 Unix 계열 시스템의 사실상 표준 기능이 되었다. 1985년에는 Linux 1.0이 개발된 CPU인 Intel 80386에 이 기능이 포함되었다. 페이지 테이블은 CPU가 인식하는 가상 주소를 외부 메모리 버스에서 인식하는 물리 주소로 매핑한다. 리눅스는 페이지 테이블을 현재 5단계 높이의 계층 구조로 정의한다. 각 아키텍처의 독립적인 코드는 이를 하드웨어의 제약 조건에 맞추어 매핑할 것이다.
가상 주소에 해당하는 물리 주소는 종종 기본 물리 페이지 프레임으로 참조된다. 페이지 프레임 번호 또는 PFN은 페이지의 물리 주소(외부 메모리 버스에서 보이는 주소)를 PAGE_SIZE
로 나눈 값이다.
물리 메모리 주소 0은 PFN 0이 되고, 가장 높은 PFN은 CPU의 외부 주소 버스가 주소를 지정할 수 있는 물리 메모리의 마지막 페이지가 된다. 페이지 세분성 (page granularity, page의 크기)이 4KB이고 주소 범위가 32비트인 경우, PFN 0은 주소 0x00000000
에, PFN 1은 주소 0x00001000
에, PFN 2는 0x00002000
에 있으며, 0xfffff000
의 PFN 0xfffff
에 도달할 때까지 계속된다. 16KB 페이지의 경우 PFN은 0x00004000
, 0x00008000
… 0xffffc000
에 있으며 PFN은 0에서 0x3fffff
까지이다. 만약 4KB 페이지의 경우, 페이지 기본 주소 (base address)는 주소의 비트 12-31을 사용하며, 이 경우에는 PAGE_SHIFT
가 12로 정의된다. 그래서, PAGE_SIZE
가 일반적으로 페이지 시프트 측면에서 (1 << PAGE_SHIFT)
로 정의되는 이유이다.
시간이 지남에 따라 메모리 크기 증가에 대응하여 더 많은 단계의 계층 구조가 개발되었다. Linux가 처음 만들어졌을 때 4KB 페이지와 1024개의 항목이 있는 swapper_pg_dir
이라는 단일 페이지 테이블이 사용되었고, 이는 4MB를 커버했다. 이는 토발즈의 첫 번째 컴퓨터가 4MB의 물리 메모리를 가지고 있었다는 사실과 일치한다. 이 단일 테이블의 항목은 PTE(페이지 테이블 항목)라고 불렸다. 소프트웨어 페이지 테이블 계층 구조는 페이지 테이블 하드웨어가 계층적으로 발전해 왔다는 사실과, 이는 페이지 테이블 메모리를 절약하고 매핑 속도를 높이기 위해 수행되었다는 사실을 반영한다.
물론 전체 메모리를 단일 페이지로 분할하는 엄청난 양의 항목이 있는 단일 선형 페이지 테이블을 상상할 수도 있다. 이러한 페이지 테이블의 구조는 매우 희소할 것이다. (Sparse) 왜냐하면 가상 메모리의 많은 부분이 일반적으로 사용되지 않기 때문이다. 계층적 페이지 테이블을 사용하면 가상 주소 공간의 큰 빈 구멍이 (hole) 귀중한 페이지 테이블 메모리를 낭비하지 않는다. 왜냐하면 페이지 테이블 계층 구조의 상위 레벨에서 넓은 영역을 매핑되지 않음으로 표시하는 것으로 충분하기 때문이다.
또한, 최신 CPU에서 상위 레벨 페이지 테이블의 하나의 항목 (entry)은 물리 메모리 범위를 직접 가리킬 수 있으며, 이를 통해 가상 메모리에서 물리 메모리로 매핑하는 더 빠른 방법 (shortcut)을 제공한다.즉, 수 메가바이트 또는 심지어 기가바이트의 연속적인 물리 메모리 범위를 단일 상위 레벨 페이지 테이블 항목으로 매핑할 수 있다. 이처럼 큰 매핑 범위를 찾으면 계층 구조를 더 깊이 탐색할 필요가 없다.
현재의 페이지 테이블 계층 구조는 다음과 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
+-----+
| PGD |
+-----+
|
| +-----+
+-->| P4D |
+-----+
|
| +-----+
+-->| PUD |
+-----+
|
| +-----+
+-->| PMD |
+-----+
|
| +-----+
+-->| PTE |
+-----+
페이지 테이블 계층 구조의 다양한 레벨에 있는 기호는 아래에서부터 다음과 같은 의미를 갖는다:
pte
pte, pte_t, pteval_t = 페이지 테이블 항목 (Page Table Entry)
앞에서 언급했듯이. PTE는 PTRS_PER_PTE
요소의 배열이며, 각 요소는 pteval_t
타입이고, 가상 메모리의 단일 페이지를 물리 메모리의 단일 페이지에 매핑한다. 각 아키텍처 독립 코드는는 pteval_t
의 크기와 내용을 정의한다. 일반적인 예는 pteval_t
가 32비트 또는 64비트 값이고, 이 항목의 상위 비트는 PFN(페이지 프레임 번호)이며, 하위 비트는 메모리 보호와 같은 아키텍처별 비트인 것이다. 이름의 “항목(Entry)” 부분은 약간 혼란스러울 수 있다. 왜냐하면 Linux 1.0에서는 이것이 단일 최상위 레벨 페이지 테이블의 단일 페이지 테이블 항목을 실제로 참조했지만, 2레벨 페이지 테이블이 처음 도입되었을 때 매핑 요소의 배열이 되도록 개조되었기 때문이다. 따라서 PTE는 최하위 페이지 테이블이지, 실제 하나의 페이지를 나타내는 페이지 테이블 항목이 아니다.
pmd
pmd, pmd_t, pmdval_t = 페이지 중간 디렉토리 (Page Middle Directory)
PTE 바로 위의 계층 구조이며, PTE에 대한 PTRS_PER_PMD
참조를 갖는다.
pud
pud, pud_t, pudval_t = 페이지 상위 디렉토리 (Page Upper Directory)
pud는 4레벨 페이지 테이블을 처리하기 위해 다른 레벨 이후에 도입되었다. 이는 잠재적으로 사용되지 않거나, 아래에서 설명하는 것처럼 접힐 수 있다. (folding)
p4d
p4d, p4d_t, p4dval_t = 페이지 레벨 4 디렉토리 (Page Level 4 Directory)
PUD가 도입된 후 5레벨 페이지 테이블을 처리하기 위해 도입되었다. 이제 우리는 PGD, PMD, PUD 등을 디렉토리 레벨을 나타내는 숫자로 대체해야 하고 더 이상 임시 이름으로 계속할 수 없다는 것이 분명해졌다. 이것은 실제로 5레벨 페이지 테이블을 가진 시스템에서만 사용되며, 그렇지 않으면 접힌다.
pgd
pgd, pgd_t, pgdval_t = 페이지 전역 디렉토리 (Page Global Directory)
커널 메모리에 대한 PGD를 처리하는 Linux 커널 메인 페이지 테이블은 여전히 swapper_pg_dir
에서 찾을 수 있지만, 시스템의 각 유저 프로세스 (user process)는 자체 메모리 컨텍스트와 따라서 자체 PGD를 가지므로 struct mm_struct
에서 찾을 수 있고, 이는 다시 각 struct task_struct
에서 참조된다.
따라서 작업은 struct mm_struct
형태의 메모리 컨텍스트를 가지며, 이는 차례로 해당 페이지 전역 디렉토리에 대한 struct pgt_t *pgd
포인터를 가진다. 반복하자면, 페이지 테이블 계층 구조의 각 레벨은 포인터의 배열이다. 따라서 PGD는 다음 하위 레벨에 대한 PTRS_PER_PGD
포인터를 포함하고, P4D는 PUD 항목에 대한 PTRS_PER_P4D
포인터를 포함하는 식이다. 각 레벨의 포인터 수는 아키텍처에 따라 정의된다.
페이지 테이블 폴딩 (Page Table Folding)
아키텍처가 모든 페이지 테이블 레벨을 사용하지 않는 경우, 레벨을 접을 수 (fold) 있는데, 이는 레벨을 건너뛴다는 의미이며, 페이지 테이블에 수행되는 모든 연산은 다음 하위 레벨에 접근할 때 레벨을 건너뛰도록 컴파일 타임 (커널 빌드 시) 결정될 것이다. 가상 메모리 관리 코드와 같이 아키텍처 독립이기를 원하는 페이지 테이블 처리 코드는 현재의 5개 레벨 모두를 순회하도록 작성해야 한다.또한, 이러한 스타일은 향후 변경에 견고하도록 아키텍처별 코드에도 잘 맞아야 한다.
MMU, TLB, 및 페이지 폴트 (MMU, TLB, and Page Faults)
메모리 관리 장치 (Memory Management Unit, MMU)는 가상 주소에서 물리 주소로의 변환을 처리하는 하드웨어이다. MMU는 이러한 변환 속도를 높이기 위해 TLB (Translation Lookaside Buffers) 및 페이지 워크 캐시 (Page Walk Caches)라고 불리는 비교적 작은 하드웨어 캐시를 사용할 수 있다.
Page walk Caches는 X86에서 지원하지 않는다.
CPU가 메모리 위치에 접근할 때, CPU는 MMU에 가상 주소를 제공한다. MMU는 TLB 또는 페이지 워크 캐시 (이를 지원하는 아키텍처에서)에 기존 변환이 있는지 확인한다. 변환을 찾을 수 없는 경우, MMU는 페이지 워크를 사용하여 물리 주소를 결정하고 맵을 생성한다. 페이지의 더티 비트 (dirty bit)는 페이지에 쓰기가 발생할 때 설정된다 (즉, 켜진다). 각 메모리 페이지에는 관련된 권한 및 더티 비트가 있다. 더티 비트가 설정된 것은, 페이지가 메모리에 로드된 이후 수정되었음을 나타낸다.
아무런 문제가 없다면, 물리 메모리에 접근할 수 있고 각 물리 프레임에 대한 요청된 연산이 수행된다. MMU가 특정 변환을 찾을 수 없는 데에는 여러 가지 이유가 있다.
- CPU가 현재 작업이 허용되지 않은 메모리에 접근하려고 시도하거나,
- 데이터가 물리 메모리에 존재하지 않기 때문에 발생할 수 있다.
이러한 조건이 발생하면 MMU는 페이지 폴트 (page fault)를 트리거한다. 페이지 폴트는 예외 (exception)의 한 유형으로, CPU에 현재 실행을 일시 중지하고 언급된 예외를 처리하기 위한 특수 함수를 실행하도록 신호를 보낸다. 페이지 폴트는 일반적으로 “지연 할당 (Lazy Allocation)” 및 “쓰기 시 복사 (Copy-on-Write)”라는 프로세스 관리 최적화 기술에 의해 트리거된다. 페이지 폴트는 프레임이 스토리지 (Storage) (스왑 파티션 또는 파일)로 스왑 아웃되고 물리 메모리에서 제거되었을 때도 발생할 수 있다. 이러한 기술은 메모리 효율성을 개선하고, 대기 시간을 줄이며, 공간 점유율을 최소화한다. 이 문서는 “지연 할당” 및 “쓰기 시 복사”의 세부 사항을 더 깊이 다루지 않을 것이다. 왜냐하면 이러한 주제는 프로세스 주소 관리와 관련되어 범위에서 벗어나기 때문이다.
스왑은 다른 언급된 기술과 구별되는데, 이는 심각한 메모리 압박 상황에서 메모리를 줄이기 위한 수단으로 수행되기 때문에 바람직하지 않기 때문이다. 스왑은 커널 논리 주소 (Kernel logical address)로 매핑된 메모리에는 작동할 수 없다. 커널 논리 주소는 연속적인 물리 메모리 범위를 직접 매핑하는 커널 가상 주소 공간 (Kernel Address space)의 하위 집합이다. 이러한 임의의 논리 주소가 주어지면, 해당 물리 주소는 오프셋에 대한 간단한 산술 연산으로 결정된다. 이러한 논리 주소에 대한 접근은 복잡한 페이지 테이블 조회를 피하기 때문에 빠르지만, 프레임이 제거 및 페이징될 수 없다는 단점이 있다.
커널이 물리 프레임에 있어야 하는 데이터를 위한 공간을 확보하지 못하면, 커널은 메모리 부족 (Out-Of-Memory, OOM) 킬러를 호출하여 임계값 아래로 메모리 압력 (Memory pressure)이 감소할 때까지 우선 순위가 낮은 프로세스를 종료하여 공간을 확보한다. 추가적으로, 페이지 폴트는 코드 버그 또는 CPU가 접근하도록 지시받은 악의적으로 조작된 주소로 인해 발생할 수도 있다.
프로세스의 스레드는 자신의 주소 공간에 속하지 않는 (공유되지 않은) 메모리를 주소 지정하기 위한 명령어를 사용하거나, 읽기 전용 위치에 쓰려고 하는 명령어를 실행하려고 시도할 수 있다.이러한 조건이 사용자 공간에서 발생하면, 커널은 현재 스레드에 세그멘테이션 폴트 (Segmentation Fault, SIGSEGV) 신호를 보낸다. 이 신호는 일반적으로 스레드 및 해당 스레드가 속한 프로세스의 종료를 유발한다.
이 문서는 Linux 커널이 이러한 페이지 폴트를 처리하고, 테이블 및 테이블 항목을 생성하며, 메모리가 존재하는지 확인하고, 존재하지 않는 경우 영구 저장소 또는 다른 장치에서 데이터를 로드하도록 요청하고, MMU와 해당 캐시를 업데이트하는 방법에 대한 개요를 단순화하여 보여줄 것이다.
Page fault handling
첫 번째 단계는 아키텍처에 따라 다르다. 대부분의 아키텍처는 do_page_fault()
로 점프하는 반면, x86 인터럽트 핸들러는 handle_page_fault()
를 호출하는 DEFINE_IDTENTRY_RAW_ERRORCODE()
매크로에 의해 정의된다. 하지만, 모든 아키텍처는 결국 페이지 테이블 할당의 실제 작업을 수행하기 위해 __handle_mm_fault()
를 (보통) 호출하는 handle_mm_fault()
의 호출로 끝난다.
__handle_mm_fault()
를 호출할 수 없는 불행한 경우는 가상 주소가 접근이 허용되지 않는 물리 메모리 영역을 가리키는 것을 의미한다 (적어도 현재 컨텍스트에서는). 이 조건은 커널이 위에서 언급한 SIGSEGV 신호를 프로세스에 보내는 것으로 귀결되며 이미 위에서 설명한 결과를 발생시킨다.
__handle_mm_fault()
는 페이지 테이블의 상위 계층의 항목 오프셋을 찾고 필요할 수 있는 테이블을 할당하기 위해 여러 함수를 호출하여 작업을 수행한다. 오프셋을 찾는 함수는 *_offset()
과 같은 이름을 가지며, “*“는 pgd, p4d, pud, pmd, pte에 해당한다. 대신 해당 테이블을 계층별로 할당하는 함수는 *_alloc
이라고 불리며, 위에서 언급한 규칙을 사용하여 계층 구조의 해당 테이블 유형에 따라 이름을 지정한다.
페이지 테이블 순회 (Page table walk)는 중간 또는 상위 레벨 (PMD, PUD) 중 하나에서 끝날 수 있다. Linux는 일반적인 4KB보다 큰 페이지 크기 (Hugepage)를 지원한다. 이러한 종류의 더 큰 페이지를 사용할 때, 상위 레벨 페이지는 하위 레벨 페이지 항목 (PTE)을 사용할 필요 없이 직접 매핑할 수 있다. Hugepage는 일반적으로 2MB에서 1GB에 이르는 큰 연속적인 물리 영역을 포함한다. 이들은 각각 PMD 및 PUD 페이지 항목에 의해 매핑된다. Hugepage는 TLB 압력 감소, 페이지 테이블 오버헤드 감소, 메모리 할당 효율성, 특정 워크로드에 대한 성능 향상과 같은 여러 이점을 제공한다. 그러나 이러한 이점은 메모리 낭비 및 할당 문제와 같은 상충 관계를 수반한다.
할당을 포함한 page table walk의 맨 마지막에, 오류가 반환되지 않으면, __handle_mm_fault()
는 최종적으로 handle_pte_fault()
를 호출한다. handle_pte_fault()
는 do_fault()
를 통해 do_read_fault()
, do_cow_fault()
, do_shared_fault()
중 하나를 수행한다. “read”, “cow”, “shared”는 처리하는 폴트의 이유와 종류에 대한 힌트를 제공한다.
이러한 워크플로우의 실제 구현은 매우 복잡하다. 이 디자인은 Linux가 각 아키텍처의 특정 특성에 맞게 페이지 폴트를 처리하는 동시에 공통적인 전체 구조를 공유할 수 있도록 한다.
Linux가 페이지 폴트를 처리하는 방법에 대한 이러한 간단한 개요를 마무리하기 위해, 페이지 폴트 핸들러는 각각 pagefault_disable()
및 pagefault_enable()
로 비활성화 및 활성화될 수 있다는 점을 추가하겠다. 몇몇 코드 경로는 페이지 폴트 핸들러로의 트랩을 비활성화해야 하기 때문에 후자의 두 함수를 사용한다. 이는 주로 데드락을 방지하기 위함이다.