이미지 로딩 중...

Rust로 만드는 나만의 OS - 페이징 개념 완벽 가이드 - 슬라이드 1/11
A

AI Generated

2025. 11. 13. · 5 Views

Rust로 만드는 나만의 OS - 페이징 개념 완벽 가이드

운영체제의 핵심 메모리 관리 기법인 페이징을 Rust로 직접 구현해봅니다. 가상 메모리부터 페이지 테이블, TLB까지 실무 OS 개발에 필요한 모든 개념을 다룹니다.


목차

  1. 가상_메모리와_페이징_기초 - 물리 메모리를 추상화하는 첫걸음
  2. 페이지_테이블_구조 - 계층적 주소 변환의 핵심
  3. 페이지_테이블_순회 - 가상 주소에서 물리 주소로
  4. 페이지_할당자_구현 - 물리 프레임 관리
  5. TLB와_캐싱_최적화 - 주소 변환 성능 향상
  6. 페이지_폴트_처리 - 예외 상황 대응
  7. Identity_Mapping과_Higher_Half - 커널 주소 공간 설계
  8. Recursive_Mapping - 페이지 테이블 자기 참조
  9. PCID와_ASID - 컨텍스트_스위치_최적화
  10. Huge_Pages - 대형_페이지_최적화

1. 가상_메모리와_페이징_기초 - 물리 메모리를 추상화하는 첫걸음

시작하며

여러분이 여러 프로그램을 동시에 실행할 때, 각 프로그램이 0x0000 주소부터 메모리를 사용하려고 한다면 어떻게 될까요? 충돌이 일어나고, 한 프로그램이 다른 프로그램의 메모리를 덮어쓰는 참사가 발생할 것입니다.

이런 문제는 실제 개발 현장에서 메모리 보호와 격리가 필요한 모든 시스템에서 발생합니다. 특히 멀티태스킹 환경에서는 각 프로세스가 독립적인 메모리 공간을 가진 것처럼 보여야 하지만, 실제 물리 메모리는 하나뿐입니다.

바로 이럴 때 필요한 것이 페이징(Paging)입니다. 페이징은 가상 주소를 물리 주소로 변환하여 각 프로세스가 독립적인 메모리 공간을 가진 것처럼 만들어주는 핵심 메커니즘입니다.

개요

간단히 말해서, 페이징은 메모리를 고정 크기의 블록(페이지)으로 나누고, 가상 주소를 물리 주소로 매핑하는 메모리 관리 기법입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 페이징 없이는 메모리 보호, 프로세스 격리, 효율적인 메모리 할당이 불가능합니다.

예를 들어, Docker 컨테이너나 가상 머신이 서로 격리되어 실행되는 것도 페이징 덕분입니다. 기존에는 세그멘테이션(Segmentation)을 사용했지만 외부 단편화 문제가 심각했습니다.

이제는 페이징을 통해 고정 크기 블록을 사용하여 단편화를 최소화하고 메모리 관리를 단순화할 수 있습니다. 페이징의 핵심 특징은 첫째, 고정 크기 페이지(보통 4KB), 둘째, 가상-물리 주소 변환, 셋째, 페이지 테이블을 통한 매핑입니다.

이러한 특징들이 메모리 보호와 효율성을 동시에 보장하기 때문에 현대 모든 OS의 기반이 됩니다.

코드 예제

// Rust로 페이지 크기와 기본 구조 정의
pub const PAGE_SIZE: usize = 4096; // 4KB 페이지

// 가상 주소 구조체
#[derive(Debug, Clone, Copy)]
#[repr(transparent)]
pub struct VirtAddr(u64);

impl VirtAddr {
    // 페이지 번호 추출 (상위 52비트)
    pub fn page_number(&self) -> u64 {
        self.0 >> 12
    }

    // 페이지 오프셋 추출 (하위 12비트)
    pub fn page_offset(&self) -> u64 {
        self.0 & 0xFFF
    }
}

설명

이것이 하는 일: 가상 주소를 페이지 번호와 오프셋으로 분리하여 페이지 테이블 조회의 기반을 마련합니다. 첫 번째로, PAGE_SIZE 상수는 4096바이트(4KB)로 설정됩니다.

이는 x86-64 아키텍처의 표준 페이지 크기입니다. 4KB를 선택한 이유는 너무 작으면 페이지 테이블이 커지고, 너무 크면 내부 단편화가 증가하기 때문에 적절한 균형점입니다.

그 다음으로, VirtAddr 구조체가 64비트 주소를 감싸고 있습니다. page_number() 메서드는 상위 52비트를 추출하여(오른쪽으로 12비트 시프트) 어느 페이지인지 식별합니다.

page_offset() 메서드는 하위 12비트(0xFFF와 AND 연산)를 추출하여 페이지 내의 정확한 위치를 파악합니다. 마지막으로, 이 분리된 정보가 하드웨어 MMU(Memory Management Unit)에 전달되면, 페이지 번호는 페이지 테이블의 인덱스로, 오프셋은 물리 페이지 내의 위치로 사용되어 최종 물리 주소를 계산합니다.

여러분이 이 코드를 사용하면 가상 주소를 구조적으로 다룰 수 있고, 타입 안정성을 확보하여 주소 계산 실수를 컴파일 타임에 잡을 수 있습니다. 또한 비트 연산을 캡슐화하여 코드 가독성이 높아지고, 나중에 다른 아키텍처로 포팅할 때도 수정이 용이합니다.

실전 팁

💡 페이지 크기는 CPU 아키텍처에 따라 다르므로, 빌드 타임에 타겟에 맞게 설정하는 것이 좋습니다. x86-64는 4KB, ARM은 4KB 또는 16KB를 지원합니다.

💡 VirtAddr를 repr(transparent)로 선언하면 내부 u64와 동일한 메모리 레이아웃을 가져 FFI(Foreign Function Interface)나 하드웨어 레지스터 접근 시 안전합니다.

💡 페이지 정렬된 주소만 허용하려면 new() 메서드에서 assert!(addr & 0xFFF == 0)로 검증하세요. 런타임 오류를 조기에 발견할 수 있습니다.

💡 디버깅 시 Display trait을 구현하여 주소를 0x1234_5678_9ABC 형식으로 출력하면 가독성이 크게 향상됩니다.

💡 const fn으로 주소 생성 함수를 만들면 컴파일 타임에 주소 계산이 가능해져 런타임 오버헤드가 없습니다.


2. 페이지_테이블_구조 - 계층적 주소 변환의 핵심

시작하며

여러분이 64비트 주소 공간(16 엑사바이트)을 모두 매핑하려고 한다면, 페이지 테이블은 얼마나 커질까요? 단순 계산으로도 수백 테라바이트가 필요합니다.

이는 현실적으로 불가능합니다. 이런 문제는 대규모 가상 주소 공간을 효율적으로 관리해야 하는 모든 현대 OS에서 발생합니다.

메모리는 한정되어 있지만, 가상 주소 공간은 엄청나게 크기 때문입니다. 바로 이럴 때 필요한 것이 계층적 페이지 테이블(Hierarchical Page Table)입니다.

4단계 또는 5단계로 나누어 실제 사용하는 메모리 영역만 테이블을 할당하여 공간을 절약합니다.

개요

간단히 말해서, 계층적 페이지 테이블은 페이지 테이블을 여러 단계로 나누어 트리 구조로 구성하는 방식입니다. x86-64에서는 4단계(PML4, PDP, PD, PT)를 사용합니다.

왜 이 개념이 필요한지 실무 관점에서 설명하자면, sparse한 주소 공간을 효율적으로 표현하기 위해서입니다. 예를 들어, 프로세스가 코드 영역(낮은 주소)과 스택 영역(높은 주소)만 사용한다면, 중간 영역에 대한 페이지 테이블은 할당하지 않아도 됩니다.

기존의 단일 레벨 페이지 테이블은 전체 주소 공간에 대한 테이블을 미리 할당해야 했습니다. 이제는 계층 구조를 통해 필요한 부분만 동적으로 할당하여 메모리 사용량을 극적으로 줄일 수 있습니다.

계층적 페이지 테이블의 핵심 특징은 첫째, 각 레벨이 512개 엔트리를 가짐(9비트씩 분할), 둘째, 사용하지 않는 영역은 테이블 미할당, 셋째, 재귀적 구조로 확장 가능합니다. 이러한 특징들이 실제 메모리 사용량을 수만 분의 1로 줄여주므로 대규모 시스템에서 필수적입니다.

코드 예제

// 페이지 테이블 엔트리 (8바이트)
#[derive(Clone, Copy)]
#[repr(transparent)]
pub struct PageTableEntry(u64);

impl PageTableEntry {
    // 물리 프레임 주소 추출 (비트 12-51)
    pub fn addr(&self) -> PhysAddr {
        PhysAddr((self.0 & 0x000F_FFFF_FFFF_F000) as usize)
    }

    // 플래그 확인
    pub fn is_present(&self) -> bool { self.0 & 0x1 != 0 }
    pub fn is_writable(&self) -> bool { self.0 & 0x2 != 0 }
    pub fn is_user(&self) -> bool { self.0 & 0x4 != 0 }

    // 새 엔트리 생성
    pub fn new(addr: PhysAddr, flags: u64) -> Self {
        PageTableEntry(addr.0 as u64 | flags)
    }
}

설명

이것이 하는 일: 페이지 테이블의 각 항목을 표현하며, 물리 프레임 주소와 접근 권한 정보를 비트 필드로 압축하여 저장합니다. 첫 번째로, PageTableEntry는 64비트 정수를 감싸고 있으며, 각 비트가 특정 의미를 가집니다.

비트 0은 present 플래그(페이지가 메모리에 있는지), 비트 1은 writable 플래그(쓰기 가능한지), 비트 2는 user 플래그(사용자 모드 접근 가능한지)를 나타냅니다. 이렇게 플래그를 비트로 관리하면 공간 효율이 극대화됩니다.

그 다음으로, addr() 메서드는 비트 12부터 51까지(40비트)를 추출하여 물리 프레임의 시작 주소를 얻습니다. 하위 12비트는 플래그와 예약 영역이고, 상위 12비트는 sign extension을 위해 사용됩니다.

0x000F_FFFF_FFFF_F000 마스크는 정확히 이 범위를 추출합니다. 세 번째로, is_present(), is_writable(), is_user() 메서드들은 각 플래그 비트를 체크합니다.

하드웨어 MMU가 페이지 테이블을 순회할 때 이 플래그들을 검사하여 접근 권한을 결정합니다. present가 0이면 페이지 폴트가 발생합니다.

마지막으로, new() 메서드는 물리 주소와 플래그를 OR 연산으로 결합하여 새 엔트리를 생성합니다. 주소는 항상 페이지 정렬되어 있으므로(하위 12비트가 0) 플래그와 충돌하지 않습니다.

여러분이 이 코드를 사용하면 페이지 테이블 조작이 타입 안전해지고, 비트 연산 실수를 방지할 수 있습니다. 또한 하드웨어 스펙과 정확히 일치하는 메모리 레이아웃을 보장하여 MMU와의 호환성을 확보합니다.

플래그 체크 메서드들은 가독성을 높여 권한 관리 로직을 명확하게 만듭니다.

실전 팁

💡 페이지 테이블 엔트리를 수정할 때는 반드시 TLB flush를 수행해야 합니다. 그렇지 않으면 CPU가 이전 매핑을 캐시하여 예상치 못한 동작이 발생합니다.

💡 NX(No Execute) 비트(비트 63)를 설정하면 코드 실행을 방지할 수 있어 보안이 강화됩니다. 스택이나 힙 영역에는 반드시 NX를 설정하세요.

💡 Copy-on-Write를 구현하려면 writable 플래그를 0으로 설정하고, 페이지 폴트 핸들러에서 실제 쓰기 시 복사를 수행하세요.

💡 디버깅 시 Accessed(비트 5)와 Dirty(비트 6) 플래그를 모니터링하면 어떤 페이지가 사용되고 수정되었는지 추적할 수 있습니다.

💡 페이지 테이블 자체도 페이징되므로, 재귀 매핑(Recursive Mapping) 기법을 사용하면 페이지 테이블 수정이 간편해집니다.


3. 페이지_테이블_순회 - 가상 주소에서 물리 주소로

시작하며

여러분이 가상 주소 0x1234_5678_9000을 물리 주소로 변환해야 한다면, 어떤 과정을 거쳐야 할까요? 단순히 테이블 하나만 찾으면 되는 것이 아니라, 4단계 테이블을 차례로 순회해야 합니다.

이런 과정은 모든 메모리 접근마다 발생하며, OS 커널이 페이지 테이블을 관리할 때 반드시 이해해야 하는 핵심 로직입니다. 순회 과정 중 하나라도 실패하면 페이지 폴트가 발생합니다.

바로 이럴 때 필요한 것이 페이지 테이블 워커(Page Table Walker)입니다. 각 레벨의 인덱스를 추출하고, 순차적으로 테이블을 따라가 최종 물리 주소를 찾아냅니다.

개요

간단히 말해서, 페이지 테이블 워커는 가상 주소의 각 레벨 인덱스를 추출하여 4단계 페이지 테이블을 순회하고 최종 물리 프레임을 찾는 알고리즘입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 커널이 직접 페이지 테이블을 조작하거나 디버깅할 때 주소 변환 과정을 명시적으로 수행해야 하기 때문입니다.

예를 들어, 새 프로세스의 주소 공간을 설정하거나 메모리 매핑을 변경할 때 워커가 필수적입니다. 기존에는 하드웨어 MMU만 페이지 테이블을 읽었지만, 이제는 OS도 동일한 로직을 구현하여 페이지 폴트 처리, demand paging, 메모리 보호 검증 등을 수행해야 합니다.

페이지 테이블 워커의 핵심 특징은 첫째, 가상 주소를 9비트씩 4개 + 12비트 오프셋으로 분할, 둘째, 각 레벨에서 다음 테이블의 물리 주소를 조회, 셋째, present 플래그 체크로 조기 종료입니다. 이러한 특징들이 하드웨어와 소프트웨어의 일관된 주소 변환을 보장합니다.

코드 예제

impl VirtAddr {
    // 각 레벨의 페이지 테이블 인덱스 추출 (9비트씩)
    pub fn p4_index(&self) -> usize {
        ((self.0 >> 39) & 0x1FF) as usize
    }
    pub fn p3_index(&self) -> usize {
        ((self.0 >> 30) & 0x1FF) as usize
    }
    pub fn p2_index(&self) -> usize {
        ((self.0 >> 21) & 0x1FF) as usize
    }
    pub fn p1_index(&self) -> usize {
        ((self.0 >> 12) & 0x1FF) as usize
    }
}

// 페이지 테이블 순회 함수
pub fn translate(addr: VirtAddr, p4_table: &PageTable) -> Option<PhysAddr> {
    let p3 = p4_table[addr.p4_index()].next_table()?;
    let p2 = p3[addr.p3_index()].next_table()?;
    let p1 = p2[addr.p2_index()].next_table()?;
    let frame = p1[addr.p1_index()].addr();
    Some(PhysAddr(frame.0 + addr.page_offset() as usize))
}

설명

이것이 하는 일: 가상 주소의 각 비트 범위를 추출하여 4단계 페이지 테이블을 순회하고, 최종 물리 주소를 계산합니다. 첫 번째로, p4_index()부터 p1_index()까지의 메서드들이 가상 주소의 특정 비트 범위를 추출합니다.

예를 들어 p4_index()는 비트 39-47(9비트)을 추출하여 PML4 테이블의 인덱스를 얻습니다. 0x1FF 마스크(이진수 111111111)는 정확히 9비트만 남깁니다.

x86-64는 48비트 가상 주소를 사용하므로 9*4 + 12 = 48이 됩니다. 그 다음으로, translate() 함수가 실제 순회를 수행합니다.

p4_table에서 시작하여 p4_index()로 첫 번째 엔트리를 찾고, 그 엔트리가 가리키는 다음 레벨 테이블(p3)을 가져옵니다. next_table() 메서드는 present 플래그를 체크하고, 0이면 None을 반환하여 순회를 중단합니다.

이것이 ?연산자로 전파됩니다. 세 번째로, 동일한 과정이 p3, p2, p1 레벨에서 반복됩니다.

각 레벨에서 해당하는 인덱스 메서드를 사용하여 올바른 엔트리를 선택합니다. 4단계를 모두 성공적으로 통과하면, p1 테이블의 엔트리가 최종 물리 프레임의 시작 주소를 담고 있습니다.

마지막으로, 물리 프레임 주소에 페이지 오프셋(하위 12비트)을 더하여 최종 물리 주소를 계산합니다. 프레임 주소는 4KB 정렬되어 있으므로 오프셋을 더해야 정확한 바이트 위치를 얻습니다.

여러분이 이 코드를 사용하면 커널에서 명시적으로 주소 변환을 수행할 수 있고, 페이지 폴트 핸들러에서 어느 단계에서 실패했는지 파악할 수 있습니다. 또한 메모리 덤프나 디버깅 도구를 구현할 때 가상-물리 매핑을 추적하는 데 필수적입니다.

?연산자를 사용하여 에러 처리가 깔끔하고, 코드 가독성이 하드웨어 동작과 일치합니다.

실전 팁

💡 대형 페이지(2MB, 1GB)를 사용하면 TLB 미스를 줄일 수 있습니다. p2나 p3 레벨에서 huge page 플래그(비트 7)를 설정하면 순회가 조기 종료됩니다.

💡 재귀 매핑을 사용하면 페이지 테이블 자체를 가상 주소로 접근할 수 있어 순회 코드가 단순해집니다. 마지막 PML4 엔트리를 자기 자신을 가리키게 설정하세요.

💡 순회 중 각 레벨의 플래그를 누적하여 최종 권한을 계산해야 합니다. 상위 레벨이 read-only면 하위 레벨이 writable이어도 쓰기가 불가능합니다.

💡 성능 최적화를 위해 자주 사용하는 매핑은 커널 영역에 identity mapping을 설정하여 순회 없이 직접 접근하세요.

💡 디버깅 시 각 레벨의 엔트리 값을 로그로 출력하면 페이지 테이블 설정 오류를 빠르게 찾을 수 있습니다.


4. 페이지_할당자_구현 - 물리 프레임 관리

시작하며

여러분이 새 페이지를 할당해야 할 때, 어느 물리 프레임이 사용 가능한지 어떻게 알 수 있을까요? 모든 물리 메모리를 순회하며 빈 공간을 찾는다면 성능이 끔찍하게 느려질 것입니다.

이런 문제는 메모리 할당과 해제가 빈번하게 발생하는 모든 시스템에서 나타납니다. 특히 멀티코어 환경에서는 여러 CPU가 동시에 메모리를 요청하므로 효율적이고 스레드 안전한 할당자가 필수적입니다.

바로 이럴 때 필요한 것이 프레임 할당자(Frame Allocator)입니다. 비트맵이나 연결 리스트로 사용 가능한 프레임을 추적하고, 빠르게 할당/해제를 수행합니다.

개요

간단히 말해서, 프레임 할당자는 물리 메모리를 4KB 프레임 단위로 관리하며, 사용 가능한 프레임을 추적하고 요청 시 할당하는 메모리 관리자입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 페이지 테이블이나 사용자 프로그램에 메모리를 제공하려면 빈 물리 프레임을 찾아야 하기 때문입니다.

예를 들어, 프로세스가 fork()를 호출하면 수천 개의 페이지를 복사해야 하는데, 각 페이지마다 새 프레임을 할당받아야 합니다. 기존의 단순한 bump allocator(다음 주소를 증가시키는 방식)는 해제를 지원하지 않아 메모리 재사용이 불가능했습니다.

이제는 비트맵이나 free list를 사용하여 해제된 프레임을 재사용하고, 메모리 효율을 극대화할 수 있습니다. 프레임 할당자의 핵심 특징은 첫째, O(1) 또는 O(log n) 할당 속도, 둘째, 멀티코어 환경에서 동기화 지원, 셋째, NUMA(Non-Uniform Memory Access) 인식입니다.

이러한 특징들이 고성능 시스템에서 메모리 병목을 방지하고 확장성을 보장합니다.

코드 예제

use alloc::collections::BTreeSet;

pub struct FrameAllocator {
    // 사용 가능한 프레임의 시작 주소들을 저장
    free_frames: BTreeSet<PhysAddr>,
}

impl FrameAllocator {
    pub fn new() -> Self {
        FrameAllocator { free_frames: BTreeSet::new() }
    }

    // 메모리 영역을 할당자에 등록
    pub fn add_region(&mut self, start: PhysAddr, end: PhysAddr) {
        let mut addr = start.align_up(PAGE_SIZE);
        while addr < end {
            self.free_frames.insert(addr);
            addr = PhysAddr(addr.0 + PAGE_SIZE);
        }
    }

    // 프레임 할당
    pub fn allocate(&mut self) -> Option<PhysAddr> {
        self.free_frames.pop_first()
    }

    // 프레임 해제
    pub fn deallocate(&mut self, addr: PhysAddr) {
        self.free_frames.insert(addr);
    }
}

설명

이것이 하는 일: 물리 메모리 영역을 프레임 단위로 분할하고, 사용 가능한 프레임 집합을 유지하여 할당 요청에 응답합니다. 첫 번째로, FrameAllocator 구조체는 BTreeSet을 사용하여 빈 프레임들을 저장합니다.

BTreeSet은 정렬된 상태를 유지하며 삽입/삭제/검색이 O(log n)으로 빠르고, 가장 낮은 주소를 O(log n)에 찾을 수 있어 할당자에 적합합니다. 해시셋보다 메모리 레이아웃이 예측 가능하고 캐시 친화적입니다.

그 다음으로, add_region() 메서드가 부팅 시 BIOS/UEFI로부터 받은 메모리 맵 정보를 기반으로 사용 가능한 영역을 등록합니다. align_up()으로 시작 주소를 페이지 경계에 맞추고, PAGE_SIZE씩 증가하며 모든 프레임을 free_frames에 추가합니다.

예를 들어, 0x100000-0x8000000 영역이면 약 32,000개의 프레임이 등록됩니다. 세 번째로, allocate() 메서드는 pop_first()로 가장 낮은 주소의 프레임을 제거하고 반환합니다.

낮은 주소부터 할당하면 메모리 지역성이 좋아져 캐시 효율이 향상됩니다. free_frames가 비어있으면 None을 반환하여 OOM(Out Of Memory) 상황을 알립니다.

마지막으로, deallocate() 메서드는 해제된 프레임을 다시 free_frames에 추가합니다. BTreeSet의 자동 정렬 덕분에 다음 할당 시 이 프레임이 적절한 위치에 있어 재사용됩니다.

여러분이 이 코드를 사용하면 메모리 누수 없이 프레임을 재사용할 수 있고, 할당 실패를 Option으로 안전하게 처리할 수 있습니다. BTreeSet의 자동 중복 제거 덕분에 같은 프레임을 두 번 해제해도 문제가 없으며, 메모리 관리 버그가 줄어듭니다.

또한 정렬된 상태를 유지하여 메모리 단편화를 최소화하고, 디버깅 시 할당 패턴을 쉽게 파악할 수 있습니다.

실전 팁

💡 실제 프로덕션에서는 비트맵 할당자가 더 빠릅니다. 각 프레임을 1비트로 표현하여 메모리 사용량이 1/64로 줄고, 비트 연산으로 O(1) 할당이 가능합니다.

💡 멀티코어 환경에서는 per-CPU 할당자를 구현하세요. 각 CPU가 자신의 프레임 풀을 가지면 락 경합이 사라져 성능이 극적으로 향상됩니다.

💡 메모리 영역을 크기별로 분류(ZONE_DMA, ZONE_NORMAL, ZONE_HIGHMEM)하면 DMA 제약이 있는 장치에 적합한 메모리를 할당할 수 있습니다.

💡 buddy allocator를 사용하면 연속된 여러 프레임을 효율적으로 할당할 수 있어 대형 페이지나 DMA 버퍼에 유용합니다.

💡 할당/해제 통계를 수집하면 메모리 사용 패턴을 분석하고, 메모리 누수를 조기에 발견할 수 있습니다.


5. TLB와_캐싱_최적화 - 주소 변환 성능 향상

시작하며

여러분이 메모리를 읽을 때마다 4단계 페이지 테이블을 순회한다면, 매번 4번의 추가 메모리 접근이 필요합니다. 이는 메모리 접근 속도를 5배나 느리게 만들어 성능을 파괴합니다.

이런 문제는 모든 가상 메모리 시스템에서 발생하며, 주소 변환 오버헤드가 전체 시스템 성능의 병목이 됩니다. 특히 메모리 집약적인 애플리케이션에서는 수십 퍼센트의 성능 저하가 발생할 수 있습니다.

바로 이럴 때 필요한 것이 TLB(Translation Lookaside Buffer)입니다. 최근 주소 변환 결과를 캐시하여 대부분의 경우 페이지 테이블 순회를 건너뛰고 즉시 물리 주소를 얻습니다.

개요

간단히 말해서, TLB는 가상 주소와 물리 주소의 매핑을 캐시하는 CPU 내부의 고속 하드웨어 캐시입니다. 보통 64-1024개의 엔트리를 가집니다.

왜 이 개념이 필요한지 실무 관점에서 설명하자면, 프로그램은 지역성(locality)을 가지므로 같은 페이지를 반복적으로 접근하기 때문입니다. 예를 들어, 루프 안의 배열 접근은 동일한 페이지를 수천 번 히트하므로, TLB가 있으면 첫 번째 접근만 느리고 나머지는 매우 빠릅니다.

기존에는 모든 메모리 접근이 페이지 테이블 순회를 유발했지만, TLB 도입 후 히트율이 95-99%에 달해 실질적인 오버헤드가 거의 사라졌습니다. 현대 CPU는 L1 TLB(데이터/명령어 분리)와 L2 TLB(통합)의 2단계 구조를 가집니다.

TLB의 핵심 특징은 첫째, 하드웨어 관리(CPU가 자동으로 채움), 둘째, ASID(Address Space ID)로 컨텍스트 스위치 최적화, 셋째, huge page 지원으로 커버리지 확대입니다. 이러한 특징들이 가상 메모리의 성능 오버헤드를 실용적인 수준으로 낮춰 현대 OS를 가능하게 했습니다.

코드 예제

use core::arch::asm;

// TLB flush 함수들

// 특정 페이지의 TLB 엔트리를 무효화
pub fn flush_page(addr: VirtAddr) {
    unsafe {
        asm!("invlpg [{}]", in(reg) addr.0, options(nostack, preserves_flags));
    }
}

// 전체 TLB flush (CR3 재로드)
pub fn flush_all() {
    unsafe {
        asm!(
            "mov {tmp}, cr3",
            "mov cr3, {tmp}",
            tmp = out(reg) _,
            options(nostack, preserves_flags)
        );
    }
}

// PCID를 사용한 선택적 flush (현대적인 방법)
pub fn flush_all_except_globals() {
    unsafe {
        asm!("mov rax, cr4", "or rax, 0x80", "mov cr4, rax", out("rax") _);
    }
}

설명

이것이 하는 일: 페이지 테이블 엔트리를 수정한 후 TLB의 오래된 캐시 항목을 제거하여 CPU가 최신 매핑을 사용하도록 합니다. 첫 번째로, flush_page() 함수는 invlpg 명령어를 사용하여 특정 가상 주소의 TLB 엔트리만 무효화합니다.

이는 단일 페이지의 권한을 변경하거나 매핑을 제거할 때 사용됩니다. 예를 들어, 페이지를 read-only로 변경했다면 해당 페이지만 flush하면 되므로 다른 TLB 엔트리는 그대로 유지되어 성능 영향이 최소화됩니다.

그 다음으로, flush_all() 함수는 CR3 레지스터(페이지 테이블 루트 주소)를 재로드하여 전체 TLB를 비웁니다. CPU는 CR3가 변경되면 모든 TLB 엔트리를 자동으로 무효화합니다.

이는 컨텍스트 스위치(프로세스 전환) 시 사용되며, 이전 프로세스의 주소 매핑이 남아있지 않도록 보장합니다. 세 번째로, flush_all_except_globals() 함수는 CR4.PGE(Page Global Enable) 비트를 토글하여 글로벌이 아닌 엔트리만 flush합니다.

커널 영역은 모든 프로세스가 공유하므로 global 플래그를 설정하면, 컨텍스트 스위치 시에도 TLB에 남아 성능이 향상됩니다. 마지막으로, 이러한 flush 함수들은 반드시 적절한 타이밍에 호출되어야 합니다.

페이지 테이블을 수정한 후 flush하지 않으면 CPU가 이전 매핑을 계속 사용하여 잘못된 메모리에 접근하거나 권한 검사를 우회할 수 있습니다. 반대로 너무 자주 flush하면 TLB 미스가 증가하여 성능이 저하됩니다.

여러분이 이 코드를 사용하면 메모리 관리의 정확성을 보장하면서도 성능을 최적화할 수 있습니다. 단일 페이지 flush를 사용하면 불필요한 TLB 미스를 방지하고, 글로벌 플래그를 활용하면 커널 코드의 TLB 히트율을 극대화할 수 있습니다.

인라인 어셈블리를 통해 하드웨어를 직접 제어하므로 컴파일러 최적화에도 안전합니다.

실전 팁

💡 대량의 페이지를 변경할 때는 각 페이지마다 flush하는 것보다 전체 flush가 더 빠를 수 있습니다. 임계값은 보통 32-64페이지입니다.

💡 PCID(Process-Context Identifier)를 사용하면 컨텍스트 스위치 시 TLB를 flush하지 않아도 됩니다. 각 프로세스에 고유 PCID를 할당하면 TLB가 여러 프로세스의 엔트리를 동시에 보유합니다.

💡 IPI(Inter-Processor Interrupt)를 사용하여 멀티코어 시스템의 모든 CPU에서 동시에 TLB flush를 수행해야 일관성이 보장됩니다.

💡 디버깅 시 의도적으로 TLB를 비활성화하거나 매 접근마다 flush하면 페이징 관련 버그를 더 쉽게 재현할 수 있습니다.

💡 huge page(2MB, 1GB)를 사용하면 동일한 TLB 엔트리로 더 넓은 메모리 영역을 커버하여 TLB 미스율이 급격히 감소합니다.


6. 페이지_폴트_처리 - 예외 상황 대응

시작하며

여러분이 할당되지 않은 메모리 주소에 접근하거나, read-only 페이지에 쓰기를 시도하면 어떻게 될까요? 프로그램이 즉시 크래시하는 것이 아니라, OS가 개입할 기회를 얻습니다.

이런 상황은 버그뿐만 아니라 정상적인 메모리 관리 전략에서도 발생합니다. demand paging, copy-on-write, memory-mapped file 등 많은 최적화 기법이 페이지 폴트를 활용합니다.

바로 이럴 때 필요한 것이 페이지 폴트 핸들러(Page Fault Handler)입니다. 폴트 원인을 분석하고, 복구 가능하면 메모리를 할당하거나 권한을 변경하여 프로그램을 계속 실행시킵니다.

개요

간단히 말해서, 페이지 폴트 핸들러는 메모리 접근 예외를 받아 원인을 파악하고, 적절한 조치(할당, 스왑 인, 복사)를 취하거나 프로세스를 종료하는 커널 함수입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 모든 메모리를 미리 할당하는 것은 낭비이기 때문입니다.

예를 들어, 프로세스가 1GB 버퍼를 선언해도 실제로 몇 MB만 사용할 수 있으므로, 페이지 폴트 시 필요한 부분만 할당하는 demand paging이 효율적입니다. 기존에는 모든 메모리를 프로세스 생성 시 할당했지만, 이제는 lazy allocation으로 폴트가 발생할 때만 할당하여 메모리 사용량을 수십 배 줄이고 프로세스 시작 속도를 대폭 향상시킬 수 있습니다.

페이지 폴트 핸들러의 핵심 특징은 첫째, CR2 레지스터에서 폴트 주소 읽기, 둘째, 에러 코드로 원인 파악(present, write, user), 셋째, 복구 가능 여부 판단입니다. 이러한 특징들이 메모리 오버커밋, 가상 메모리 크기 확장, 효율적인 프로세스 포킹 등 현대 OS의 핵심 기능을 가능하게 합니다.

코드 예제

use x86_64::structures::idt::PageFaultErrorCode;

// 페이지 폴트 핸들러
pub extern "x86-interrupt" fn page_fault_handler(
    stack_frame: InterruptStackFrame,
    error_code: PageFaultErrorCode,
) {
    use x86_64::registers::control::Cr2;

    // CR2에서 폴트가 발생한 가상 주소 읽기
    let fault_addr = Cr2::read();

    // 폴트 원인 분석
    let is_present = !error_code.contains(PageFaultErrorCode::PROTECTION_VIOLATION);
    let is_write = error_code.contains(PageFaultErrorCode::CAUSED_BY_WRITE);
    let is_user = error_code.contains(PageFaultErrorCode::USER_MODE);

    // 복구 시도
    if is_present && is_write {
        // Copy-on-Write 처리
        if try_copy_on_write(fault_addr) {
            return; // 복구 성공, 프로그램 계속
        }
    } else if !is_present {
        // Demand paging: 새 페이지 할당
        if allocate_page(fault_addr) {
            return; // 복구 성공
        }
    }

    // 복구 불가능: 프로세스 종료
    panic!("Page fault at {:?}: {:?}", fault_addr, error_code);
}

설명

이것이 하는 일: CPU가 메모리 접근 예외를 발생시키면, 폴트 주소와 원인을 분석하여 적절한 메모리 관리 작업을 수행합니다. 첫 번째로, page_fault_handler 함수는 "x86-interrupt" calling convention을 사용하여 CPU가 직접 호출할 수 있도록 합니다.

CPU는 폴트 발생 시 stack_frame(레지스터 상태)과 error_code(폴트 원인)를 자동으로 스택에 푸시합니다. CR2 레지스터를 읽으면 정확히 어느 가상 주소에 접근하려다 폴트가 발생했는지 알 수 있습니다.

그 다음으로, error_code를 분석하여 폴트 유형을 파악합니다. PROTECTION_VIOLATION이 없으면 페이지가 아예 존재하지 않는 것(is_present = false)이고, 있으면 권한 문제입니다.

CAUSED_BY_WRITE가 있으면 쓰기 시도, 없으면 읽기 시도입니다. USER_MODE가 있으면 사용자 코드에서 발생한 것입니다.

세 번째로, 복구 로직이 실행됩니다. is_present이고 is_write라면 read-only 페이지에 쓰기를 시도한 것이므로, copy-on-write를 확인합니다.

페이지가 COW로 표시되어 있다면 새 프레임에 복사하고 writable로 변경한 후 return하여 명령어를 재실행합니다. !is_present라면 페이지가 없으므로 demand paging으로 새 프레임을 할당하고 매핑합니다.

마지막으로, 복구가 불가능하면(예: 커널 영역에 사용자 모드가 접근, 또는 진짜 버그) panic!으로 프로세스를 종료합니다. 실제 OS에서는 SIGSEGV 신호를 보내거나 프로세스를 종료합니다.

여러분이 이 코드를 사용하면 메모리 오버커밋을 통해 물리 메모리보다 큰 가상 메모리를 제공할 수 있고, fork() 시 copy-on-write로 실제 쓰기가 발생할 때만 복사하여 성능을 극적으로 향상시킬 수 있습니다. 또한 memory-mapped file을 구현하여 파일 I/O를 메모리 접근으로 단순화할 수 있습니다.

에러 코드 분석이 정확하므로 버그와 정상적인 폴트를 명확히 구분할 수 있습니다.

실전 팁

💡 디버깅을 위해 폴트 발생 시 stack_frame을 로그로 남기세요. instruction pointer(RIP)를 보면 어느 코드에서 폴트가 발생했는지 정확히 알 수 있습니다.

💡 guard page를 활용하면 스택 오버플로우를 감지할 수 있습니다. 스택 끝에 unmapped 페이지를 두면 스택이 너무 깊어질 때 페이지 폴트가 발생합니다.

💡 swap 기능을 구현하려면 !is_present일 때 페이지 테이블 엔트리의 비어있는 비트에 디스크 위치를 저장하고, 폴트 시 디스크에서 읽어옵니다.

💡 성능 프로파일링을 위해 페이지 폴트 횟수를 추적하세요. 너무 많으면 메모리 할당 전략을 재검토해야 합니다.

💡 멀티스레드 환경에서는 같은 페이지에 동시 폴트가 발생할 수 있으므로, 페이지 할당 시 lock을 사용하여 중복 할당을 방지하세요.


7. Identity_Mapping과_Higher_Half - 커널 주소 공간 설계

시작하며

여러분이 커널을 부팅할 때, 페이징을 활성화하는 순간 현재 실행 중인 코드의 주소가 달라지면 어떻게 될까요? CPU는 다음 명령어를 찾지 못하고 triple fault로 재부팅됩니다.

이런 문제는 모든 OS가 부팅 과정에서 겪으며, 물리 주소와 가상 주소의 전환을 안전하게 처리해야 하는 핵심 과제입니다. 특히 커널은 사용자 프로세스와 주소 공간을 공유하면서도 격리되어야 합니다.

바로 이럴 때 필요한 것이 identity mapping과 higher half kernel입니다. 부팅 초기에는 물리=가상 매핑으로 안전하게 전환하고, 이후 커널을 상위 주소(예: 0xFFFF_8000_0000_0000)에 매핑하여 사용자 공간과 분리합니다.

개요

간단히 말해서, identity mapping은 물리 주소 X를 가상 주소 X로 매핑하는 것이고, higher half kernel은 커널을 가상 주소 공간의 상위 절반에 배치하여 사용자 프로그램과 분리하는 설계 패턴입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 커널이 낮은 주소(0x0-0x7FFF...)를 차지하면 사용자 프로그램이 사용할 공간이 줄어들고, 커널을 잘못 접근할 위험이 증가하기 때문입니다.

예를 들어, 모든 프로세스가 0x0000_0000_0040_0000에 로드된다면 커널과 충돌합니다. 기존의 단순한 물리 주소 사용 방식에서는 커널과 사용자 공간의 경계가 불명확했습니다.

이제는 higher half 설계로 커널은 0xFFFF..., 사용자는 0x0000...를 사용하여 명확히 분리되고, 주소만 보고도 커널/사용자 영역을 구분할 수 있습니다. 이 설계 패턴의 핵심 특징은 첫째, 부팅 시 identity + higher half 동시 매핑, 둘째, 페이징 활성화 후 identity 제거, 셋째, 커널 주소는 모든 프로세스 공유입니다.

이러한 특징들이 안전한 부팅, 명확한 주소 공간 분할, 효율적인 커널 접근을 보장합니다.

코드 예제

// 부팅 시 초기 페이지 테이블 설정
pub fn setup_initial_paging() {
    let p4_table = create_page_table();

    // 1. Identity mapping: 물리 0-2MB를 가상 0-2MB로
    // 페이징 활성화 직후에도 현재 코드가 실행 가능하도록
    let identity_start = PhysAddr(0);
    let identity_end = PhysAddr(2 * 1024 * 1024); // 2MB
    map_range(p4_table, identity_start, identity_start, identity_end);

    // 2. Higher half mapping: 커널을 상위 주소로
    // 커널 ELF는 0xFFFF_8000_0010_0000에 링크됨
    let kernel_phys = PhysAddr(0x10_0000); // 커널의 실제 물리 주소
    let kernel_virt = VirtAddr(0xFFFF_8000_0010_0000); // 커널의 가상 주소
    let kernel_end = kernel_phys + kernel_size();
    map_range(p4_table, kernel_virt, kernel_phys, kernel_end);

    // CR3에 로드하여 페이징 활성화
    load_page_table(p4_table);

    // 3. Identity mapping 제거 (이제 higher half만 사용)
    unmap_range(p4_table, identity_start, identity_end);
    flush_tlb();
}

설명

이것이 하는 일: 부팅 과정에서 물리 주소와 가상 주소 전환을 안전하게 처리하고, 커널을 가상 주소 공간의 상위에 영구적으로 매핑합니다. 첫 번째로, identity mapping 부분에서 물리 주소 0-2MB를 가상 주소 0-2MB로 1:1 매핑합니다.

이는 현재 실행 중인 코드(부트로더와 초기 커널)가 페이징 활성화 직후에도 같은 주소로 계속 실행될 수 있도록 보장합니다. 2MB면 커널의 초기 코드와 데이터를 충분히 커버합니다.

만약 이 매핑이 없다면, CR3를 로드하는 순간 instruction pointer가 무효한 주소를 가리켜 크래시합니다. 그 다음으로, higher half mapping 부분에서 커널의 실제 물리 위치(1MB, 0x10_0000)를 가상 주소 0xFFFF_8000_0010_0000으로 매핑합니다.

커널 ELF 파일은 링커 스크립트에서 이 가상 주소를 기준으로 링크되므로, 모든 함수 포인터와 전역 변수 주소가 이 범위에 있습니다. 물리 주소는 낮지만 가상 주소는 높아서, 사용자 프로그램(0x0000...)과 완전히 분리됩니다.

세 번째로, load_page_table()로 CR3에 페이지 테이블을 로드하고 페이징을 활성화합니다. 이 순간부터 모든 메모리 접근이 가상 주소로 변환됩니다.

그러나 아직 identity mapping이 있으므로 현재 실행 중인 코드는 계속 작동합니다. 마지막으로, higher half 주소로 점프한 후 identity mapping을 제거합니다.

이제 커널은 완전히 상위 주소에서만 실행되며, 하위 주소 공간 전체를 사용자 프로그램에게 양보합니다. TLB flush로 이전 매핑을 제거합니다.

여러분이 이 코드를 사용하면 부팅 과정의 복잡한 주소 전환을 안전하게 처리할 수 있고, 커널과 사용자 공간의 명확한 분리로 보안이 강화됩니다. 모든 프로세스가 동일한 커널 매핑을 공유하므로 시스템 콜 시 페이지 테이블을 교체할 필요가 없어 성능이 향상됩니다.

또한 주소만 보고도 커널/사용자 영역을 즉시 판단할 수 있어 디버깅이 용이합니다.

실전 팁

💡 커널 스택도 higher half에 배치하세요. 스택 오버플로우가 사용자 메모리를 덮어쓰는 것을 방지할 수 있습니다.

💡 KASLR(Kernel Address Space Layout Randomization)을 구현하려면 부팅마다 커널의 가상 주소를 랜덤화하여 보안을 강화하세요.

💡 멀티부팅 환경에서는 identity mapping 범위를 확장하여 부트로더가 제공한 정보(메모리 맵, 프레임버퍼)에 접근할 수 있도록 하세요.

💡 물리 메모리를 직접 접근해야 할 때는 별도의 direct mapping 영역(예: 0xFFFF_8800_0000_0000)을 두면 프레임 할당자 구현이 단순해집니다.

💡 사용자 영역 최상위에 vsyscall/vDSO 페이지를 매핑하면 간단한 시스템 콜(gettimeofday)을 컨텍스트 스위치 없이 처리할 수 있습니다.


8. Recursive_Mapping - 페이지 테이블 자기 참조

시작하며

여러분이 페이지 테이블 자체를 수정하려면, 페이지 테이블의 물리 주소를 알아야 합니다. 그런데 커널은 가상 주소로 동작하므로, 페이지 테이블에 접근하려면 또 다른 매핑이 필요합니다.

이는 닭과 달걀 문제입니다. 이런 문제는 페이지 테이블을 동적으로 조작해야 하는 모든 상황에서 발생합니다.

새 매핑 추가, 권한 변경, 메모리 해제 등 기본적인 메모리 관리 작업이 복잡해집니다. 바로 이럴 때 필요한 것이 재귀 매핑(Recursive Mapping)입니다.

페이지 테이블의 마지막 엔트리가 자기 자신을 가리키게 하여, 페이지 테이블을 마치 일반 메모리처럼 가상 주소로 접근할 수 있게 만듭니다.

개요

간단히 말해서, 재귀 매핑은 PML4 테이블의 마지막 엔트리(511번)를 자기 자신의 물리 주소로 설정하여, 페이지 테이블 계층 전체를 가상 주소 공간에 매핑하는 기법입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 페이지 테이블 수정이 매우 빈번하게 발생하기 때문입니다.

예를 들어, mmap() 시스템 콜이나 메모리 할당마다 페이지 테이블에 새 엔트리를 추가해야 하는데, 재귀 매핑이 없다면 매번 임시 매핑을 만들어야 해 성능이 저하됩니다. 기존에는 물리 메모리 전체를 direct mapping하거나, 임시 매핑을 사용했습니다.

이제는 재귀 매핑으로 단 하나의 엔트리만으로 모든 페이지 테이블에 일관된 가상 주소로 접근할 수 있어 코드가 단순하고 빠릅니다. 재귀 매핑의 핵심 특징은 첫째, 고정된 가상 주소로 페이지 테이블 접근(예: 0xFFFF_FF80_0000_0000), 둘째, 추가 메모리 오버헤드 없음(엔트리 하나만 사용), 셋째, 계층 구조 유지입니다.

이러한 특징들이 효율적이고 우아한 페이지 테이블 관리를 가능하게 합니다.

코드 예제

// 재귀 매핑 설정
const RECURSIVE_INDEX: usize = 511; // PML4의 마지막 엔트리

pub fn setup_recursive_mapping(p4_table: &mut PageTable) {
    let p4_phys = PhysAddr::from_ptr(p4_table);
    p4_table[RECURSIVE_INDEX] = PageTableEntry::new(
        p4_phys,
        PageTableFlags::PRESENT | PageTableFlags::WRITABLE
    );
}

// 재귀 매핑을 통해 페이지 테이블 접근
pub fn get_page_table(level: u8, indices: &[usize]) -> &'static mut PageTable {
    // 재귀 인덱스를 적절히 반복하여 주소 계산
    let mut addr = 0xFFFF_0000_0000_0000; // 재귀 영역 시작

    // 레벨에 따라 재귀 인덱스 추가
    for _ in 0..=(4 - level) {
        addr |= (RECURSIVE_INDEX as u64) << (12 + 9 * (4 - level as usize));
    }

    // 실제 인덱스 추가
    for (i, &idx) in indices.iter().enumerate() {
        addr |= (idx as u64) << (12 + 9 * i);
    }

    unsafe { &mut *(addr as *mut PageTable) }
}

설명

이것이 하는 일: PML4 테이블의 마지막 엔트리를 자기 자신으로 설정하여, 페이지 테이블 계층 전체가 예측 가능한 가상 주소에 매핑되도록 합니다. 첫 번째로, setup_recursive_mapping() 함수가 PML4 테이블의 511번 엔트리를 자기 자신의 물리 주소로 설정합니다.

이는 일종의 "루프"를 만드는 것으로, MMU가 이 엔트리를 따라가면 다시 PML4 테이블로 돌아옵니다. 예를 들어, 가상 주소 0xFFFF_FF80_0000_0000(모든 인덱스가 511)에 접근하면, 4단계 모두 PML4를 참조하므로 결국 PML4 자체가 데이터로 보입니다.

그 다음으로, get_page_table() 함수가 특정 레벨의 페이지 테이블을 가상 주소로 계산합니다. 핵심 아이디어는 재귀 인덱스(511)를 적절한 위치에 배치하는 것입니다.

예를 들어, P3 테이블에 접근하려면 P4와 P3 인덱스 위치에 511을 넣고, P2와 P1 위치에는 실제 인덱스를 넣습니다. 이렇게 하면 MMU가 순회 중 두 번은 재귀 엔트리를 따라가고, 나머지는 실제 테이블로 진행합니다.

세 번째로, 주소 계산 로직을 보면 재귀 영역은 0xFFFF_0000_0000_0000부터 시작합니다(상위 비트가 모두 1). 각 9비트 인덱스를 적절한 시프트 위치에 OR 연산으로 삽입합니다.

비트 12-20이 P1 인덱스, 21-29가 P2, 30-38이 P3, 39-47이 P4입니다. 마지막으로, 계산된 주소를 PageTable 포인터로 캐스팅하여 반환합니다.

이제 이 참조를 통해 페이지 테이블을 직접 읽고 쓸 수 있습니다. unsafe 블록이 필요한 이유는 원시 포인터를 참조로 변환하기 때문입니다.

여러분이 이 코드를 사용하면 페이지 테이블 수정이 매우 단순해집니다. 임시 매핑이나 물리 주소 계산 없이 일반 메모리처럼 접근할 수 있어 코드 가독성이 높아지고 버그가 줄어듭니다.

또한 고정된 가상 주소를 사용하므로 페이지 테이블 위치를 추적할 필요가 없으며, 컨텍스트 스위치 시에도 동일한 주소로 접근할 수 있습니다.

실전 팁

💡 재귀 매핑 영역의 주소 계산이 복잡하므로, 각 레벨별로 상수를 미리 정의하면 실수를 방지할 수 있습니다(P4_ADDR, P3_ADDR 등).

💡 디버깅 시 재귀 영역에 직접 접근하여 페이지 테이블 내용을 덤프하면 매핑 상태를 한눈에 파악할 수 있습니다.

💡 멀티코어 환경에서 페이지 테이블을 수정할 때는 락을 사용하세요. 재귀 매핑이 편리하지만 경쟁 조건은 여전히 발생할 수 있습니다.

💡 일부 아키텍처(ARM)는 재귀 매핑을 지원하지 않으므로, 대신 커널 영역에 페이지 테이블을 복사하는 방식을 사용해야 합니다.

💡 재귀 매핑과 PCID를 함께 사용할 때는 커널 페이지 테이블이 모든 프로세스에서 동일해야 하므로, PML4의 상위 절반은 공유하도록 설계하세요.


9. PCID와_ASID - 컨텍스트_스위치_최적화

시작하며

여러분이 프로세스를 전환할 때마다 TLB를 완전히 비운다면, 새 프로세스의 첫 수천 개 메모리 접근이 모두 TLB 미스로 느려집니다. 컨텍스트 스위치가 빈번한 시스템에서는 성능이 절반 이하로 떨어질 수 있습니다.

이런 문제는 멀티태스킹 환경에서 심각하며, 특히 마이크로서비스나 컨테이너처럼 프로세스 전환이 자주 발생하는 현대 워크로드에서 병목이 됩니다. TLB는 소중한 자원인데 매번 비우는 것은 낭비입니다.

바로 이럴 때 필요한 것이 PCID(Process-Context Identifier)와 ASID(Address Space Identifier)입니다. 각 프로세스에 고유 ID를 부여하여 TLB 엔트리를 태깅하면, 여러 프로세스의 매핑이 TLB에 공존할 수 있습니다.

개요

간단히 말해서, PCID(x86-64의 용어, ARM에서는 ASID)는 각 TLB 엔트리에 붙는 프로세스 식별 태그로, TLB flush 없이 컨텍스트 스위치가 가능하게 만드는 하드웨어 기능입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, TLB 미스는 수십 사이클의 오버헤드를 발생시키므로, 컨텍스트 스위치 직후 성능이 급격히 저하되기 때문입니다.

예를 들어, 서버가 1000개의 요청을 1000개 프로세스로 처리한다면, PCID 없이는 매번 TLB를 재구성하느라 엄청난 시간을 낭비합니다. 기존에는 CR3를 변경할 때마다 자동으로 TLB가 flush되었습니다.

이제는 PCID를 활성화하면 CR3의 하위 12비트가 프로세스 ID가 되어, 동일 ID면 flush하지 않고 다른 ID면 해당 ID의 엔트리만 구분됩니다. PCID의 핵심 특징은 첫째, 최대 4096개의 고유 ID 지원, 둘째, CR3 변경 시 자동 TLB flush 비활성화, 셋째, 선택적 flush 가능(invpcid 명령어)입니다.

이러한 특징들이 컨텍스트 스위치 성능을 20-40% 향상시켜 고빈도 태스크 전환에서 필수적입니다.

코드 예제

use x86_64::registers::control::Cr3;

// PCID 활성화 (CR4.PCIDE 비트 설정)
pub fn enable_pcid() {
    unsafe {
        let mut cr4 = x86_64::registers::control::Cr4::read();
        cr4.insert(x86_64::registers::control::Cr4Flags::PCIDE);
        x86_64::registers::control::Cr4::write(cr4);
    }
}

// PCID를 포함하여 페이지 테이블 로드
pub fn load_cr3_with_pcid(p4_phys: PhysAddr, pcid: u16) {
    assert!(pcid < 4096, "PCID must be < 4096");

    // CR3의 하위 12비트에 PCID 삽입
    let cr3_value = p4_phys.0 as u64 | (pcid as u64);

    unsafe {
        asm!(
            "mov cr3, {}",
            in(reg) cr3_value,
            options(nostack, preserves_flags)
        );
    }
}

// 특정 PCID의 TLB만 flush
pub fn flush_pcid(pcid: u16) {
    unsafe {
        // INVPCID 명령어 사용 (타입 1: single context)
        let descriptor = [pcid as u64, 0u64];
        asm!(
            "invpcid {}, [{}]",
            in(reg) 1u64, // type = 1
            in(reg) &descriptor,
            options(nostack)
        );
    }
}

설명

이것이 하는 일: 각 프로세스에 고유 PCID를 할당하고, CR3 변경 시 PCID를 함께 설정하여 TLB가 여러 프로세스의 매핑을 동시에 보유하도록 합니다. 첫 번째로, enable_pcid() 함수가 CR4.PCIDE 비트를 설정하여 PCID 기능을 활성화합니다.

이 비트가 설정되면 CR3의 하위 12비트가 PCID로 해석되고, CR3 변경 시 자동 TLB flush가 비활성화됩니다. 주의할 점은 PCIDE는 Long Mode(64비트)에서만 지원되며, 활성화하기 전에 CPU가 지원하는지 CPUID로 확인해야 합니다.

그 다음으로, load_cr3_with_pcid() 함수가 페이지 테이블 물리 주소와 PCID를 조합하여 CR3에 로드합니다. CR3의 상위 52비트는 4KB 정렬된 페이지 테이블 주소이고 하위 12비트가 PCID이므로, OR 연산으로 결합합니다.

예를 들어, 프로세스 A는 PCID 1, 프로세스 B는 PCID 2를 받으면, 두 프로세스 간 전환 시 TLB 엔트리가 유지됩니다. 세 번째로, PCID가 활성화된 상태에서는 각 TLB 엔트리가 <가상 주소, PCID> 쌍으로 태깅됩니다.

MMU가 주소 변환 시 현재 CR3의 PCID와 일치하는 엔트리만 사용합니다. 이렇게 하면 프로세스 A의 주소 0x1000과 프로세스 B의 주소 0x1000이 서로 다른 물리 주소로 매핑되어도 TLB에 공존할 수 있습니다.

마지막으로, flush_pcid() 함수가 특정 PCID의 TLB 엔트리만 선택적으로 제거합니다. INVPCID 명령어는 타입에 따라 다양한 flush 모드를 제공하며, 타입 1은 특정 PCID만 flush합니다.

프로세스가 종료되거나 주소 공간이 크게 변경될 때 유용합니다. 여러분이 이 코드를 사용하면 컨텍스트 스위치 직후 TLB 워밍업 시간이 사라져 평균 응답 시간이 크게 개선됩니다.

특히 짧은 시간 슬라이스로 여러 프로세스를 빠르게 전환하는 시스템에서 효과가 큽니다. 또한 커널 영역은 글로벌 페이지로 표시하여 모든 PCID에서 공유하므로, 시스템 콜 성능도 향상됩니다.

실전 팁

💡 PCID 0은 특별한 의미를 가지므로(일부 명령어에서 "모든 PCID"를 의미), 실제 프로세스에는 1부터 할당하세요.

💡 4096개 이상의 프로세스가 있다면 PCID를 재활용해야 합니다. LRU 방식으로 가장 오래 사용하지 않은 PCID를 선택하고 해당 TLB를 flush하세요.

💡 fork() 시 부모와 자식에게 다른 PCID를 할당하면 copy-on-write 성능이 향상됩니다. 같은 PCID를 쓰면 페이지 테이블 변경 시 TLB 일관성 문제가 발생할 수 있습니다.

💡 ARM 아키텍처의 ASID는 8비트(256개) 또는 16비트(65536개)를 지원하므로, 아키텍처별로 ID 할당 전략을 다르게 가져가세요.

💡 디버깅 시 PCID를 비활성화하면 TLB 관련 버그가 더 명확하게 드러납니다. 버그가 PCID 활성화 시에만 발생한다면 TLB 일관성 문제일 가능성이 높습니다.


10. Huge_Pages - 대형_페이지_최적화

시작하며

여러분이 10GB 데이터베이스를 다룬다면, 4KB 페이지로는 약 260만 개의 페이지 테이블 엔트리가 필요합니다. TLB는 겨우 수백 개만 캐시할 수 있으므로, TLB 미스율이 99.9%에 달해 성능이 폭락합니다.

이런 문제는 대용량 메모리를 다루는 데이터베이스, 머신러닝, 과학 계산 등 모든 메모리 집약적 애플리케이션에서 발생합니다. 작은 페이지로는 TLB의 "커버리지"가 턱없이 부족합니다.

바로 이럴 때 필요한 것이 huge pages(2MB, 1GB)입니다. 페이지 크기를 512배 또는 262,144배로 늘려 동일한 TLB 엔트리로 훨씬 넓은 메모리 영역을 커버합니다.

개요

간단히 말해서, huge pages는 기본 4KB보다 큰 페이지(x86-64에서 2MB, 1GB)를 사용하여 TLB 효율을 높이고 페이지 테이블 순회 오버헤드를 줄이는 최적화 기법입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 현대 애플리케이션은 수십-수백 GB 메모리를 사용하므로 4KB 페이지만으로는 TLB가 작업 세트의 극히 일부만 커버하기 때문입니다.

예를 들어, 512개 TLB 엔트리 * 4KB = 2MB만 커버하지만, 2MB 페이지를 사용하면 512 * 2MB = 1GB를 커버합니다. 기존에는 모든 메모리가 4KB로 관리되어 대규모 메모리 애플리케이션의 성능이 TLB 병목에 시달렸습니다.

이제는 huge pages로 TLB 미스를 90% 이상 줄이고, 메모리 집약적 워크로드의 처리량을 20-50% 향상시킬 수 있습니다. Huge pages의 핵심 특징은 첫째, P2 또는 P3 레벨에서 매핑 종료(페이지 테이블 순회 단축), 둘째, TLB 커버리지 극적 증가, 셋째, 내부 단편화 증가(트레이드오프)입니다.

이러한 특징들이 대규모 메모리 애플리케이션의 필수 최적화 수단이 되었습니다.

코드 예제

// 2MB huge page 매핑
pub fn map_huge_page(
    p4_table: &mut PageTable,
    virt: VirtAddr,
    phys: PhysAddr,
    flags: PageTableFlags,
) -> Result<(), MapError> {
    // 2MB 정렬 확인
    assert!(virt.0 % (2 * 1024 * 1024) == 0);
    assert!(phys.0 % (2 * 1024 * 1024) == 0);

    // P4 -> P3 -> P2까지만 순회
    let p3 = get_or_create_table(p4_table, virt.p4_index())?;
    let p2 = get_or_create_table(p3, virt.p3_index())?;

    // P2 엔트리에 HUGE_PAGE 플래그 설정
    let entry = &mut p2[virt.p2_index()];
    if entry.is_present() {
        return Err(MapError::AlreadyMapped);
    }

    *entry = PageTableEntry::new(
        phys,
        flags | PageTableFlags::HUGE_PAGE // 비트 7 설정
    );

    flush_page(virt);
    Ok(())
}

// 1GB huge page 매핑 (P3 레벨)
pub fn map_1gb_page(
    p4_table: &mut PageTable,
    virt: VirtAddr,
    phys: PhysAddr,
    flags: PageTableFlags,
) -> Result<(), MapError> {
    assert!(virt.0 % (1024 * 1024 * 1024) == 0);

    let p3 = get_or_create_table(p4_table, virt.p4_index())?;
    let entry = &mut p3[virt.p3_index()];

    *entry = PageTableEntry::new(phys, flags | PageTableFlags::HUGE_PAGE);
    flush_page(virt);
    Ok(())
}

설명

이것이 하는 일: 페이지 테이블의 중간 레벨(P2 또는 P3)에서 HUGE_PAGE 플래그를 설정하여 페이지 크기를 512배 또는 262,144배로 확대합니다. 첫 번째로, map_huge_page() 함수는 2MB huge page를 매핑합니다.

핵심은 P4 -> P3 -> P2까지만 순회하고, P2 엔트리에 직접 물리 주소를 쓰는 것입니다. 일반 4KB 페이지라면 P2는 P1 테이블을 가리켜야 하지만, HUGE_PAGE 플래그가 있으면 P2 엔트리가 2MB 물리 프레임을 직접 가리킵니다.

따라서 P1 테이블이 필요 없어져 메모리 오버헤드가 줄고 순회 단계도 하나 감소합니다. 그 다음으로, 2MB 정렬 체크가 필수입니다.

Huge page는 2MB 경계에 정렬되어야 하므로 하위 21비트가 0이어야 합니다. 정렬되지 않으면 하드웨어가 페이지 폴트를 발생시킵니다.

이는 huge page의 제약 사항으로, 작은 객체를 위해서는 사용할 수 없습니다. 세 번째로, map_1gb_page() 함수는 1GB huge page를 매핑합니다.

이는 더 극단적인 최적화로, P3 레벨에서 직접 매핑이 종료됩니다. 1GB 페이지는 대규모 공유 메모리, 대용량 파일 매핑, 거대한 배열 등에 적합합니다.

예를 들어, 100GB 메모리 맵 파일을 100개의 1GB 페이지로 매핑하면 TLB 엔트리 100개만으로 전체를 커버할 수 있습니다. 마지막으로, HUGE_PAGE 플래그(비트 7)가 설정되면 CPU는 해당 레벨에서 주소 변환을 종료하고, 엔트리의 물리 주소에 나머지 비트들을 오프셋으로 더합니다.

2MB 페이지는 21비트 오프셋, 1GB 페이지는 30비트 오프셋을 사용합니다. 여러분이 이 코드를 사용하면 메모리 집약적 애플리케이션의 성능을 극적으로 향상시킬 수 있습니다.

데이터베이스 버퍼 캐시, JVM 힙, Redis 메모리 등을 huge pages로 할당하면 TLB 미스가 거의 사라집니다. 또한 페이지 테이블 메모리 사용량이 줄어들고, 페이지 폴트 처리 빈도도 감소하여 전체 시스템 효율이 향상됩니다.

실전 팁

💡 투명 huge pages(THP)를 구현하면 애플리케이션 수정 없이 자동으로 huge pages를 사용할 수 있습니다. 연속된 4KB 페이지를 감지하여 2MB로 병합하세요.

💡 내부 단편화를 줄이려면 큰 할당(>1MB)만 huge pages를 사용하고, 작은 할당은 일반 페이지를 사용하는 하이브리드 전략을 취하세요.

💡 CPU가 1GB 페이지를 지원하는지 CPUID로 확인하세요(CPUID.80000001H:EDX.Page1GB[bit 26]). 모든 x86-64 CPU가 지원하는 것은 아닙니다.

💡 컨테이너 환경에서는 호스트 OS가 huge pages를 예약해야 하므로, /sys/kernel/mm/hugepages를 통해 미리 할당하세요.

💡 디버깅 시 /proc/meminfo의 HugePages_* 항목을 모니터링하면 huge pages 사용량과 단편화 상태를 파악할 수 있습니다.


#Rust#Paging#VirtualMemory#PageTable#MemoryManagement#시스템프로그래밍

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.