> [!abstract] Introduction > 가상화*Virtualization*는 컴퓨터가 메모리를 최대한 활용하기 위해 펼치는 일종의 속임수입니다. 이번 글에서는 가상화를 살펴보며 어떻게 모든 프로세스가 독립적으로 동일한 크기의 메모리를 사용하는지, 그리고 이를 위해 페이지 테이블이 어떤 구조로 설계되었는지 알아봅니다. # Physical Memory vs Virtual Memory 가상화*Virtualization*을 이해하기 위해서는 가상 메모리*Virtual Memory*, 그리고 가상 메모리와 대비되는 개념인 물리 메모리*Physical Memory*를 이해해야 한다. ## Physical Memory 물리 메모리*Physical Memory*는 메인보드에 박혀 있는 그 메모리의 실제 모습을 나타내며, 바이트 단위의 메모리 셀*cell*이 연속적으로 이어진 배열의 형태로 되어 있다. 각 셀은 0부터 시작하는 고유의 물리 주소*Physical Address*를 가지고 있으며, CPU는 이 주소 체계를 이용하여 메모리와 관련된 연산을 수행한다. ![[PhysicalAddressing.png]] 그러나 메모리는 여전히 싸지 않으며 과거에는 더욱 비쌌고, 기기마다 하드웨어 사양이 제각기 달라 하나의 코드로 여러 기기에서 동일하게 작동하는 프로그램을 만들기란 불가능에 가까웠다. 그래서 이 문제를 해결하기 위해 가상화*Virtualization*라는 해결책이 등장하였다. ## Virtual Memory 가상화라 불리는 이 해결책의 핵심은 단순하다. 모든 프로세스가 각자 충분한 공간을 사용할 수 있도록 하자는 것이다. 그런데 실제 메모리는 하나고 모든 프로세스가 같이 사용하는데 이게 어떻게 가능한 걸까? 그 비결은 바로 프로세스가 보는 메모리를 주소 공간*Address Space*로 추상화시켜 물리적 메모리와 다르게 보이도록 하는 것이다. ![[AddressSpace.png]] 프로세스가 보는 메모리는 위와 같이 하나의 연속적인 공간이며, 프로세스가 사용하기에 충분한 크기를 가지고 있다. 실제로는 모든 프로세스가 하나의 물리 메모리를 나누어 사용하지만, 프로세스에게는 이렇게 충분한 크기의 연속적인 주소 공간을 혼자 사용하고 있다는 일종의 눈속임을 주는 셈이다. 이것이 바로 프로세스가 보는 메모리인 가상 메모리*Virtual Memory*다. 가상 메모리에 접근할 때는 가상 주소*Virtual Address*를 사용하는데, 이 주소는 가상 메모리를 기준으로 구성된 주소여서 같은 메모리에 대한 주소여도 물리 주소 공간*Physical Address Space*의 주소와 가상 주소 공간*Virtual Address Space*의 주소가 다르다. 그럼에도 불구하고 오늘날 컴퓨터에는 아무런 문제가 없는 이유는 컴퓨터 시스템 내부에 가상 주소와 물리 주소를 같이 사용하기 위한 설계가 적용되었기 때문이다. ## Address Translation 이 설계를 이해하려면 우선 가상 주소와 물리 주소가 어떻게 같이 사용되는지 알아야 한다. 가장 기본적인 방법은 아래와 같이 가상 주소를 물리 주소로 바꿔주는 메모리 관리 유닛*Memory Management Unit, MMU*를 전용 하드웨어로 구현하는 것이다. ![[VirtualAddress.png]] MMU의 역할은 가상 주소를 읽고 그 가상 주소와 연결된 물리 주소에 접근하는 것인데, 이를 위하여 MMU 내부에 페이지 테이블*Page Table*이 담겨 있다. 페이지 테이블에는 가상 주소와 물리 주소가 한 쌍으로 묶여 있고, 이 테이블을 통하여 가상 주소는 물리 주소로 바뀌어 메모리에 접근할 때는 언제나 물리 주소를 사용하게 된다. # Paging 이 시점에서 물리 주소 공간과 가상 주소 공간의 크기를 비교해보자. 일반적인 PC에 사용하는 물리 메모리는 32GB 정도면 많다는 소리를 듣는데, 일반적인 PC에 사용하는 CPU 아키텍처는 64비트다. 이는 우리가 사용하는 가상 주소 공간의 크기가 최대 $2^{64}$ 바이트라는 말인데, 이 크기는 32GB를 훌쩍 넘는 것은 물론이고 서버나 슈퍼컴퓨터에서도 잘 사용하지 않는 크기의 메모리다. 그렇다면 실제로는 프로세스가 메인 메모리 뿐만 아니라 SSD 같은 저장 장치에도 저장되어 있는 것이다. 프로세스가 실제로 저장된 모습을 이해하기 위해서는 소프트웨어의 시점과는 다르게 접근할 필요가 있다. 운영체제는 메모리 가상화*Memory Virtualization*를 통해 실제로는 곳곳에 흩어져 있는 프로세스를 하나의 통합된 주소 공간으로 파악하고, 이 과정에서 메모리를 가상 메모리(혹은 논리 메모리*Logical Memory*)와 물리적 메모리로 구분하게 되는데, 덕분에 오늘날 모든 프로그램은 프로세스의 시점, 즉 가상 메모리를 기반으로 작성되어 물리적 메모리를 고려할 필요가 없다. ![Paging: 가상 메모리와 물리 메모리의 관계](MemoryPaging.png) 이런 일을 가능하게 해준 것이 바로 페이징*paging*이다. 페이징은 물리 메모리를 고정된 크기의 단위인 프레임*frame*으로, 가상 메모리를 동일한 크기의 페이지*page*로 쪼개는 것부터 시작한다. CPU가 생성한 모든 주소는 이 페이지에 기반하며, 크게 페이지 넘버*Page Number*와 페이지 오프셋*Page Offset*으로 나눌 수 있다. 페이지 넘버는 페이지의 위치를 가리키고 페이지 오프셋은 페이지 내부의 위치를 의미한다. ![[PageAddress.png]] 여기에 더해 MMU에 페이지 테이블을 구현하면 기본적인 페이징을 구현할 수 있다. MMU에서 가상 주소를 물리 주소로 바꾸는 원리는 단순하게 페이지 테이블을 보고 페이지 넘버 p를 프레임 넘버*Frame Number* f로 바꾸는 것이다. 페이지 p에 대응하는 프레임 f는 위치만 다를 뿐 내부 구성은 동일하기 때문에 페이지 오프셋 d는 그대로 사용된다. 페이징의 결정적인 장점은 외부 단편화*external fragmentation*[^external-fragmentation]가 발생하지 않는다는 것이다. 모든 프레임의 크기가 동일하므로 빈 프레임이 어디에 있든 페이지를 할당할 수 있고, 메모리 곳곳에 흩어진 빈 공간을 모두 활용할 수 있다. 다만 페이지 내부에 사용하지 않는 공간이 남는 내부 단편화*internal fragmentation*는 여전히 존재한다. 예를 들어 4KB 페이지에 3KB만 사용한다면 1KB가 낭비되지만, 이 낭비는 한 페이지 크기 미만으로 제한되므로 외부 단편화에 비하면 미미하다. ## Page Table 이제 페이지 테이블의 구조를 자세하게 뜯어볼 차례다. 페이지 넘버 p는 페이지 테이블의 인덱스를 가리키며 그 자리에는 p에 대응하는 프레임 넘버 f가 있다. ![[PageTable.png]] 페이지 테이블은 페이지 테이블 엔트리*Page Table Entry, PTE*의 배열로 이루어져 있으며, 각 PTE에는 자신이 참조하고 있는 페이지의 물리 주소가 저장되어 있다. 이 주소는 RAM 뿐만 아니라 디스크의 어떤 공간을 가리킬 수도 있다. ![[PageIndirection.png]] ### PTE의 구조 PTE는 단순히 프레임 번호만 저장하는 것이 아니라 해당 페이지에 관한 다양한 메타데이터를 함께 보관한다. x86-64 아키텍처의 PTE를 기준으로 주요 필드를 살펴보면 다음과 같다. | 비트 | 이름 | 설명 | |------|------|------| | 0 | Present (P) | 해당 페이지가 물리 메모리에 존재하는지 여부. 0이면 Page Fault 발생 | | 1 | Read/Write (R/W) | 0이면 읽기 전용, 1이면 읽기·쓰기 가능 | | 2 | User/Supervisor (U/S) | 0이면 커널 모드에서만 접근, 1이면 사용자 모드에서도 접근 가능 | | 5 | Accessed (A) | 하드웨어가 해당 페이지에 접근했을 때 자동으로 1로 설정 | | 6 | Dirty (D) | 해당 페이지에 쓰기가 발생했을 때 자동으로 1로 설정 | | 7 | Page Size (PS) | Huge Page 매핑 여부. 1이면 해당 단계에서 변환 종료[^huge-page] | | 8 | Global (G) | 1이면 `CR3` 교체 시에도 TLB에서 플러시되지 않는 전역 매핑 | | 63 | Execute Disable (NX) | 1이면 해당 페이지에서 명령어 실행 불가[^nx-bit] | | 12~51 | Physical Address | 프레임의 물리 주소 (4KB 정렬이므로 하위 12비트는 암묵적으로 0) | `Accessed` 비트와 `Dirty` 비트는 운영체제의 페이지 교체 알고리즘에 핵심적인 역할을 한다. 운영체제는 `Accessed` 비트를 주기적으로 검사하고 초기화하여 어떤 페이지가 최근에 사용되었는지 추적하며, `Dirty` 비트를 통해 교체할 페이지를 디스크에 다시 써야 하는지 판단한다. `Dirty` 비트가 0인 깨끗한 페이지는 디스크 쓰기 없이 바로 교체할 수 있으므로 교체 비용이 낮다. ### Page Hit과 Page Fault PTE가 RAM에 존재하는 어떤 페이지를 가리키고 있고, 그 페이지를 실제로 RAM에서 읽을 수 있다면 **Page Hit**이라 부른다. 반면, PTE의 `Present` 비트가 0이거나 RAM에 없는 페이지를 가리키고 있다면 이를 **Page Fault**라 부르며, 이럴 경우 디스크에 있는 페이지를 RAM으로 가져온다. 만약 이미 RAM이 꽉 차 있다면, 페이지 교체 알고리즘에 따라 디스크로 보낼 페이지(희생 페이지*victim page*)를 선택해 그 페이지가 있던 자리에 저장한다. ## Multi-Level Page Table 단일 페이지 테이블에는 치명적인 문제가 하나 있다. 32비트 주소 공간에서 4KB 페이지를 사용한다고 가정하면, 가상 주소 공간 전체를 커버하기 위해 $2^{32} / 2^{12} = 2^{20}$, 즉 약 100만 개의 PTE가 필요하다. 각 PTE가 4바이트라면 페이지 테이블 하나에 4MB가 소요되는데, 이것을 **프로세스마다** 하나씩 유지해야 한다. 64비트 환경에서는 이 문제가 더 심각해져서, 48비트 가상 주소 공간 전체를 단일 테이블로 매핑하려면 $2^{36}$개의 PTE, 즉 512GB에 달하는 메모리가 필요하다. 이는 현실적으로 불가능하다. 핵심 관찰은 프로세스가 가상 주소 공간 전체를 사용하지 않는다는 점이다. 일반적인 프로세스는 코드, 힙, 스택 등 일부 영역만 사용하고, 나머지 대부분의 가상 주소 공간은 비어 있다. 단일 페이지 테이블은 이 빈 공간에 대해서도 PTE를 할당해야 하므로 메모리가 낭비된다. 이 문제를 해결하는 방법이 다단계 페이지 테이블*Multi-Level Page Table*이다. 핵심 아이디어는 페이지 테이블 자체를 페이지 단위로 쪼개고, 상위 테이블이 하위 테이블의 존재 여부를 관리하는 계층 구조를 만드는 것이다. ### 2단계 페이지 테이블 가장 단순한 형태인 2단계 페이지 테이블을 살펴보자. 가상 주소는 세 부분으로 나뉜다. $ \underbrace{p_1}_{\text{1단계 인덱스}} \quad \underbrace{p_2}_{\text{2단계 인덱스}} \quad \underbrace{d}_{\text{페이지 오프셋}} $ 1단계 페이지 테이블(혹은 페이지 디렉토리(Page Directory))은 항상 메모리에 존재하며, 각 엔트리는 2단계 페이지 테이블의 물리 주소를 가리킨다. 2단계 페이지 테이블은 최종적으로 물리 프레임 번호를 담고 있다. 여기서 결정적인 차이가 드러나는데, 프로세스가 사용하지 않는 가상 주소 범위에 대응하는 1단계 엔트리는 `Present` 비트를 0으로 설정하기만 하면 된다. 해당 2단계 테이블을 아예 할당하지 않아도 되므로, 사용하지 않는 주소 범위에 대한 메모리 낭비가 사라진다. ![[multiLevelPageTable.svg]] 32비트 시스템에서 4KB 페이지를 기준으로 비교하면, 단일 페이지 테이블은 항상 4MB를 차지하지만, 2단계 페이지 테이블은 페이지 디렉토리(4KB)와 실제로 사용하는 2단계 테이블들만 메모리에 존재하면 된다. 주소 공간의 1%만 사용하는 프로세스라면 약 40KB 정도면 충분하다. ### x86-64의 4단계 페이징 x86-64 아키텍처는 4단계 페이지 테이블[^pml4]을 사용한다. 64비트 가상 주소 중 실제로 사용하는 것은 하위 48비트이며[^canonical-address], 이 48비트는 다음과 같이 분할된다. $ \underbrace{63 \cdots 48}_{\text{부호 확장}} \quad \underbrace{47 \cdots 39}_{\text{PML4 (9비트)}} \quad \underbrace{38 \cdots 30}_{\text{PDP (9비트)}} \quad \underbrace{29 \cdots 21}_{\text{PD (9비트)}} \quad \underbrace{20 \cdots 12}_{\text{PT (9비트)}} \quad \underbrace{11 \cdots 0}_{\text{오프셋 (12비트)}} $ 각 단계의 테이블은 하나의 4KB 페이지에 딱 맞도록 512개($2^9$)의 8바이트 엔트리로 구성된다. 주소 변환 과정은 다음과 같다. 1. `CR3` 레지스터[^cr3]가 PML4(Page Map Level 4) 테이블의 물리 주소를 가리킨다. 2. 가상 주소의 비트 47~39(9비트)로 PML4 엔트리를 선택하여 PDP(Page Directory Pointer) 테이블의 물리 주소를 얻는다. 3. 비트 38~30으로 PDP 엔트리를 선택하여 PD(Page Directory) 테이블의 물리 주소를 얻는다. 4. 비트 29~21로 PD 엔트리를 선택하여 PT(Page Table) 테이블의 물리 주소를 얻는다. 5. 비트 20~12로 PT 엔트리를 선택하여 최종 물리 프레임 번호를 얻는다. 6. 프레임 번호에 오프셋(비트 11~0)을 결합하여 물리 주소가 완성된다. ![[x86FourLevelPaging.svg]] 이 과정에서 매 단계마다 메모리를 한 번 읽어야 하므로 총 네 번의 메모리 접근이 추가된다. 이 오버헤드를 줄이기 위해 [TLB](Translation%20Lookaside%20Buffer.md)가 최근 변환 결과를 캐싱하여, 대부분의 접근에서 페이지 테이블 워크를 건너뛸 수 있게 한다. Intel은 Ice Lake 서버 프로세서부터 5단계 페이징*PML5*도 지원하는데, 이 경우 최상위에 PML5 테이블이 추가되어 57비트 가상 주소(128PB)를 사용할 수 있다. 다만 대부분의 워크로드에서 48비트(256TB)이면 충분하므로, 5단계 페이징은 대규모 메모리를 필요로 하는 서버 환경에서 선택적으로 활성화된다. ## Demand Paging 지금까지 살펴본 내용만으로는 가상 메모리의 진가를 모두 드러내기 어렵다. 가상 메모리의 핵심 전략 중 하나는 **요구 페이징***demand paging*으로, 프로세스를 실행할 때 모든 페이지를 메모리에 올리는 것이 아니라 실제로 접근하는 순간에만 메모리에 적재하는 기법이다. 프로세스가 처음 실행될 때 운영체제는 프로세스의 가상 주소 공간을 설정하지만, 대부분의 페이지를 실제 물리 메모리에 올리지 않는다. 대신 PTE의 `Present` 비트를 0으로 설정해두고, 프로세스가 해당 페이지에 접근하는 순간 Page Fault가 발생하면 그때서야 디스크에서 해당 페이지를 물리 메모리로 가져온다. 이 접근 방식은 참조 지역성*locality of reference*[^locality]이라는 프로그램의 본질적인 특성에 기반한다. 프로그램은 실행 시간의 대부분을 소수의 페이지에서 보내므로, 전체를 올리지 않아도 거의 문제가 없다. 요구 페이징의 이점은 세 가지다. 1. 프로세스의 시작 시간이 빨라진다. 모든 코드와 데이터를 디스크에서 읽어올 필요 없이 즉시 실행을 시작할 수 있다. 2. 물리 메모리를 절약한다. 한 번도 접근하지 않는 페이지(예: 에러 처리 코드)는 물리 메모리를 전혀 소비하지 않는다. 3. 물리 메모리보다 큰 가상 주소 공간을 사용할 수 있다. 활발히 사용되는 페이지만 물리 메모리에 유지하고 나머지는 디스크에 두면 되기 때문이다. ## Page Fault 처리 요구 페이징에서 가장 핵심적인 메커니즘은 Page Fault의 처리다. CPU가 가상 주소를 발행하고 MMU가 페이지 테이블을 조회했을 때 `Present` 비트가 0이면 하드웨어가 Page Fault 예외를 발생시키고, 제어권이 운영체제의 Page Fault 핸들러로 넘어간다. 핸들러의 동작은 다음과 같다. 1. **주소 유효성 검사**: 폴트가 발생한 가상 주소가 프로세스의 유효한 주소 공간에 속하는지 확인한다. 유효하지 않으면 세그멘테이션 폴트*segmentation fault*를 발생시켜 프로세스를 종료한다. 2. **권한 검사**: 해당 메모리 영역에 대한 접근 권한(읽기, 쓰기, 실행)이 적절한지 확인한다. 읽기 전용 영역에 쓰기를 시도했다면 보호 폴트*protection fault*가 발생한다. 3. **빈 프레임 확보**: 물리 메모리에서 빈 프레임을 찾는다. 빈 프레임이 없으면 페이지 교체 알고리즘을 실행하여 희생 페이지를 선택한다. 4. **디스크 I/O**: 디스크(스왑 영역 또는 파일 시스템)에서 요청된 페이지를 확보한 프레임으로 읽어온다. 이 과정은 수 밀리초가 소요되며, 그동안 해당 프로세스는 대기 상태*blocked*로 전환되고 다른 프로세스가 CPU를 사용한다. 5. **페이지 테이블 갱신**: 디스크 I/O가 완료되면 PTE의 `Present` 비트를 1로 설정하고 프레임 번호를 기록한다. 6. **명령어 재시작**: 폴트를 유발한 명령어를 다시 실행한다. 이번에는 해당 페이지가 물리 메모리에 존재하므로 정상적으로 진행된다. ![[pageFaultFlow.svg]] Page Fault의 비용은 TLB 미스와는 비교할 수 없을 정도로 크다. [TLB](Translation%20Lookaside%20Buffer.md) 미스가 수백 나노초 수준인 반면, Page Fault는 디스크 I/O를 수반하므로 수 밀리초에서 수십 밀리초가 소요된다. SSD 기준으로도 약 $50\mu s$~$200\mu s$의 지연이 발생하고, HDD라면 $5$~$10ms$에 달한다. 이 차이는 약 만 배에 이르므로, Page Fault의 빈도를 최소화하는 것이 시스템 성능에 결정적인 영향을 미친다. ## Page Replacement 물리 메모리가 가득 찬 상태에서 새 페이지를 적재해야 할 때, 운영체제는 기존 페이지 중 하나를 골라 디스크로 내보내야 한다. 어떤 페이지를 내보낼 것인가를 결정하는 것이 페이지 교체 알고리즘*page replacement algorithm*이며, 이 선택이 시스템의 Page Fault 빈도를 좌우한다. 이론적으로 가장 이상적인 알고리즘은 **OPT**(최적 알고리즘)로, 앞으로 가장 오랫동안 사용되지 않을 페이지를 교체한다. 그러나 미래의 접근 패턴을 알 수 없으므로 실제 구현은 불가능하고, 다른 알고리즘의 성능을 비교하는 하한*lower bound*으로 사용된다. 실제 운영체제에서 널리 사용되는 알고리즘은 다음과 같다. **LRU**(Least Recently Used)는 가장 오래 사용되지 않은 페이지를 교체한다. 과거의 접근 기록을 미래의 예측에 활용하는 것으로, 참조 지역성을 잘 반영한다. 그러나 정확한 LRU를 구현하려면 모든 메모리 접근에 대해 타임스탬프를 기록하거나 스택을 유지해야 하므로 하드웨어 비용이 크다. 이 때문에 대부분의 운영체제는 LRU를 근사하는 알고리즘을 사용한다. 리눅스 커널은 **이중 리스트 LRU 근사***two-list LRU approximation*를 채택하고 있는데, 활성 리스트*active list*와 비활성 리스트*inactive list*로 페이지를 분류하고, PTE의 `Accessed` 비트를 주기적으로 검사하여 페이지의 활성 여부를 판단한다. 교체 대상은 비활성 리스트의 꼬리에서 선택된다. 교체할 페이지의 `Dirty` 비트가 1이면 해당 페이지의 내용이 수정되었으므로 디스크에 다시 써야 한다. 이를 **페이지 아웃***page-out* 또는 **스왑 아웃***swap-out*이라 부른다. `Dirty` 비트가 0인 깨끗한 페이지는 디스크 쓰기 없이 즉시 프레임을 회수할 수 있으므로, 교체 비용이 훨씬 낮다. ## Thrashing 프로세스의 워킹 셋*working set*[^working-set]이 할당된 물리 프레임 수보다 크면 Page Fault가 연쇄적으로 발생한다. 페이지를 가져오자마자 다른 페이지가 교체되고, 교체된 페이지가 곧바로 다시 필요해지는 악순환이 반복되는데, 이 현상을 **스래싱***thrashing*이라 한다. 스래싱이 발생하면 CPU 이용률이 급격히 떨어진다. 대부분의 시간이 Page Fault 처리, 즉 디스크 I/O 대기에 소비되기 때문이다. 운영체제가 CPU 이용률이 낮다고 판단하여 더 많은 프로세스를 적재하면 상황은 더욱 악화된다. 각 프로세스에 할당되는 프레임이 줄어들어 스래싱이 심화되고, 시스템 전체가 거의 멈추는 지경에 이른다. 스래싱을 방지하기 위한 기본 전략은 각 프로세스에 워킹 셋만큼의 프레임을 보장하는 것이다. 리눅스 커널은 프레임 회수 데몬*kswapd*[^kswapd]이 여유 메모리 수위를 감시하면서 미리 페이지를 회수하고, 메모리 압박이 심해지면 OOM Killer*Out-of-Memory Killer*[^oom-killer]가 메모리를 가장 많이 소비하는 프로세스를 강제 종료하여 시스템 전체의 붕괴를 막는다. # 가상화의 전체 그림 하드웨어 수준에서는 페이징과 다단계 주소 변환, 그리고 [TLB](Translation%20Lookaside%20Buffer.md)를 통하여 가상화를 지원하고 있으며, 운영체제 수준에서는 요구 페이징, 페이지 교체 정책, 여분의 메모리 공간을 리스트로 관리하는 등 메모리 관리 기능과 예외 처리 코드로 가상화를 지원하고 있다. 이 두 계층의 협력이 있기에 오늘날 모든 프로세스는 거대하고 연속적인 주소 공간을 독점하는 듯한 환상 속에서 안전하게 실행될 수 있다. --- [^external-fragmentation]: 외부 단편화*external fragmentation*는 메모리의 빈 공간이 여러 곳에 흩어져 있어, 총 빈 공간은 충분하지만 연속된 큰 블록을 할당할 수 없는 상태다. 세그멘테이션 기반 메모리 관리에서 주로 발생하며, 페이징은 고정 크기 단위 할당으로 이 문제를 근본적으로 해결한다. [^huge-page]: Huge Page(대형 페이지)는 표준 4KB보다 큰 페이지 크기를 사용하여 [TLB](Translation%20Lookaside%20Buffer.md) 효율을 높이는 기법이다. x86-64에서는 PD 엔트리의 PS 비트를 1로 설정하여 2MB 매핑을, PDP 엔트리에서 1GB 매핑을 구현한다. [^nx-bit]: NX 비트*No-Execute bit*는 AMD가 도입하고(Enhanced Virus Protection) Intel이 XD*Execute Disable* 비트라는 이름으로 채택한 하드웨어 보안 기능이다. 데이터 영역(힙, 스택)에서 코드를 실행하지 못하도록 막아 버퍼 오버플로우 공격을 완화한다. 리눅스에서는 `noexec` 마운트 옵션과 함께 사용된다. [^pml4]: PML4*Page Map Level 4*는 x86-64의 4단계 페이지 테이블 계층에서 최상위 테이블이다. `CR3` 레지스터가 PML4의 물리 주소를 가리키며, 512개의 PML4 엔트리가 각각 512GB의 가상 주소 범위를 담당하여 총 256TB($2^{48}$ 바이트)를 커버한다. [^canonical-address]: x86-64에서 가상 주소의 비트 63~48은 비트 47의 부호 확장이어야 하며, 이 규칙을 따르는 주소를 정규 주소*canonical address*라 한다. 비트 47이 0이면 상위 비트가 모두 0(사용자 공간, `0x0000000000000000`~`0x00007FFFFFFFFFFF`), 1이면 모두 1(커널 공간, `0xFFFF800000000000`~`0xFFFFFFFFFFFFFFFF`)이 되어, 주소 공간 중앙에 거대한 비정규 구멍*non-canonical hole*이 생긴다. [^cr3]: `CR3`*Control Register 3*은 x86에서 현재 프로세스의 최상위 페이지 테이블(PML4 또는 PML5)의 물리 주소를 저장하는 제어 레지스터다. 프로세스 전환 시 `CR3`를 새 프로세스의 페이지 테이블 주소로 변경하면 주소 공간이 전환된다. PCID를 사용하지 않으면 `CR3` 변경 시 모든 non-global TLB 엔트리가 자동으로 플러시된다. [^locality]: 참조 지역성*locality of reference*은 프로그램이 최근에 접근한 메모리 주소(시간 지역성*temporal locality*) 또는 그 근처의 주소(공간 지역성*spatial locality*)를 가까운 미래에 다시 접근할 가능성이 높다는 관찰이다. 캐시, TLB, 프리페칭 등 현대 컴퓨터 아키텍처의 대부분의 최적화가 이 특성에 기반한다. [^working-set]: 워킹 셋*working set*은 Peter Denning(1968)이 제안한 개념으로, 시간 구간 $\Delta$ 동안 프로세스가 접근한 페이지의 집합이다. $\Delta$가 너무 작으면 지역성을 포착하지 못하고, 너무 크면 불필요한 페이지까지 포함한다. 운영체제는 각 프로세스의 워킹 셋 크기만큼의 프레임을 할당하여 스래싱을 방지한다. [^kswapd]: `kswapd`는 리눅스 커널의 백그라운드 페이지 회수 데몬으로, 각 NUMA 노드마다 하나의 스레드(`kswapd0`, `kswapd1`, …)가 실행된다. 여유 메모리가 `pages_low` 워터마크 아래로 떨어지면 깨어나 `pages_high` 워터마크에 도달할 때까지 비활성 리스트에서 페이지를 회수한다. [^oom-killer]: OOM Killer*Out-of-Memory Killer*는 리눅스 커널이 메모리 부족 상태에서 시스템 전체의 교착을 방지하기 위해 프로세스를 강제 종료하는 메커니즘이다. 각 프로세스에 `oom_score`를 부여하고(RSS 크기, `oom_score_adj` 값 등을 기반), 점수가 가장 높은 프로세스에 `SIGKILL`을 전송한다. # 출처 - Silberschatz, A., Galvin, P. B., and Gagne, G. (2018) *Operating System Concepts*. 10th ed. Hoboken, NJ: Wiley. - Arpaci-Dusseau, R. H. and Arpaci-Dusseau, A. C. (2023) *Operating Systems: Three Easy Pieces*. 1.10 ed. Arpaci-Dusseau Books. - Bryant, R. E. and O'Hallaron, D. R. (2016) *Computer Systems: A Programmer's Perspective*. 3rd ed. Boston: Pearson. - Hennessy, J. L. and Patterson, D. A. (2019) *Computer Architecture: A Quantitative Approach*. 6th ed. Cambridge, MA: Morgan Kaufmann. - Bovet, D. P. and Cesati, M. (2008) *Understanding the Linux Kernel*. 3rd ed. Sebastopol, CA: O'Reilly. - Intel Corporation. *Intel® 64 and IA-32 Architectures Software Developer's Manual*. Volume 3, Chapter 4 "Paging". - AMD. *AMD64 Architecture Programmer's Manual — System Programming*. Volume 2. - Denning, P. J. (1968) "The working set model for program behavior," *Communications of the ACM*, 11(5), pp. 323–333. - Linux kernel source v6.12 — `mm/vmscan.c`, `mm/oom_kill.c`.