이미지 로딩 중...

Rust로 만드는 나만의 OS - 페이지 테이블 구조 완벽 가이드 - 슬라이드 1/13
A

AI Generated

2025. 11. 13. · 3 Views

Rust로 만드는 나만의 OS - 페이지 테이블 구조 완벽 가이드

운영체제의 핵심인 4단계 페이지 테이블 구조를 Rust로 구현하는 방법을 알아봅니다. 가상 메모리 관리의 핵심 메커니즘을 실무적으로 깊이 있게 다룹니다.


목차

  1. 4단계 페이지 테이블 기본 구조
  2. 가상 주소 분해와 인덱스 추출
  3. 페이지 테이블 워킹(Walking) 구현
  4. 페이지 매핑 생성 및 플래그 설정
  5. 페이지 언매핑 및 메모리 해제
  6. 재귀 매핑(Recursive Mapping) 기법
  7. Huge Page(대형 페이지) 지원
  8. Copy-on-Write(COW) 구현
  9. 페이지 테이블 엔트리 플래그 최적화
  10. 페이지 폴트 핸들러 구현
  11. 페이지 테이블 동기화 및 TLB Shootdown
  12. PCID(Process Context Identifier) 최적화

1. 4단계 페이지 테이블 기본 구조

시작하며

여러분이 운영체제를 개발하면서 가상 메모리 주소를 물리 메모리로 변환해야 하는 상황을 겪어본 적 있나요? 단순히 주소를 1:1로 매핑하면 메모리가 부족하고, 프로세스 간 메모리 격리도 불가능합니다.

이런 문제는 실제 시스템 프로그래밍에서 가장 기본이 되는 도전과제입니다. x86_64 아키텍처는 이를 해결하기 위해 4단계 페이징 구조를 사용하는데, 이는 48비트 가상 주소 공간을 효율적으로 관리할 수 있게 해줍니다.

바로 이럴 때 필요한 것이 4단계 페이지 테이블 구조입니다. 이 구조를 이해하고 구현하면 프로세스마다 독립적인 가상 메모리 공간을 제공하고, 메모리 보호와 효율적인 메모리 사용이 가능해집니다.

개요

간단히 말해서, 4단계 페이지 테이블은 가상 주소를 물리 주소로 변환하기 위한 계층적 자료구조입니다. PML4(Page Map Level 4) → PDPT(Page Directory Pointer Table) → PD(Page Directory) → PT(Page Table) 순서로 탐색합니다.

왜 이 구조가 필요한지 실무 관점에서 설명하면, 전체 주소 공간을 한 번에 매핑하려면 엄청난 메모리가 필요합니다. 예를 들어, 48비트 주소 공간을 4KB 페이지로 나누면 약 256TB의 매핑 테이블이 필요한데, 4단계 구조를 사용하면 실제 사용하는 메모리만큼만 테이블을 할당할 수 있습니다.

기존의 단순 매핑 방식에서는 모든 메모리를 미리 할당해야 했다면, 이제는 필요한 부분만 동적으로 할당할 수 있습니다. 이 구조의 핵심 특징은 첫째, 계층적 구조로 메모리 효율성을 극대화하고, 둘째, 각 단계마다 512개의 엔트리를 가지며, 셋째, 각 엔트리는 64비트로 플래그와 물리 주소를 담습니다.

이러한 특징들이 현대 운영체제의 메모리 관리를 가능하게 만듭니다.

코드 예제

use x86_64::structures::paging::{PageTable, PageTableFlags as Flags};
use x86_64::{PhysAddr, VirtAddr};

// 페이지 테이블 엔트리 구조
#[repr(C)]
pub struct PageTableEntry {
    entry: u64,
}

impl PageTableEntry {
    // 주석: 물리 주소와 플래그로 새 엔트리 생성
    pub fn new(addr: PhysAddr, flags: Flags) -> Self {
        Self {
            entry: (addr.as_u64() & 0x000f_ffff_ffff_f000) | flags.bits(),
        }
    }

    // 주석: 엔트리가 사용 중인지 확인
    pub fn is_present(&self) -> bool {
        self.entry & 0x1 != 0
    }

    // 주석: 다음 단계 테이블의 물리 주소 추출
    pub fn addr(&self) -> PhysAddr {
        PhysAddr::new(self.entry & 0x000f_ffff_ffff_f000)
    }
}

설명

이것이 하는 일: PageTableEntry는 64비트 엔트리로, 하위 12비트는 플래그(권한, 존재 여부 등)를 저장하고 중간 40비트는 다음 테이블이나 물리 페이지의 주소를 저장합니다. 코드를 단계별로 나누어 보겠습니다.

첫 번째로, new 함수는 물리 주소의 하위 12비트를 마스킹하여 페이지 정렬된 주소만 추출하고, 플래그 비트와 OR 연산으로 합칩니다. 왜 이렇게 하는지는, 페이지는 항상 4KB(2^12) 경계에 정렬되므로 하위 12비트는 항상 0이고, 이 공간을 플래그로 활용할 수 있기 때문입니다.

그 다음으로, is_present 함수가 실행되면서 최하위 비트(Present 플래그)를 검사합니다. 내부에서는 비트 AND 연산으로 해당 엔트리가 유효한 매핑을 가지고 있는지 확인하는데, 이는 페이지 폴트를 방지하는 핵심 검사입니다.

마지막으로, addr 함수가 엔트리에서 물리 주소 부분만 추출하여 다음 단계 테이블을 찾을 수 있게 합니다. 최종적으로 4단계 탐색을 거쳐 실제 물리 메모리 위치를 얻게 됩니다.

여러분이 이 코드를 사용하면 안전한 타입 시스템으로 페이지 테이블을 관리할 수 있고, 컴파일 타임에 많은 오류를 잡을 수 있으며, 메모리 안전성이 보장된 OS 개발이 가능해집니다.

실전 팁

💡 페이지 테이블은 항상 4KB 경계에 정렬되어야 하므로, 할당 시 align_to(4096)를 반드시 사용하세요.

💡 Present 플래그를 체크하지 않고 주소를 역참조하면 트리플 폴트가 발생할 수 있으니, 항상 is_present() 먼저 확인하세요.

💡 물리 주소를 추출할 때 0x000f_ffff_ffff_f000 마스크를 사용하는 이유는 x86_64가 52비트 물리 주소를 지원하기 때문입니다.

💡 디버깅 시 각 엔트리의 비트를 바이너리로 출력하면 플래그 상태를 한눈에 파악할 수 있습니다.

💡 TLB(Translation Lookaside Buffer) 무효화를 잊지 마세요. 페이지 테이블 변경 후 invlpg 명령어로 캐시를 초기화해야 합니다.


2. 가상 주소 분해와 인덱스 추출

시작하며

여러분이 64비트 가상 주소를 받았을 때, 이 주소가 4단계 테이블의 어느 엔트리를 참조해야 하는지 알아야 하는 상황을 겪어본 적 있나요? 48비트 주소 공간에서 각 9비트씩 4개의 인덱스와 12비트 오프셋으로 나누는 작업이 필요합니다.

이런 주소 분해는 페이지 테이블 워킹(walking)의 첫 단계이며, 잘못 계산하면 전혀 다른 메모리 위치를 참조하게 됩니다. 특히 x86_64는 48비트만 사용하고 상위 16비트는 부호 확장되어야 하는 특수한 규칙이 있습니다.

바로 이럴 때 필요한 것이 가상 주소 분해 로직입니다. 비트 연산으로 각 레벨의 인덱스를 정확히 추출하면 올바른 페이지 테이블 엔트리에 접근할 수 있습니다.

개요

간단히 말해서, 가상 주소 분해는 48비트 주소를 5개 부분으로 나누는 작업입니다. PML4 인덱스(9비트), PDPT 인덱스(9비트), PD 인덱스(9비트), PT 인덱스(9비트), 페이지 오프셋(12비트)으로 구성됩니다.

왜 이 분해가 필요한지 실무 관점에서 설명하면, CPU의 MMU(Memory Management Unit)가 정확히 이 방식으로 주소를 변환하기 때문입니다. 예를 들어, 0xFFFF_8000_0010_5000 같은 주소를 받으면 각 9비트씩 추출하여 512개 엔트리 배열의 인덱스로 사용해야 합니다.

기존에는 하드코딩된 시프트와 마스크를 사용했다면, 이제는 타입 안전한 래퍼를 만들어 실수를 방지할 수 있습니다. 이 로직의 핵심 특징은 첫째, 비트 시프트와 마스크 연산으로 O(1) 시간에 인덱스를 추출하고, 둘째, 각 인덱스는 0-511 범위를 가지며, 셋째, 상위 16비트는 47번 비트의 부호 확장이어야 합니다.

이러한 특징들이 하드웨어와 소프트웨어의 일관성을 보장합니다.

코드 예제

use x86_64::VirtAddr;

// 주석: 가상 주소에서 각 레벨의 인덱스 추출
pub struct PageTableIndices {
    pub p4_index: u16,  // PML4 인덱스
    pub p3_index: u16,  // PDPT 인덱스
    pub p2_index: u16,  // PD 인덱스
    pub p1_index: u16,  // PT 인덱스
}

impl PageTableIndices {
    pub fn from_addr(addr: VirtAddr) -> Self {
        let addr = addr.as_u64();
        // 주석: 각 9비트씩 추출하여 테이블 인덱스로 사용
        Self {
            p4_index: ((addr >> 39) & 0x1FF) as u16,  // 비트 39-47
            p3_index: ((addr >> 30) & 0x1FF) as u16,  // 비트 30-38
            p2_index: ((addr >> 21) & 0x1FF) as u16,  // 비트 21-29
            p1_index: ((addr >> 12) & 0x1FF) as u16,  // 비트 12-20
        }
    }

    // 주석: 페이지 내 오프셋 추출 (하위 12비트)
    pub fn page_offset(addr: VirtAddr) -> u64 {
        addr.as_u64() & 0xFFF
    }
}

설명

이것이 하는 일: 64비트 가상 주소에서 비트 시프트와 마스크 연산으로 각 페이지 테이블 레벨의 인덱스를 추출하여, 4단계 탐색 경로를 결정합니다. 코드를 단계별로 나누어 보겠습니다.

첫 번째로, p4_index는 비트 39-47을 추출합니다. 39비트 우측 시프트 후 0x1FF(512) 마스크를 적용하여 PML4 테이블의 512개 엔트리 중 어느 것을 참조할지 결정합니다.

왜 39비트인지는, 하위 12비트는 오프셋이고 그 위 27비트(9+9+9)는 하위 테이블들의 인덱스이기 때문입니다. 그 다음으로, p3_index, p2_index, p1_index가 순차적으로 실행되면서 각각 30, 21, 12비트 시프트를 수행합니다.

내부에서는 동일한 0x1FF 마스크를 적용하여 각 레벨에서 512개 엔트리 중 하나를 선택하는데, 이는 x86_64의 9비트 인덱스 설계와 정확히 일치합니다. 마지막으로, page_offset 함수가 하위 12비트를 추출하여 4KB 페이지 내의 정확한 바이트 위치를 얻습니다.

최종적으로 4개의 인덱스로 테이블을 순회하고 오프셋으로 실제 바이트에 접근하게 됩니다. 여러분이 이 코드를 사용하면 주소 변환 로직을 명확히 이해할 수 있고, 디버깅 시 어느 테이블의 어느 엔트리가 문제인지 정확히 파악할 수 있으며, 커스텀 페이징 구조를 구현할 때도 기반으로 활용할 수 있습니다.

실전 팁

💡 0x1FF는 이진수로 111111111(9비트)이므로, 512개 엔트리 인덱스를 추출하는 완벽한 마스크입니다.

💡 상위 16비트(48-63)는 반드시 47번 비트와 같아야 하며, 그렇지 않으면 General Protection Fault가 발생합니다.

💡 디버깅 시 주소를 이진수로 출력하고 9비트씩 끊어서 보면 각 인덱스를 시각적으로 확인할 수 있습니다.

💡 재귀 매핑을 사용하면 페이지 테이블 자체도 가상 주소로 접근 가능하므로, 고정 오프셋(0xFFFF_FFFF_FFFF_F000 등)을 활용하세요.

💡 성능을 위해 인덱스 추출 함수는 #[inline(always)]로 강제 인라인하면 함수 호출 오버헤드를 제거할 수 있습니다.


3. 페이지 테이블 워킹(Walking) 구현

시작하며

여러분이 가상 주소를 실제 물리 주소로 변환하기 위해 4단계 테이블을 순회해야 하는 상황을 겪어본 적 있나요? PML4부터 시작해서 각 엔트리를 따라가며 최종 물리 페이지를 찾는 과정이 필요합니다.

이런 워킹 과정은 MMU가 하드웨어적으로 수행하는 작업이지만, OS 개발자는 소프트웨어로 동일한 로직을 구현해야 페이지 폴트 처리나 메모리 매핑을 할 수 있습니다. 중간에 Present 플래그가 0인 엔트리를 만나면 페이지 폴트가 발생하므로 신중한 처리가 필요합니다.

바로 이럴 때 필요한 것이 페이지 테이블 워킹 함수입니다. 안전하게 각 레벨을 순회하고 에러를 적절히 처리하면 robust한 메모리 관리 시스템을 만들 수 있습니다.

개요

간단히 말해서, 페이지 테이블 워킹은 4단계 테이블을 순차적으로 따라가며 최종 물리 주소를 찾는 알고리즘입니다. 각 단계에서 인덱스로 엔트리를 찾고, 그 엔트리가 가리키는 다음 테이블로 이동하는 과정을 반복합니다.

왜 이 워킹이 필요한지 실무 관점에서 설명하면, 커널이 프로세스의 메모리 레이아웃을 파악하거나, 페이지 폴트 핸들러가 어느 페이지가 없는지 판단하거나, 메모리 매핑 시 중복 확인을 할 때 반드시 필요합니다. 예를 들어, mmap() 시스템 콜 구현 시 해당 가상 주소가 이미 매핑되어 있는지 확인해야 합니다.

기존의 직접 메모리 접근 방식에서는 세그멘테이션 폴트나 잘못된 메모리 접근이 빈번했다면, 이제는 Result 타입으로 안전하게 에러를 처리할 수 있습니다. 이 워킹의 핵심 특징은 첫째, 각 단계에서 유효성 검사를 수행하고, 둘째, 재귀적 또는 반복적 접근이 가능하며, 셋째, huge page(2MB, 1GB)를 조기에 감지할 수 있습니다.

이러한 특징들이 유연하고 안전한 메모리 관리를 가능하게 합니다.

코드 예제

use x86_64::structures::paging::{PageTable, PageTableFlags};
use x86_64::{PhysAddr, VirtAddr};

pub enum TranslateError {
    NotMapped,      // 엔트리가 Present=0
    HugePage,       // 2MB/1GB 페이지
}

// 주석: 가상 주소를 물리 주소로 변환
pub unsafe fn translate_addr(addr: VirtAddr, p4_table: &PageTable)
    -> Result<PhysAddr, TranslateError>
{
    let indices = PageTableIndices::from_addr(addr);
    let mut table = p4_table;

    // 주석: PML4 → PDPT → PD → PT 순회
    for level in [indices.p4_index, indices.p3_index, indices.p2_index] {
        let entry = &table[level as usize];

        if !entry.flags().contains(PageTableFlags::PRESENT) {
            return Err(TranslateError::NotMapped);
        }

        // 주석: Huge page 감지 (PS bit)
        if entry.flags().contains(PageTableFlags::HUGE_PAGE) {
            return Err(TranslateError::HugePage);
        }

        // 주석: 다음 테이블로 이동
        table = &*(entry.addr().as_u64() as *const PageTable);
    }

    // 주석: 최종 페이지 테이블에서 물리 주소 추출
    let entry = &table[indices.p1_index as usize];
    if !entry.flags().contains(PageTableFlags::PRESENT) {
        return Err(TranslateError::NotMapped);
    }

    Ok(entry.addr() + PageTableIndices::page_offset(addr))
}

설명

이것이 하는 일: 가상 주소의 인덱스를 추출하여 PML4부터 시작해 각 레벨의 페이지 테이블을 따라가며, 최종적으로 물리 페이지 주소와 오프셋을 결합하여 실제 물리 주소를 반환합니다. 코드를 단계별로 나누어 보겠습니다.

첫 번째로, from_addr로 4개의 인덱스를 추출하고 PML4 테이블 참조를 시작점으로 설정합니다. 왜 이렇게 하는지는, CR3 레지스터가 PML4의 물리 주소를 담고 있으며 모든 주소 변환은 여기서 시작되기 때문입니다.

그 다음으로, for 루프가 실행되면서 처음 3개 레벨(PML4, PDPT, PD)을 순회합니다. 내부에서는 각 엔트리의 PRESENT 플래그를 검사하고, HUGE_PAGE 플래그로 2MB나 1GB 페이지를 감지합니다.

중간 테이블이 매핑되지 않았으면 즉시 에러를 반환하여 invalid 메모리 접근을 방지합니다. 마지막으로, 최종 페이지 테이블(PT)에서 p1_index로 엔트리를 찾고 물리 주소를 추출합니다.

최종적으로 페이지 시작 주소에 12비트 오프셋을 더해 정확한 물리 바이트 주소를 얻게 됩니다. 여러분이 이 코드를 사용하면 페이지 폴트의 원인을 정확히 파악할 수 있고, 커스텀 메모리 할당자 구현 시 주소 변환 로직을 통합할 수 있으며, huge page 최적화 여부를 런타임에 결정할 수 있습니다.

실전 팁

💡 unsafe 블록을 사용하므로 반드시 p4_table이 유효한 페이지 테이블을 가리키는지 호출 전에 검증하세요.

💡 HUGE_PAGE 플래그 검사는 PD 레벨(2MB)과 PDPT 레벨(1GB)에서만 의미가 있으므로, PT 레벨에서는 생략 가능합니다.

💡 재귀 매핑을 사용하면 물리 주소를 가상 주소로 변환할 필요가 없어 코드가 간결해집니다.

💡 TLB 히트율을 높이려면 연속된 가상 주소에 대한 변환을 배치로 처리하세요.

💡 디버깅 시 각 레벨의 테이블 주소와 인덱스를 로깅하면 어느 단계에서 실패했는지 추적하기 쉽습니다.


4. 페이지 매핑 생성 및 플래그 설정

시작하며

여러분이 새로운 가상 메모리 영역을 할당하고 물리 메모리와 매핑해야 하는 상황을 겪어본 적 있나요? 단순히 엔트리를 설정하는 것뿐만 아니라, 중간 테이블이 없으면 생성하고, 적절한 권한 플래그를 설정해야 합니다.

이런 매핑 생성은 mmap, brk, 페이지 폴트 처리 등 거의 모든 메모리 관리 작업의 핵심입니다. 읽기 전용, 실행 가능, 사용자 모드 접근 등 다양한 권한을 플래그로 세밀하게 제어할 수 있어야 보안과 안정성이 보장됩니다.

바로 이럴 때 필요한 것이 페이지 매핑 생성 함수입니다. 안전하게 중간 테이블을 할당하고 적절한 플래그를 설정하면 프로세스별 독립적인 메모리 공간을 구축할 수 있습니다.

개요

간단히 말해서, 페이지 매핑 생성은 가상 주소와 물리 주소를 연결하는 엔트리를 페이지 테이블에 추가하는 작업입니다. PRESENT, WRITABLE, USER_ACCESSIBLE, NO_EXECUTE 등의 플래그로 접근 권한을 제어합니다.

왜 이 매핑이 필요한지 실무 관점에서 설명하면, 모든 메모리 할당은 결국 페이지 매핑으로 귀결됩니다. 예를 들어, malloc()으로 힙 메모리를 할당하면 커널이 새 페이지를 매핑하고 사용자 프로세스에 반환합니다.

exec()로 프로그램을 로드하면 코드 섹션은 읽기+실행, 데이터 섹션은 읽기+쓰기로 각각 다르게 매핑됩니다. 기존의 수동 비트 조작 방식에서는 플래그 실수로 보안 취약점이 발생했다면, 이제는 타입 안전한 플래그 열거형으로 실수를 방지할 수 있습니다.

이 매핑의 핵심 특징은 첫째, 중간 테이블을 lazy하게 할당하여 메모리를 절약하고, 둘째, 플래그로 하드웨어 레벨 보호를 활성화하며, 셋째, 원자적 업데이트로 멀티코어 환경에서도 안전합니다. 이러한 특징들이 현대적인 메모리 보호 메커니즘의 기반이 됩니다.

코드 예제

use x86_64::structures::paging::{
    PageTable, PageTableFlags as Flags, FrameAllocator, PhysFrame
};
use x86_64::{PhysAddr, VirtAddr};

// 주석: 가상 주소를 물리 프레임에 매핑
pub unsafe fn map_page<A>(
    addr: VirtAddr,
    frame: PhysFrame,
    flags: Flags,
    p4_table: &mut PageTable,
    allocator: &mut A,
) -> Result<(), MapError>
where
    A: FrameAllocator<Size4KiB>,
{
    let indices = PageTableIndices::from_addr(addr);
    let mut table = p4_table;

    // 주석: 중간 테이블들을 순회하며 필요시 생성
    for level in [indices.p4_index, indices.p3_index, indices.p2_index] {
        let entry = &mut table[level as usize];

        // 주석: 엔트리가 없으면 새 테이블 할당
        if !entry.flags().contains(Flags::PRESENT) {
            let new_frame = allocator.allocate_frame()
                .ok_or(MapError::FrameAllocationFailed)?;

            entry.set_addr(new_frame.start_address(),
                Flags::PRESENT | Flags::WRITABLE);

            // 주석: 새 테이블을 0으로 초기화
            let table_ptr = new_frame.start_address().as_u64() as *mut PageTable;
            (*table_ptr).zero();
        }

        table = &mut *(entry.addr().as_u64() as *mut PageTable);
    }

    // 주석: 최종 엔트리 설정
    let entry = &mut table[indices.p1_index as usize];
    if entry.flags().contains(Flags::PRESENT) {
        return Err(MapError::PageAlreadyMapped);
    }

    entry.set_addr(frame.start_address(), flags | Flags::PRESENT);
    Ok(())
}

설명

이것이 하는 일: 4단계 테이블을 순회하며 중간 테이블이 없으면 새로 할당하고, 최종적으로 가상 주소를 물리 프레임에 연결하는 엔트리를 권한 플래그와 함께 설정합니다. 코드를 단계별로 나누어 보겠습니다.

첫 번째로, 인덱스를 추출하고 PML4부터 순회를 시작합니다. 각 레벨에서 엔트리가 PRESENT인지 확인하고, 없으면 allocate_frame()으로 새 4KB 프레임을 할당받아 테이블로 사용합니다.

왜 이렇게 하는지는, 전체 주소 공간을 미리 할당하는 것은 비효율적이고, 실제 사용하는 부분만 동적으로 할당하는 것이 메모리를 절약하기 때문입니다. 그 다음으로, 새로 할당한 테이블을 zero()로 초기화합니다.

내부에서는 512개 엔트리를 모두 0으로 설정하는데, 이는 초기화하지 않으면 이전 메모리의 쓰레기 값이 유효한 매핑처럼 보여 심각한 버그를 일으킬 수 있기 때문입니다. 중간 테이블의 플래그는 PRESENT | WRITABLE로 설정하여 하위 레벨에서 더 제한적인 권한을 설정할 수 있게 합니다.

마지막으로, 최종 페이지 테이블에서 이미 매핑된 주소인지 검사하고, 새 엔트리를 설정합니다. 최종적으로 가상 주소 접근 시 하드웨어가 이 엔트리를 따라가 물리 메모리에 도달하게 됩니다.

여러분이 이 코드를 사용하면 커널 힙, 사용자 스택, mmap 영역 등 다양한 메모리 영역을 프로그래밍 방식으로 관리할 수 있고, demand paging(지연 할당)을 구현할 수 있으며, copy-on-write 같은 고급 최적화도 가능해집니다.

실전 팁

💡 중간 테이블의 플래그는 최종 페이지보다 관대하게 설정해야 합니다. 예를 들어, 최종 페이지가 읽기 전용이어도 중간 테이블은 WRITABLE이어야 합니다.

💡 멀티코어 환경에서는 lock cmpxchg 명령어로 엔트리를 원자적으로 업데이트하여 race condition을 방지하세요.

💡 매핑 후 반드시 invlpg 또는 CR3 재로드로 TLB를 무효화해야 변경사항이 즉시 반영됩니다.

💡 huge page를 사용하려면 PD나 PDPT 레벨에서 HUGE_PAGE 플래그를 설정하고 워킹을 조기 종료하세요.

💡 NO_EXECUTE 플래그는 보안에 매우 중요하므로, 데이터 영역에는 항상 설정하여 code injection 공격을 방어하세요.


5. 페이지 언매핑 및 메모리 해제

시작하며

여러분이 더 이상 사용하지 않는 메모리 영역을 해제하고 페이지 테이블 엔트리를 정리해야 하는 상황을 겪어본 적 있나요? 단순히 PRESENT 플래그를 0으로 만드는 것뿐만 아니라, 물리 프레임을 해제하고 TLB를 무효화해야 합니다.

이런 언매핑은 메모리 누수를 방지하고 시스템 안정성을 유지하는 핵심 작업입니다. munmap, 프로세스 종료, 페이지 스왑아웃 등 다양한 상황에서 발생하며, 잘못 처리하면 dangling pointer나 메모리 누수가 발생합니다.

바로 이럴 때 필요한 것이 페이지 언매핑 함수입니다. 안전하게 매핑을 제거하고 리소스를 반환하면 장시간 실행되는 서버도 메모리를 효율적으로 재활용할 수 있습니다.

개요

간단히 말해서, 페이지 언매핑은 페이지 테이블 엔트리를 제거하고 해당 물리 프레임을 프리 리스트에 반환하는 작업입니다. PRESENT 플래그를 끄고, TLB를 무효화하며, 선택적으로 빈 중간 테이블도 해제합니다.

왜 이 언매핑이 필요한지 실무 관점에서 설명하면, 모든 메모리 해제는 결국 페이지 언매핑으로 구현됩니다. 예를 들어, free()로 힙 메모리를 해제하면 커널은 해당 페이지를 언매핑하고 물리 메모리를 다른 프로세스가 사용할 수 있게 합니다.

프로세스가 종료되면 모든 페이지를 언매핑하여 시스템 전체 메모리를 회수합니다. 기존의 수동 메모리 관리에서는 이중 해제나 메모리 누수가 빈번했다면, 이제는 RAII 패턴과 Result 타입으로 안전하게 관리할 수 있습니다.

이 언매핑의 핵심 특징은 첫째, TLB 무효화로 즉시 변경사항을 반영하고, 둘째, 물리 프레임을 프리 리스트에 반환하여 재사용 가능하게 하며, 셋째, 빈 중간 테이블을 재귀적으로 해제하여 메모리를 절약합니다. 이러한 특징들이 메모리 효율성과 안정성을 보장합니다.

코드 예제

use x86_64::structures::paging::{PageTable, PageTableFlags as Flags};
use x86_64::{VirtAddr, PhysFrame};

pub enum UnmapError {
    PageNotMapped,
}

// 주석: 페이지 언매핑 및 프레임 반환
pub unsafe fn unmap_page(
    addr: VirtAddr,
    p4_table: &mut PageTable,
) -> Result<PhysFrame, UnmapError> {
    let indices = PageTableIndices::from_addr(addr);
    let mut table = p4_table;

    // 주석: 최종 페이지 테이블까지 순회
    for level in [indices.p4_index, indices.p3_index, indices.p2_index] {
        let entry = &table[level as usize];

        if !entry.flags().contains(Flags::PRESENT) {
            return Err(UnmapError::PageNotMapped);
        }

        table = &mut *(entry.addr().as_u64() as *mut PageTable);
    }

    // 주석: 최종 엔트리 제거
    let entry = &mut table[indices.p1_index as usize];
    if !entry.flags().contains(Flags::PRESENT) {
        return Err(UnmapError::PageNotMapped);
    }

    let frame = PhysFrame::containing_address(entry.addr());

    // 주석: 엔트리를 0으로 설정하여 매핑 제거
    entry.set_unused();

    // 주석: TLB에서 해당 주소 무효화
    use x86_64::instructions::tlb;
    tlb::flush(addr);

    Ok(frame)
}

설명

이것이 하는 일: 가상 주소에 해당하는 페이지 테이블 엔트리를 찾아 PRESENT 플래그를 끄고, 매핑되었던 물리 프레임을 반환하며, CPU의 TLB 캐시를 무효화하여 이후 접근 시 페이지 폴트가 발생하도록 합니다. 코드를 단계별로 나누어 보겠습니다.

첫 번째로, 워킹 로직으로 최종 페이지 테이블까지 순회합니다. 각 단계에서 PRESENT 플래그를 확인하여 매핑되지 않은 주소를 언매핑하려는 실수를 방지합니다.

왜 이렇게 하는지는, 존재하지 않는 매핑을 제거하려는 것은 논리적 오류이며, 호출자가 이를 인지해야 하기 때문입니다. 그 다음으로, 최종 엔트리에서 물리 프레임 주소를 추출합니다.

내부에서는 containing_address로 PhysFrame 객체를 생성하는데, 이는 나중에 프레임 할당자에게 반환할 때 타입 안전성을 보장합니다. set_unused()로 엔트리를 0으로 만들어 PRESENT 플래그를 끄고 모든 다른 비트도 초기화합니다.

마지막으로, tlb::flush(addr)로 해당 가상 주소의 TLB 엔트리를 무효화합니다. 최종적으로 CPU가 이 주소에 접근하면 TLB 미스가 발생하고, 페이지 테이블을 다시 워킹하여 매핑이 없음을 발견해 페이지 폴트를 발생시킵니다.

여러분이 이 코드를 사용하면 메모리 누수 없이 동적 메모리를 관리할 수 있고, 프로세스 격리를 유지하면서 물리 메모리를 재활용할 수 있으며, 메모리 압박 상황에서 페이지를 스왑아웃하는 기능도 구현할 수 있습니다.

실전 팁

💡 tlb::flush는 단일 주소만 무효화하므로, 여러 페이지를 언매핑할 때는 tlb::flush_all()이나 CR3 재로드가 더 효율적일 수 있습니다.

💡 반환된 PhysFrame을 프레임 할당자의 deallocate_frame()으로 반드시 해제해야 메모리 누수를 방지할 수 있습니다.

💡 중간 테이블이 모든 엔트리가 비어있으면 해당 테이블도 해제하여 메모리를 절약할 수 있지만, 복잡도가 증가합니다.

💡 멀티코어 환경에서는 다른 CPU의 TLB도 무효화해야 하므로, IPI(Inter-Processor Interrupt)로 shootdown을 구현하세요.

💡 디버깅 시 언매핑 전에 엔트리의 Accessed/Dirty 플래그를 로깅하면 페이지 사용 패턴을 분석할 수 있습니다.


6. 재귀 매핑(Recursive Mapping) 기법

시작하며

여러분이 페이지 테이블 자체를 수정하기 위해 물리 주소를 가상 주소로 변환해야 하는 상황을 겪어본 적 있나요? 매번 identity mapping이나 임시 매핑을 만드는 것은 비효율적이고 복잡합니다.

이런 문제는 페이지 테이블 관리 코드를 작성할 때 항상 직면하는 chicken-and-egg 문제입니다. 페이지 테이블을 수정하려면 그 테이블에 접근해야 하는데, 접근하려면 가상 주소가 필요하고, 가상 주소를 얻으려면 또 다른 매핑이 필요합니다.

바로 이럴 때 필요한 것이 재귀 매핑 기법입니다. PML4의 마지막 엔트리가 자기 자신을 가리키게 하면, 모든 페이지 테이블을 고정된 가상 주소로 접근할 수 있습니다.

개요

간단히 말해서, 재귀 매핑은 PML4의 511번째 엔트리(또는 다른 특정 슬롯)가 PML4 자신의 물리 주소를 가리키게 하는 기법입니다. 이렇게 하면 페이지 테이블들이 가상 메모리 공간의 특정 영역에 매핑됩니다.

왜 이 기법이 필요한지 실무 관점에서 설명하면, 커널이 페이지 테이블을 동적으로 수정할 때 물리 주소를 직접 다루는 것은 매우 불편합니다. 예를 들어, 새 매핑을 추가할 때 중간 테이블을 할당하고 초기화하려면 그 테이블의 가상 주소가 필요한데, 재귀 매핑이 있으면 계산만으로 얻을 수 있습니다.

기존의 직접 물리 주소 접근 방식에서는 MMU를 끄거나 identity mapping이 필요했다면, 이제는 페이징을 켠 상태에서도 모든 테이블에 안전하게 접근할 수 있습니다. 이 기법의 핵심 특징은 첫째, 추가 매핑 없이 모든 레벨의 테이블에 접근 가능하고, 둘째, 가상 주소 계산이 수학적으로 결정적이며, 셋째, 512개 엔트리 중 하나만 희생하면 됩니다.

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

코드 예제

use x86_64::{VirtAddr, structures::paging::PageTable};

// 주석: 재귀 매핑 슬롯 (PML4의 511번 엔트리)
const RECURSIVE_INDEX: u16 = 511;

// 주석: 재귀 매핑을 사용하여 페이지 테이블 접근
pub struct RecursivePageTable {
    p4_addr: VirtAddr,
}

impl RecursivePageTable {
    // 주석: 재귀 매핑된 PML4 주소로 초기화
    pub unsafe fn new() -> Self {
        Self {
            p4_addr: VirtAddr::new(
                0xFFFF_FFFF_FFFF_F000  // 재귀 매핑 기본 주소
            ),
        }
    }

    // 주석: 특정 레벨의 페이지 테이블 가상 주소 계산
    pub fn table_address(&self, indices: [u16; 4]) -> VirtAddr {
        let mut addr = 0xFFFF_0000_0000_0000u64;  // 최상위 비트 부호 확장

        // 주석: 재귀 인덱스를 추가하여 테이블 레벨 결정
        addr |= (RECURSIVE_INDEX as u64) << 39;  // PML4 재귀

        for (level, &index) in indices.iter().enumerate() {
            addr |= (index as u64) << (12 + 9 * (3 - level));
        }

        VirtAddr::new(addr)
    }

    // 주석: PML4 테이블에 직접 접근
    pub fn p4_table(&self) -> &'static mut PageTable {
        unsafe { &mut *(self.p4_addr.as_u64() as *mut PageTable) }
    }
}

설명

이것이 하는 일: PML4의 특정 엔트리(보통 511번)를 PML4 자신의 물리 주소로 설정하여, 페이지 테이블 워킹 메커니즘을 이용해 모든 테이블을 가상 메모리로 접근할 수 있게 만드는 자기 참조 구조입니다. 코드를 단계별로 나누어 보겠습니다.

첫 번째로, new 함수는 재귀 매핑의 기본 주소인 0xFFFF_FFFF_FFFF_F000을 설정합니다. 왜 이 주소인지는, PML4의 511번 엔트리를 4번 연속 참조하면 PML4 자신에 도달하고, 9비트씩 4번 모두 511(0x1FF)을 사용하면 이 주소가 계산되기 때문입니다.

그 다음으로, table_address 함수가 실행되면서 특정 레벨의 테이블 주소를 계산합니다. 내부에서는 재귀 인덱스를 PML4 위치에 넣고, 나머지 인덱스를 하위 레벨에 배치하는데, 예를 들어 PDPT를 얻으려면 [511, p4_index, 0, 0] 형태의 주소를 만듭니다.

이는 "PML4를 따라가되, 한 레벨은 재귀로 다시 PML4로 가고, 그 다음부터 실제 인덱스를 사용"하는 원리입니다. 마지막으로, p4_table 함수가 포인터 캐스팅으로 PML4를 직접 참조합니다.

최종적으로 이 참조를 통해 페이지 테이블의 모든 엔트리를 읽고 쓸 수 있어, 동적 메모리 매핑과 언매핑이 간단해집니다. 여러분이 이 코드를 사용하면 페이지 테이블 수정 코드가 매우 간결해지고, 물리 주소와 가상 주소 변환의 복잡성이 사라지며, 부트로더에서 재귀 매핑만 설정하면 커널 전체에서 활용할 수 있습니다.

실전 팁

💡 재귀 매핑은 511번 대신 다른 슬롯(예: 510번)을 사용할 수도 있지만, 관례적으로 마지막 엔트리를 사용합니다.

💡 재귀 매핑을 초기화할 때 PRESENT, WRITABLE 플래그를 반드시 설정해야 테이블에 쓰기가 가능합니다.

💡 5단계 페이징(Intel LA57)에서는 재귀 매핑도 5단계로 확장되므로, 주소 계산 로직을 조정해야 합니다.

💡 디버깅 시 0xFFFF_0000_0000_0000 영역의 메모리 덤프를 보면 모든 페이지 테이블 구조를 시각적으로 확인할 수 있습니다.

💡 성능상 재귀 매핑은 약간의 TLB 압박을 주므로, 매우 빈번한 테이블 접근에는 캐싱을 고려하세요.


7. Huge Page(대형 페이지) 지원

시작하며

여러분이 큰 메모리 영역을 매핑할 때 4KB 페이지로는 TLB 미스가 너무 많이 발생하는 상황을 겪어본 적 있나요? 데이터베이스 버퍼, 머신러닝 모델, 대용량 파일 매핑 같은 경우 성능이 크게 저하됩니다.

이런 문제는 TLB가 제한된 엔트리 수(보통 64-512개)만 캐시하기 때문에 발생합니다. 4KB 페이지로는 몇 MB밖에 커버하지 못해 대용량 메모리 접근 시 TLB 미스율이 급증합니다.

바로 이럴 때 필요한 것이 Huge Page입니다. 2MB(PD 레벨) 또는 1GB(PDPT 레벨) 페이지를 사용하면 동일한 TLB 엔트리로 훨씬 넓은 메모리를 커버할 수 있습니다.

개요

간단히 말해서, Huge Page는 표준 4KB 대신 2MB 또는 1GB 크기의 큰 페이지를 사용하는 기법입니다. 페이지 테이블의 중간 레벨(PD나 PDPT)에서 HUGE_PAGE 플래그를 설정하고 워킹을 조기 종료합니다.

왜 이 기법이 필요한지 실무 관점에서 설명하면, TLB 히트율이 성능에 큰 영향을 미칩니다. 예를 들어, 1GB 데이터베이스 버퍼를 4KB 페이지로 매핑하면 262,144개 엔트리가 필요하지만 TLB는 몇백 개밖에 캐시 못 하므로 거의 모든 접근에서 TLB 미스가 발생합니다.

512개 2MB 페이지로 매핑하면 TLB에 충분히 들어갑니다. 기존의 고정 4KB 페이지 방식에서는 메모리 오버헤드와 TLB 미스가 문제였다면, 이제는 용도에 따라 페이지 크기를 선택하여 최적화할 수 있습니다.

이 기법의 핵심 특징은 첫째, TLB 히트율을 극적으로 향상시키고, 둘째, 페이지 테이블 워킹 횟수를 줄여 지연시간을 감소시키며, 셋째, 페이지 테이블 메모리 자체도 절약합니다. 이러한 특징들이 고성능 애플리케이션의 필수 최적화 기법이 됩니다.

코드 예제

use x86_64::structures::paging::{PageTableFlags as Flags, PageTable};
use x86_64::{PhysAddr, VirtAddr};

// 주석: 2MB Huge Page 매핑 생성
pub unsafe fn map_huge_page_2mb(
    addr: VirtAddr,
    frame: PhysFrame,  // 2MB 정렬된 프레임
    flags: Flags,
    p4_table: &mut PageTable,
) -> Result<(), MapError> {
    let indices = PageTableIndices::from_addr(addr);
    let mut table = p4_table;

    // 주석: PML4 → PDPT까지만 순회
    for level in [indices.p4_index, indices.p3_index] {
        let entry = &mut table[level as usize];
        if !entry.flags().contains(Flags::PRESENT) {
            // 중간 테이블 할당 로직 (생략)
        }
        table = &mut *(entry.addr().as_u64() as *mut PageTable);
    }

    // 주석: PD 레벨에서 HUGE_PAGE 플래그 설정
    let entry = &mut table[indices.p2_index as usize];
    if entry.flags().contains(Flags::PRESENT) {
        return Err(MapError::PageAlreadyMapped);
    }

    // 주석: 2MB 프레임을 직접 매핑 (PT 레벨 생략)
    entry.set_addr(
        frame.start_address(),
        flags | Flags::PRESENT | Flags::HUGE_PAGE
    );

    Ok(())
}

// 주석: Huge Page 감지
pub fn is_huge_page(entry: &PageTableEntry) -> bool {
    entry.flags().contains(Flags::PRESENT)
        && entry.flags().contains(Flags::HUGE_PAGE)
}

설명

이것이 하는 일: PD 레벨(2MB) 또는 PDPT 레벨(1GB)에서 HUGE_PAGE 플래그를 설정하고, 해당 엔트리가 하위 테이블이 아닌 실제 물리 프레임을 직접 가리키게 하여 큰 연속 메모리 블록을 효율적으로 매핑합니다. 코드를 단계별로 나누어 보겠습니다.

첫 번째로, PML4와 PDPT까지만 순회합니다. 2MB 페이지는 PD 레벨에서 결정되므로 PT 레벨은 필요 없습니다.

왜 이렇게 하는지는, HUGE_PAGE 플래그가 설정되면 MMU가 해당 엔트리를 최종 매핑으로 해석하고 더 이상 하위 테이블을 따라가지 않기 때문입니다. 그 다음으로, PD 엔트리에 물리 프레임 주소와 HUGE_PAGE 플래그를 설정합니다.

내부에서는 2MB 정렬된 물리 주소(하위 21비트가 0)를 사용하는데, 정렬되지 않으면 하드웨어가 하위 비트를 무시하므로 의도하지 않은 주소로 매핑됩니다. 중요한 점은 PT 레벨 512개 엔트리를 생략하므로 페이지 테이블 메모리를 4KB 절약한다는 것입니다.

마지막으로, is_huge_page 함수로 엔트리가 huge page인지 감지합니다. 최종적으로 주소 변환 시 이 플래그를 확인하여 워킹을 조기 종료하고, 21비트 오프셋(2MB 페이지)이나 30비트 오프셋(1GB 페이지)을 사용하게 됩니다.

여러분이 이 코드를 사용하면 메모리 집약적 워크로드에서 20-40% 성능 향상을 얻을 수 있고, TLB 압박을 크게 줄일 수 있으며, 페이지 테이블 자체의 메모리 오버헤드도 감소합니다.

실전 팁

💡 2MB 페이지는 반드시 2MB 경계(0x200000의 배수)에 정렬된 물리/가상 주소를 사용해야 합니다.

💡 내부 단편화가 발생할 수 있으므로, 작은 객체 여러 개보다는 큰 연속 메모리에 사용하세요.

💡 Linux에서는 transparent huge page(THP)가 자동으로 4KB 페이지를 2MB로 승격시키므로 참고하세요.

💡 CPUID로 huge page 지원 여부를 확인하고, CR4.PSE 비트를 활성화해야 합니다.

💡 벤치마크로 실제 성능 향상을 측정하세요. 랜덤 접근 패턴에서는 huge page의 이점이 적을 수 있습니다.


8. Copy-on-Write(COW) 구현

시작하며

여러분이 프로세스를 fork할 때 모든 메모리를 복사하면 너무 느리고 메모리가 낭비되는 상황을 겪어본 적 있나요? 수백 MB의 프로세스를 복사하는데 수십 ms가 걸리고, 대부분 메모리는 읽기만 하므로 중복이 불필요합니다.

이런 문제는 전통적인 fork 구현에서 피할 수 없는 오버헤드였습니다. 특히 fork 후 즉시 exec하는 패턴(대부분의 쉘 명령)에서는 복사한 메모리를 전혀 사용하지 않아 완전히 낭비됩니다.

바로 이럴 때 필요한 것이 Copy-on-Write입니다. 초기에는 부모와 자식이 같은 물리 페이지를 공유하고 읽기 전용으로 매핑한 뒤, 쓰기 시도 시에만 복사하는 lazy evaluation 기법입니다.

개요

간단히 말해서, COW는 페이지를 읽기 전용으로 공유하고, 쓰기 시 페이지 폴트를 발생시켜 그때 복사하는 기법입니다. WRITABLE 플래그를 끄고 커스텀 플래그로 "원래는 쓰기 가능"을 표시합니다.

왜 이 기법이 필요한지 실무 관점에서 설명하면, fork는 Unix 계열 OS의 핵심 시스템 콜인데 naive하게 구현하면 성능이 매우 나쁩니다. 예를 들어, 100MB 프로세스를 fork하면 100MB를 즉시 복사해야 하지만, COW를 사용하면 페이지 테이블만 복사(몇 KB)하고 실제 메모리는 쓰기 발생 시에만 복사합니다.

기존의 eager copy 방식에서는 fork가 느리고 메모리 사용량이 2배가 되었다면, 이제는 실제 수정되는 페이지만 복사하여 시간과 공간을 크게 절약할 수 있습니다. 이 기법의 핵심 특징은 첫째, fork를 O(1) 시간에 수행 가능하게 하고, 둘째, 읽기 전용 데이터는 영구적으로 공유되며, 셋째, 페이지 폴트 핸들러에서 복사 로직을 구현합니다.

이러한 특징들이 현대 OS의 효율적인 프로세스 생성을 가능하게 합니다.

코드 예제

use x86_64::structures::paging::{PageTableFlags as Flags, PageTable};
use x86_64::{VirtAddr, PhysFrame};

// 주석: COW를 위한 커스텀 플래그 (비트 9-11은 OS 사용 가능)
const COW_FLAG: u64 = 1 << 9;

// 주석: 페이지를 COW로 표시 (쓰기 불가로 만듦)
pub unsafe fn mark_cow(entry: &mut PageTableEntry) {
    let mut flags = entry.flags();

    // 주석: WRITABLE 플래그를 끄고 COW 플래그 추가
    flags.remove(Flags::WRITABLE);
    entry.set_flags(flags | COW_FLAG);

    // 주석: 물리 프레임의 참조 카운트 증가 (생략)
    // increment_refcount(entry.addr());
}

// 주석: COW 페이지 폴트 처리
pub unsafe fn handle_cow_fault(
    addr: VirtAddr,
    page_table: &mut PageTable,
) -> Result<(), CowError> {
    let entry = get_entry_mut(addr, page_table)?;

    // 주석: COW 페이지인지 확인
    if !entry.flags().contains(COW_FLAG) {
        return Err(CowError::NotCowPage);
    }

    let old_frame = entry.addr();

    // 주석: 새 프레임 할당 및 데이터 복사
    let new_frame = allocate_frame()?;
    copy_frame(old_frame, new_frame);

    // 주석: 엔트리를 새 프레임으로 업데이트 (쓰기 가능)
    entry.set_addr(new_frame, Flags::PRESENT | Flags::WRITABLE);

    // 주석: TLB 무효화
    tlb::flush(addr);

    // 주석: 구 프레임의 참조 카운트 감소 및 해제
    // decrement_refcount(old_frame);

    Ok(())
}

설명

이것이 하는 일: 여러 프로세스가 같은 물리 페이지를 가리키되 읽기 전용으로 매핑하고, 쓰기 시도 시 발생하는 페이지 폴트에서 페이지를 복사하여 독립적인 사본을 만드는 lazy copying 메커니즘입니다. 코드를 단계별로 나누어 보겠습니다.

첫 번째로, mark_cow 함수가 엔트리의 WRITABLE 플래그를 제거하고 커스텀 COW 플래그를 설정합니다. 왜 이렇게 하는지는, CPU가 쓰기를 시도하면 WRITABLE=0 때문에 자동으로 페이지 폴트가 발생하고, 폴트 핸들러는 COW 플래그를 보고 복사해야 함을 알 수 있기 때문입니다.

참조 카운트를 증가시켜 여러 프로세스가 공유 중임을 추적합니다. 그 다음으로, 페이지 폴트가 발생하면 handle_cow_fault가 실행됩니다.

내부에서는 COW 플래그를 확인하고, 새 물리 프레임을 할당한 뒤 기존 데이터를 copy_frame으로 복사합니다. 4KB를 복사하는 것은 수백 ns 정도로 매우 빠르므로, fork 시 전체 메모리를 복사하는 것보다 훨씬 효율적입니다.

마지막으로, 엔트리를 새 프레임으로 업데이트하고 WRITABLE 플래그를 복원합니다. 최종적으로 페이지 폴트에서 복귀하면 쓰기 명령이 재실행되고, 이번에는 독립적인 페이지에 정상적으로 쓰기가 수행됩니다.

여러분이 이 코드를 사용하면 fork를 밀리초 이하로 수행할 수 있고, fork 후 exec 패턴에서 메모리 복사를 거의 0으로 만들 수 있으며, 메모리 사용량을 크게 절감할 수 있습니다.

실전 팁

💡 참조 카운트는 원자적 증감(atomic inc/dec)으로 구현해야 race condition을 방지할 수 있습니다.

💡 참조 카운트가 1이면 복사 없이 WRITABLE 플래그만 복원하는 최적화가 가능합니다.

💡 COW 플래그는 하드웨어가 무시하는 비트 9-11을 사용하며, OS가 자유롭게 정의할 수 있습니다.

💡 대용량 메모리를 fork할 때는 COW가 없으면 수십~수백 ms 걸리지만, COW로는 1ms 이하로 단축됩니다.

💡 성능 프로파일링 시 COW 폴트 빈도를 측정하면 실제 메모리 수정 패턴을 분석할 수 있습니다.


9. 페이지 테이블 엔트리 플래그 최적화

시작하며

여러분이 페이지 권한을 설정할 때 보안과 성능을 모두 고려해야 하는 상황을 겪어본 적 있나요? NO_EXECUTE를 모든 데이터 페이지에 설정하지 않으면 code injection 공격에 취약하고, GLOBAL 플래그를 잘못 사용하면 TLB 오염이 발생합니다.

이런 플래그 관리는 단순해 보이지만 보안과 성능에 큰 영향을 미칩니다. 잘못된 플래그 조합은 취약점으로 이어지거나, 불필요한 TLB 무효화로 성능을 저하시킵니다.

바로 이럴 때 필요한 것이 플래그 최적화 전략입니다. 용도에 맞는 플래그 조합을 체계적으로 사용하면 안전하고 효율적인 메모리 관리가 가능합니다.

개요

간단히 말해서, 페이지 테이블 플래그는 PRESENT, WRITABLE, USER_ACCESSIBLE, NO_EXECUTE, GLOBAL, ACCESSED, DIRTY 등 다양한 권한과 속성을 제어하는 비트입니다. 각 플래그는 하드웨어 동작을 직접 제어합니다.

왜 이 최적화가 필요한지 실무 관점에서 설명하면, 잘못된 플래그는 심각한 보안 취약점이나 성능 문제를 일으킵니다. 예를 들어, 스택에 NO_EXECUTE를 설정하지 않으면 buffer overflow 공격으로 임의 코드 실행이 가능합니다.

GLOBAL 플래그를 사용자 페이지에 설정하면 컨텍스트 스위치 시 TLB가 무효화되지 않아 다른 프로세스가 데이터를 볼 수 있습니다. 기존의 일괄적인 플래그 설정 방식에서는 보안이나 성능이 희생되었다면, 이제는 페이지 용도에 따라 최적화된 플래그 조합을 사용할 수 있습니다.

이 최적화의 핵심 특징은 첫째, 보안과 성능의 균형을 맞추고, 둘째, 하드웨어 기능을 최대한 활용하며, 셋째, 컴파일 타임에 정책을 강제할 수 있습니다. 이러한 특징들이 robust한 시스템 소프트웨어의 기반이 됩니다.

코드 예제

use x86_64::structures::paging::PageTableFlags as Flags;

// 주석: 용도별 최적화된 플래그 조합
pub struct PageFlags;

impl PageFlags {
    // 주석: 커널 코드 페이지 (읽기+실행, 전역, 사용자 접근 불가)
    pub fn kernel_code() -> Flags {
        Flags::PRESENT | Flags::GLOBAL
        // WRITABLE=0, NO_EXECUTE=0, USER_ACCESSIBLE=0
    }

    // 주석: 커널 데이터 페이지 (읽기+쓰기, 실행 불가, 전역)
    pub fn kernel_data() -> Flags {
        Flags::PRESENT | Flags::WRITABLE
            | Flags::NO_EXECUTE | Flags::GLOBAL
    }

    // 주석: 사용자 코드 페이지 (읽기+실행, 사용자 접근 가능)
    pub fn user_code() -> Flags {
        Flags::PRESENT | Flags::USER_ACCESSIBLE
        // NO_EXECUTE=0 (실행 가능), GLOBAL=0 (프로세스 별)
    }

    // 주석: 사용자 데이터/스택 (읽기+쓰기, 실행 불가, 사용자 접근)
    pub fn user_data() -> Flags {
        Flags::PRESENT | Flags::WRITABLE
            | Flags::USER_ACCESSIBLE | Flags::NO_EXECUTE
    }

    // 주석: 커널 힙 (읽기+쓰기, 실행 불가, 전역)
    pub fn kernel_heap() -> Flags {
        Flags::PRESENT | Flags::WRITABLE
            | Flags::NO_EXECUTE | Flags::GLOBAL
    }

    // 주석: 공유 읽기 전용 데이터 (읽기, 실행 불가, 사용자 접근)
    pub fn shared_readonly() -> Flags {
        Flags::PRESENT | Flags::USER_ACCESSIBLE
            | Flags::NO_EXECUTE
    }
}

// 주석: Accessed/Dirty 비트로 페이지 사용 추적
pub fn check_page_usage(entry: &PageTableEntry) -> PageUsage {
    let accessed = entry.flags().contains(Flags::ACCESSED);
    let dirty = entry.flags().contains(Flags::DIRTY);

    match (accessed, dirty) {
        (false, _) => PageUsage::NotUsed,      // 접근 안됨
        (true, false) => PageUsage::ReadOnly,  // 읽기만
        (true, true) => PageUsage::Modified,   // 수정됨
    }
}

설명

이것이 하는 일: 코드, 데이터, 스택, 힙 등 메모리 영역의 특성에 맞는 플래그 조합을 정의하여, 불필요한 권한을 제거하고(보안), 하드웨어 기능을 활용하며(성능), 실수를 방지합니다(안정성). 코드를 단계별로 나누어 보겠습니다.

첫 번째로, kernel_code는 커널 코드 영역에 최적화된 플래그를 반환합니다. WRITABLE을 끄고 NO_EXECUTE도 끄며(실행 가능), GLOBAL을 켜서 모든 컨텍스트에서 TLB에 유지됩니다.

왜 이렇게 하는지는, 커널 코드는 수정 불가하고 모든 프로세스가 공유하므로 TLB를 유지하면 컨텍스트 스위치 시 성능이 향상되기 때문입니다. 그 다음으로, user_data는 사용자 프로세스의 데이터/스택에 사용됩니다.

내부에서는 WRITABLE과 USER_ACCESSIBLE을 설정하되, NO_EXECUTE를 반드시 포함하여 스택이나 힙에서 코드가 실행되지 못하게 합니다. 이는 ROP(Return-Oriented Programming) 공격 등을 크게 어렵게 만드는 중요한 보안 계층입니다.

GLOBAL을 끄므로 컨텍스트 스위치 시 TLB에서 자동으로 제거됩니다. 마지막으로, check_page_usage는 ACCESSED와 DIRTY 비트를 확인하여 페이지 사용 패턴을 추적합니다.

최종적으로 이 정보는 페이지 교체 알고리즘(LRU 등)에서 어느 페이지를 스왑아웃할지 결정하는 데 사용됩니다. 여러분이 이 코드를 사용하면 W^X(Write XOR Execute) 정책을 하드웨어 레벨에서 강제할 수 있고, TLB 효율을 극대화하여 성능을 향상시킬 수 있으며, 타입 시스템으로 잘못된 플래그 조합을 컴파일 타임에 방지할 수 있습니다.

실전 팁

💡 NO_EXECUTE(NX bit)는 CPU가 지원해야 하므로, CPUID로 확인하고 IA32_EFER.NXE 비트를 활성화하세요.

💡 GLOBAL 플래그는 커널 페이지에만 사용하고, 사용자 페이지에는 절대 설정하지 마세요.

💡 ACCESSED/DIRTY 비트는 CPU가 자동으로 설정하지만, OS가 수동으로 클리어해야 추적이 가능합니다.

💡 중간 테이블의 플래그는 최종 페이지보다 관대해야 하며, 최종 플래그의 AND 연산이 적용됩니다.

💡 W^X 정책(쓰기 가능하면 실행 불가)을 모든 페이지에 강제하면 대부분의 code injection 공격을 방어할 수 있습니다.


10. 페이지 폴트 핸들러 구현

시작하며

여러분이 잘못된 메모리 접근이나 demand paging을 처리해야 하는 상황을 겪어본 적 있나요? 페이지 폴트는 오류가 아니라 메모리 관리의 핵심 메커니즘으로, 적절히 처리하면 강력한 기능을 구현할 수 있습니다.

이런 페이지 폴트 처리는 OS 커널의 가장 빈번한 인터럽트 중 하나입니다. COW, demand paging, 메모리 압축, swap 등 현대 메모리 관리 기법이 모두 페이지 폴트에 기반합니다.

잘못 처리하면 시스템이 다운되거나 보안 취약점이 발생합니다. 바로 이럴 때 필요한 것이 robust한 페이지 폴트 핸들러입니다.

오류 코드를 정확히 해석하고 상황에 맞는 처리를 하면 유연한 메모리 관리 시스템을 만들 수 있습니다.

개요

간단히 말해서, 페이지 폴트 핸들러는 #PF 예외(인터럽트 14번)를 처리하는 함수로, CR2 레지스터에서 폴트 주소를 읽고 오류 코드로 원인을 파악합니다. Present, Write, User 비트로 폴트 유형을 판별합니다.

왜 이 핸들러가 필요한지 실무 관점에서 설명하면, 페이지 폴트는 단순 오류가 아닙니다. 예를 들어, malloc()으로 메모리를 할당받아도 실제 물리 메모리는 할당되지 않고, 처음 접근 시 페이지 폴트에서 할당됩니다(demand paging).

COW, swap in/out, memory-mapped files 모두 페이지 폴트로 구현됩니다. 기존의 즉시 할당 방식에서는 메모리 낭비가 심했다면, 이제는 페이지 폴트를 활용한 lazy allocation으로 실제 사용량만큼만 메모리를 사용할 수 있습니다.

이 핸들러의 핵심 특징은 첫째, 오류 코드로 폴트 원인을 정확히 판별하고, 둘째, 복구 가능한 폴트는 처리하고 그렇지 않으면 프로세스를 종료하며, 셋째, 멀티코어 환경에서도 안전하게 동작합니다. 이러한 특징들이 현대 OS의 메모리 관리를 유연하고 효율적으로 만듭니다.

코드 예제

use x86_64::structures::idt::PageFaultErrorCode;
use x86_64::{VirtAddr, registers::control::Cr2};

// 주석: 페이지 폴트 핸들러
pub extern "x86-interrupt" fn page_fault_handler(
    stack_frame: InterruptStackFrame,
    error_code: PageFaultErrorCode,
) {
    // 주석: CR2에서 폴트 발생 주소 읽기
    let fault_addr = Cr2::read();

    // 주석: 오류 코드로 폴트 유형 판별
    let present = error_code.contains(PageFaultErrorCode::CAUSED_BY_WRITE);
    let write = error_code.contains(PageFaultErrorCode::CAUSED_BY_WRITE);
    let user = error_code.contains(PageFaultErrorCode::USER_MODE);

    // 주석: COW 페이지 폴트 처리
    if write && !present {
        if let Some(entry) = lookup_page_entry(fault_addr) {
            if entry.flags().contains(COW_FLAG) {
                // COW 복사 수행
                if handle_cow_fault(fault_addr).is_ok() {
                    return;  // 복구 성공, 실행 재개
                }
            }
        }
    }

    // 주석: Demand paging 처리
    if !present && is_valid_vma(fault_addr) {
        // 주석: 새 페이지 할당 및 매핑
        if allocate_and_map(fault_addr).is_ok() {
            return;  // 복구 성공
        }
    }

    // 주석: 복구 불가능한 폴트 - 프로세스 종료
    println!("FATAL: Page fault at {:?}", fault_addr);
    println!("  present={}, write={}, user={}", present, write, user);
    loop {}  // 실제로는 프로세스 종료 또는 패닉
}

// 주석: VMA(Virtual Memory Area) 검증
fn is_valid_vma(addr: VirtAddr) -> bool {
    // 주석: 프로세스의 유효한 메모리 영역인지 확인
    // (힙, 스택, mmap 영역 등)
    // 실제 구현에서는 VMA 리스트를 검색
    true  // 예시
}

설명

이것이 하는 일: CPU가 페이지 폴트를 발생시키면 인터럽트를 통해 이 핸들러를 호출하고, CR2 레지스터와 오류 코드를 분석하여 COW, demand paging, 잘못된 접근 등을 구별하고 적절히 처리합니다. 코드를 단계별로 나누어 보겠습니다.

첫 번째로, CR2 레지스터에서 폴트를 일으킨 가상 주소를 읽고, 오류 코드에서 비트 플래그를 추출합니다. PRESENT 비트가 0이면 페이지가 매핑되지 않음, WRITE 비트가 1이면 쓰기 시도, USER 비트가 1이면 사용자 모드에서 발생한 것입니다.

왜 이렇게 하는지는, 동일한 폴트 주소라도 원인에 따라 전혀 다른 처리가 필요하기 때문입니다. 그 다음으로, COW 페이지 폴트인지 검사합니다.

내부에서는 쓰기 시도이면서 페이지가 존재하지만 WRITABLE=0이고 COW_FLAG가 설정된 경우를 감지하여, handle_cow_fault로 페이지를 복사합니다. 복사가 성공하면 핸들러에서 반환하고 CPU가 쓰기 명령을 재실행하여 이번에는 성공하게 됩니다.

마지막으로, demand paging을 처리합니다. 최종적으로 페이지가 없지만 유효한 메모리 영역(VMA)이면 새 페이지를 할당하고 매핑합니다.

어떤 처리도 성공하지 못하면 잘못된 메모리 접근으로 판단하여 프로세스를 종료하거나 커널 패닉을 발생시킵니다. 여러분이 이 코드를 사용하면 메모리를 lazy하게 할당하여 오버커밋을 활성화할 수 있고, COW로 fork를 최적화할 수 있으며, swap과 memory-mapped files를 투명하게 지원할 수 있습니다.

실전 팁

💡 페이지 폴트는 매우 빈번하므로, 핸들러는 가능한 한 빨리 실행되어야 하며 불필요한 동기화는 피하세요.

💡 커널 모드에서 발생한 폴트(USER=0)는 일반적으로 버그이므로, 패닉하거나 상세한 디버그 정보를 출력하세요.

💡 recursive 페이지 폴트를 방지하기 위해, 핸들러 스택은 항상 매핑되어 있어야 합니다(IST 사용).

💡 멀티코어 환경에서는 같은 페이지에 동시 폴트가 발생할 수 있으므로, 페이지 할당 시 락을 사용하세요.

💡 성능 분석을 위해 페이지 폴트 카운터를 유지하고, /proc/self/stat 같은 인터페이스로 노출하세요.


11. 페이지 테이블 동기화 및 TLB Shootdown

시작하며

여러분이 멀티코어 시스템에서 페이지 테이블을 수정한 후 다른 CPU의 캐시를 무효화해야 하는 상황을 겪어본 적 있나요? 한 코어에서 페이지를 언매핑해도 다른 코어의 TLB에는 여전히 남아있어 해제된 메모리에 접근하는 버그가 발생합니다.

이런 동기화 문제는 멀티코어 환경에서 피할 수 없는 도전과제입니다. 페이지 테이블은 메모리에서 공유되지만 TLB는 각 CPU마다 별도 캐시이므로, 명시적으로 동기화하지 않으면 stale 데이터를 참조하게 됩니다.

바로 이럴 때 필요한 것이 TLB Shootdown입니다. IPI(Inter-Processor Interrupt)로 다른 CPU들에게 신호를 보내 TLB를 무효화시키는 프로토콜입니다.

개요

간단히 말해서, TLB Shootdown은 페이지 테이블 변경 후 모든 CPU의 TLB를 동기화하는 메커니즘입니다. IPI로 다른 CPU를 인터럽트하고, 해당 CPU들이 invlpg 또는 CR3 재로드로 TLB를 무효화합니다.

왜 이 메커니즘이 필요한지 실무 관점에서 설명하면, TLB는 성능을 위한 캐시이므로 페이지 테이블 변경 시 자동으로 무효화되지 않습니다. 예를 들어, CPU 0이 페이지를 언매핑하고 물리 메모리를 해제했는데, CPU 1의 TLB에 여전히 매핑이 남아있으면 use-after-free 버그가 발생하여 데이터 손상이나 보안 취약점이 생깁니다.

기존의 단일 코어 환경에서는 로컬 TLB만 무효화하면 충분했다면, 이제는 모든 CPU를 동기화해야 하며 이는 상당한 오버헤드를 추가합니다. 이 메커니즘의 핵심 특징은 첫째, IPI로 다른 CPU에게 무효화를 요청하고, 둘째, 배치 처리로 여러 페이지를 한 번에 무효화하며, 셋째, acknowledgement를 기다려 완료를 보장합니다.

이러한 특징들이 멀티코어 시스템의 메모리 일관성을 유지합니다.

코드 예제

use x86_64::{VirtAddr, instructions::tlb};
use core::sync::atomic::{AtomicU32, Ordering};

static SHOOTDOWN_PENDING: AtomicU32 = AtomicU32::new(0);
static SHOOTDOWN_ADDR: AtomicU64 = AtomicU64::new(0);

// 주석: 모든 CPU에 TLB 무효화 요청
pub fn tlb_shootdown_global(addr: VirtAddr) {
    let cpu_count = get_cpu_count();
    let current_cpu = get_current_cpu_id();

    // 주석: 무효화할 주소 설정
    SHOOTDOWN_ADDR.store(addr.as_u64(), Ordering::Release);

    // 주석: 대기 중인 CPU 수 설정
    SHOOTDOWN_PENDING.store(cpu_count - 1, Ordering::Release);

    // 주석: 다른 모든 CPU에 IPI 전송
    for cpu_id in 0..cpu_count {
        if cpu_id != current_cpu {
            send_ipi(cpu_id, IPI_TLB_SHOOTDOWN);
        }
    }

    // 주석: 로컬 TLB 즉시 무효화
    tlb::flush(addr);

    // 주석: 모든 CPU가 완료할 때까지 대기
    while SHOOTDOWN_PENDING.load(Ordering::Acquire) > 0 {
        core::hint::spin_loop();  // 스핀락
    }
}

// 주석: IPI 핸들러 (다른 CPU에서 실행됨)
pub extern "x86-interrupt" fn tlb_shootdown_handler(
    _stack_frame: InterruptStackFrame
) {
    // 주석: 무효화할 주소 읽기
    let addr = VirtAddr::new(SHOOTDOWN_ADDR.load(Ordering::Acquire));

    // 주석: 로컬 TLB 무효화
    tlb::flush(addr);

    // 주석: 완료 신호 (카운터 감소)
    SHOOTDOWN_PENDING.fetch_sub(1, Ordering::Release);

    // 주석: EOI (End of Interrupt) 전송
    unsafe { apic::send_eoi(); }
}

설명

이것이 하는 일: 한 CPU가 페이지 테이블을 수정한 후 다른 모든 CPU에게 IPI를 전송하여 TLB를 무효화하도록 하고, 모든 CPU가 완료할 때까지 기다려 stale 캐시 접근을 방지합니다. 코드를 단계별로 나누어 보겠습니다.

첫 번째로, tlb_shootdown_global이 무효화할 주소를 공유 변수에 저장하고 대기 카운터를 설정합니다. Atomic 연산과 memory ordering으로 다른 CPU에게 변경사항이 즉시 보이도록 합니다.

왜 이렇게 하는지는, IPI 핸들러가 비동기적으로 실행되므로 데이터 레이스를 방지해야 하기 때문입니다. 그 다음으로, for 루프로 다른 모든 CPU에게 IPI를 전송합니다.

내부에서는 APIC(Advanced Programmable Interrupt Controller)의 ICR 레지스터에 대상 CPU와 인터럽트 벡터를 쓰는데, 하드웨어가 자동으로 다른 CPU를 인터럽트합니다. 동시에 자신의 TLB도 즉시 무효화하여 시간을 절약합니다.

마지막으로, 스핀락으로 모든 CPU가 무효화를 완료할 때까지 대기합니다. 최종적으로 카운터가 0이 되면 모든 CPU의 TLB가 일관된 상태임을 보장하고, 안전하게 물리 메모리를 해제하거나 다른 용도로 재사용할 수 있습니다.

여러분이 이 코드를 사용하면 멀티코어 환경에서도 안전하게 페이지 테이블을 수정할 수 있고, use-after-free 같은 미묘한 버그를 방지할 수 있으며, 배치 shootdown으로 여러 페이지를 효율적으로 처리할 수 있습니다.

실전 팁

💡 shootdown은 비용이 크므로(수십 마이크로초), 가능하면 여러 페이지를 배치로 처리하여 IPI 횟수를 줄이세요.

💡 전역 무효화가 필요한 경우 CR3 재로드가 invlpg 반복보다 빠를 수 있습니다(페이지 수에 따라).

💡 PCID(Process Context ID) 기능을 사용하면 컨텍스트 스위치 시 TLB를 보존할 수 있어 shootdown 빈도를 줄입니다.

💡 비동기 shootdown을 구현하면(나중에 확인) 대기 시간을 줄일 수 있지만, 메모리 해제 시점을 신중히 관리해야 합니다.

💡 shootdown 빈도를 성능 카운터로 추적하고, 병목이 되면 페이지 테이블 수정 패턴을 최적화하세요.


12. PCID(Process Context Identifier) 최적화

시작하며

여러분이 프로세스 컨텍스트 스위치 시 TLB를 매번 초기화해서 성능이 저하되는 상황을 겪어본 적 있나요? 전통적으로는 CR3를 변경하면 TLB 전체가 플러시되어 다시 워밍업하는 데 시간이 걸립니다.

이런 TLB 플러시는 컨텍스트 스위치 오버헤드의 큰 부분을 차지합니다. 특히 마이크로서비스나 컨테이너 환경에서는 컨텍스트 스위치가 매우 빈번하므로, TLB 미스 비용이 누적됩니다.

바로 이럴 때 필요한 것이 PCID입니다. 각 프로세스에 12비트 ID를 부여하고 TLB 엔트리에 태그를 달아, 여러 프로세스의 TLB 엔트리를 동시에 유지할 수 있습니다.

개요

간단히 말해서, PCID는 CR3의 하위 12비트에 프로세스 ID를 저장하여 TLB 엔트리를 프로세스별로 구분하는 기능입니다. CR4.PCIDE를 활성화하면 CR3 변경 시에도 TLB가 보존됩니다.

왜 이 최적화가 필요한지 실무 관점에서 설명하면, 컨텍스트 스위치는 초당 수천~수만 번 발생할 수 있습니다. 예를 들어, 고빈도 트레이딩 시스템이나 실시간 게임 서버에서는 TLB 플러시가 지연시간을 수 마이크로초 증가시킬 수 있습니다.

PCID를 사용하면 이전 프로세스의 TLB 엔트리가 보존되어 다시 스케줄링될 때 즉시 히트합니다. 기존의 전역 TLB 플러시 방식에서는 컨텍스트 스위치마다 콜드 캐시를 겪었다면, 이제는 프로세스별 TLB를 유지하여 워밍업 오버헤드를 제거할 수 있습니다.

이 최적화의 핵심 특징은 첫째, 컨텍스트 스위치 시 TLB를 보존하고, 둘째, 최대 4096개 프로세스를 TLB에서 동시 지원하며, 셋째, 선택적 무효화로 특정 PCID만 플러시할 수 있습니다. 이러한 특징들이 현대 고성능 시스템의 필수 기능이 됩니다.

코드 예제

use x86_64::registers::control::{Cr3, Cr3Flags, Cr4, Cr4Flags};
use x86_64::{PhysFrame, structures::paging::PhysFrame};

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

// 주석: PCID를 포함한 CR3 설정
pub unsafe fn set_page_table_with_pcid(
    frame: PhysFrame,
    pcid: u16,  // 0-4095
) {
    // 주석: CR3 하위 12비트에 PCID 저장
    let mut cr3_value = frame.start_address().as_u64();
    cr3_value |= (pcid as u64) & 0xFFF;

    // 주석: 비트 63=0이면 TLB 유지, 1이면 플러시
    // cr3_value |= 1 << 63;  // 플러시하려면 활성화

    Cr3::write_raw(cr3_value);
}

// 주석: 특정 PCID의 TLB만 무효화
pub unsafe fn invalidate_pcid(pcid: u16) {
    use x86_64::instructions::tlb;

    // 주석: INVPCID 명령어 사용 (지원되는 경우)
    if is_invpcid_supported() {
        // Type 1: 특정 PCID의 모든 엔트리 무효화
        invpcid(1, pcid, 0);
    } else {
        // 주석: 폴백: CR3 재로드로 전역 플러시
        let (frame, _) = Cr3::read();
        Cr3::write(frame, Cr3Flags::empty());
    }
}

// 주석: PCID 할당 관리
pub struct PcidAllocator {
    next_pcid: AtomicU16,
}

impl PcidAllocator {
    pub fn new() -> Self {
        Self { next_pcid: AtomicU16::new(1) }  // 0은 예약
    }

    // 주석: 새 프로세스에 PCID 할당
    pub fn allocate(&self) -> Option<u16> {
        let pcid = self.next_pcid.fetch_add(1, Ordering::Relaxed);
        if pcid < 4096 {
            Some(pcid)
        } else {
            None  // PCID 고갈, 재사용 로직 필요
        }
    }
}

설명

이것이 하는 일: CR4.PCIDE를 활성화하고 CR3의 하위 12비트에 프로세스별 고유 ID를 저장하여, TLB 엔트리를 프로세스별로 태그하고 컨텍스트 스위치 시에도 여러 프로세스의 TLB를 동시에 유지합니다. 코드를 단계별로 나누어 보겠습니다.

첫 번째로, enable_pcid가 CR4의 PCIDE 비트를 설정하여 기능을 활성화합니다. 이 비트를 켜면 CR3의 의미가 바뀌어 하위 12비트가 PCID로 해석됩니다.

왜 이렇게 하는지는, PCID 없이는 CR3를 변경하면 자동으로 전체 TLB가 플러시되지만, PCID가 있으면 태그로 구분되어 보존되기 때문입니다. 그 다음으로, set_page_table_with_pcid가 페이지 테이블 주소와 PCID를 결합하여 CR3에 씁니다.

내부에서는 물리 주소(4KB 정렬이므로 하위 12비트 0)와 PCID를 OR 연산으로 합칩니다. CR3의 비트 63은 TLB 플러시 제어로, 0이면 TLB 유지, 1이면 해당 PCID의 TLB를 플러시하므로 선택적 제어가 가능합니다.

마지막으로, invalidate_pcid가 특정 PCID의 TLB만 무효화합니다. 최종적으로 INVPCID 명령어(더 새로운 CPU)를 사용하거나 폴백으로 전역 플러시를 수행하여, 해당 프로세스가 종료되거나 페이지 테이블이 크게 변경될 때 정리합니다.

여러분이 이 코드를 사용하면 컨텍스트 스위치 비용을 10-30% 감소시킬 수 있고, 실시간 시스템의 지연시간을 줄일 수 있으며, CPU 집약적 워크로드에서 TLB 미스율을 크게 낮출 수 있습니다.

실전 팁

💡 PCID는 CPUID로 지원 여부를 확인하고, 지원하지 않으면 전통적 방식으로 폴백하세요.

💡 PCID 0은 일반적으로 커널 전용으로 예약하고, 사용자 프로세스는 1-4095를 사용하세요.

💡 4096개 이상의 프로세스가 있으면 PCID를 재사용해야 하므로, LRU 정책으로 관리하세요.

💡 INVPCID 명령어는 여러 타입을 지원하며, 타입 2는 모든 PCID의 특정 주소를 무효화합니다.

💡 벤치마크로 실제 이득을 측정하세요. 프로세스가 많고 컨텍스트 스위치가 빈번할수록 PCID의 효과가 큽니다.


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

댓글 (0)

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