이미지 로딩 중...

Rust OS 개발 페이지 폴트 핸들링 완벽 가이드 - 슬라이드 1/11
A

AI Generated

2025. 11. 14. · 2 Views

Rust OS 개발 페이지 폴트 핸들링 완벽 가이드

운영체제의 메모리 관리 핵심인 페이지 폴트를 Rust로 구현하는 방법을 다룹니다. CPU 예외 처리부터 페이지 테이블 관리, 실제 핸들러 구현까지 OS 개발의 실전 기술을 배웁니다.


목차

  1. 페이지 폴트 예외 개요 - CPU가 보내는 메모리 접근 신호 이해하기
  2. 페이지 폴트 에러 코드 분석 - 예외 원인을 비트로 파악하기
  3. 페이지 테이블 워킹 - 가상 주소에서 물리 주소 찾기
  4. 새 페이지 할당 및 매핑 - 메모리 부재 시 동적 할당하기
  5. Copy-on-Write 구현 - 쓰기 시도 시 페이지 복사하기
  6. 스왑 인/아웃 처리 - 디스크와 메모리 간 페이지 이동
  7. 커널 페이지 폴트 처리 - 커널 모드 예외 안전하게 다루기
  8. Guard Page 구현 - 스택 오버플로우 조기 감지
  9. Lazy Allocation 전략 - 실제 사용 시점에 메모리 할당
  10. 디버깅 및 로깅 전략 - 페이지 폴트 문제 추적하기

1. 페이지 폴트 예외 개요 - CPU가 보내는 메모리 접근 신호 이해하기

시작하며

여러분이 OS를 개발하다가 갑자기 프로그램이 멈추거나 트리플 폴트가 발생한 경험 있으신가요? 메모리에 접근했는데 해당 페이지가 물리 메모리에 없거나, 권한이 없는 영역에 접근하려 할 때 CPU는 페이지 폴트 예외를 발생시킵니다.

이런 문제는 모든 운영체제가 반드시 처리해야 하는 핵심 메커니즘입니다. 페이지 폴트를 제대로 처리하지 못하면 시스템 전체가 불안정해지고, 메모리 관리 기능을 구현할 수 없습니다.

바로 이럴 때 필요한 것이 페이지 폴트 핸들러입니다. CPU의 예외 신호를 받아 적절히 대응함으로써 가상 메모리, lazy allocation, copy-on-write 같은 고급 메모리 관리 기능을 구현할 수 있습니다.

개요

간단히 말해서, 페이지 폴트는 CPU가 발생시키는 14번 예외로, 메모리 접근이 실패했을 때 운영체제에 알리는 신호입니다. 실무 OS 개발에서 페이지 폴트는 단순한 에러가 아닙니다.

이는 demand paging(필요할 때만 페이지 로드), swapping(디스크로 메모리 교체), 메모리 보호 등을 구현하는 핵심 메커니즘입니다. 예를 들어, 리눅스나 윈도우에서 실행 파일을 실행할 때 전체를 메모리에 로드하지 않고, 실제 사용되는 페이지만 페이지 폴트를 통해 로드합니다.

기존에는 모든 메모리를 미리 할당했다면, 페이지 폴트 핸들링을 통해 필요한 순간에만 메모리를 할당할 수 있습니다. 페이지 폴트의 핵심 특징은 세 가지입니다: (1) CR2 레지스터에 접근하려던 주소 저장, (2) 에러 코드를 통한 원인 파악(읽기/쓰기, 권한 위반 등), (3) recoverable 예외로 처리 후 복귀 가능.

이러한 특징들이 유연한 메모리 관리 정책을 구현할 수 있게 해줍니다.

코드 예제

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

// IDT에 페이지 폴트 핸들러 등록
pub fn init_idt(idt: &mut InterruptDescriptorTable) {
    // 14번 예외를 핸들러에 연결
    idt.page_fault.set_handler_fn(page_fault_handler);
}

// 페이지 폴트 핸들러 함수
extern "x86-interrupt" fn page_fault_handler(
    stack_frame: InterruptStackFrame,
    error_code: PageFaultErrorCode,
) {
    // CR2 레지스터에서 접근하려던 주소 읽기
    let fault_addr = Cr2::read();
    println!("PAGE FAULT at {:?}", fault_addr);
    println!("Error Code: {:?}", error_code);
    panic!("Page fault occurred!");
}

설명

이것이 하는 일: 페이지 폴트가 발생했을 때 CPU가 자동으로 호출하는 핸들러를 IDT(Interrupt Descriptor Table)에 등록하여, 메모리 접근 문제를 OS가 감지하고 대응할 수 있게 합니다. 첫 번째로, init_idt 함수에서 IDT의 page_fault 항목에 우리의 핸들러 함수를 설정합니다.

x86_64 아키텍처에서는 14번 벡터가 페이지 폴트 전용이므로, CPU가 페이지 폴트를 감지하면 자동으로 이 핸들러를 호출합니다. 이렇게 하는 이유는 CPU와 OS 간의 명확한 계약을 만들기 위해서입니다.

그 다음으로, page_fault_handler 함수가 실행되면서 두 가지 중요한 정보를 받습니다. InterruptStackFrame은 예외 발생 시점의 CPU 상태(명령어 포인터, 스택 포인터 등)를 담고 있고, PageFaultErrorCode는 왜 페이지 폴트가 발생했는지 비트 플래그로 알려줍니다(PRESENT 비트: 페이지 부재, WRITE 비트: 쓰기 시도, USER 비트: 유저 모드 접근 등).

내부에서는 이 정보들을 기반으로 적절한 처리 방법을 결정합니다. 마지막으로, Cr2::read()를 통해 접근하려던 가상 주소를 읽어옵니다.

CR2는 특수 제어 레지스터로, CPU가 페이지 폴트 발생 시 자동으로 문제의 주소를 저장합니다. 현재 코드에서는 panic으로 종료하지만, 실제 OS에서는 이 주소를 보고 (1) 페이지 할당, (2) 스왑 인, (3) 프로세스 종료 등을 결정합니다.

여러분이 이 코드를 사용하면 메모리 접근 문제를 즉시 감지할 수 있고, 디버깅이 훨씬 쉬워집니다. 실무에서는 페이지 폴트 정보를 로깅하여 메모리 접근 패턴 분석, 성능 최적화, 보안 취약점 발견 등에 활용할 수 있습니다.

실전 팁

💡 error_code.contains(PageFaultErrorCode::PRESENT)로 페이지가 없는 건지, 권한 문제인지 구분하세요. PRESENT가 설정되지 않았다면 페이지 할당이 필요하고, 설정되었다면 권한 위반입니다.

💡 페이지 폴트 핸들러 내에서 또 다른 페이지 폴트가 발생하면 더블 폴트가 됩니다. 핸들러에서 사용하는 모든 메모리(스택, 전역 변수 등)는 미리 매핑되어 있어야 합니다.

💡 CR2 레지스터는 다음 페이지 폴트가 발생하면 덮어써지므로, 핸들러 시작 부분에서 즉시 읽어 로컬 변수에 저장하세요.

💡 x86-interrupt calling convention은 필수입니다. 일반 함수로 만들면 스택 정렬이 잘못되어 트리플 폴트가 발생합니다.

💡 실제 OS에서는 페이지 폴트 발생 빈도를 추적하여 성능 병목을 찾으세요. 과도한 페이지 폴트는 thrashing의 신호일 수 있습니다.


2. 페이지 폴트 에러 코드 분석 - 예외 원인을 비트로 파악하기

시작하며

여러분이 페이지 폴트를 처리하려는데, 단순히 "페이지 폴트 발생"이라는 정보만으로는 어떻게 대응해야 할지 막막하셨죠? CPU는 단순히 예외만 발생시키는 게 아니라, 5비트의 에러 코드를 통해 구체적인 원인을 알려줍니다.

이 정보 없이는 모든 페이지 폴트를 똑같이 처리할 수밖에 없어, 보안 취약점이 생기거나 불필요한 페이지 할당이 발생합니다. 예를 들어, 읽기 전용 페이지에 쓰기를 시도한 것과 아예 매핑되지 않은 주소를 접근한 것은 전혀 다른 대응이 필요합니다.

바로 이럴 때 필요한 것이 PageFaultErrorCode 분석입니다. 에러 코드의 각 비트를 해석하여 정확한 원인을 파악하고, 상황에 맞는 최적의 처리를 할 수 있습니다.

개요

간단히 말해서, PageFaultErrorCode는 CPU가 스택에 푸시하는 32비트 값으로, 하위 5비트가 페이지 폴트의 구체적인 원인을 나타냅니다. 비트별로 다른 의미를 갖습니다: PRESENT(bit 0)는 페이지가 메모리에 있는지, WRITE(bit 1)는 쓰기 접근인지, USER(bit 2)는 유저 모드 접근인지, RESERVED_WRITE(bit 3)는 예약된 비트를 건드렸는지, INSTRUCTION_FETCH(bit 4)는 명령어 패치 중인지를 알려줍니다.

예를 들어, copy-on-write 구현 시 WRITE 비트를 확인하여 읽기는 허용하고 쓰기만 새 페이지를 할당하는 식으로 최적화할 수 있습니다. 기존에는 모든 페이지 폴트를 치명적 오류로 처리했다면, 에러 코드 분석을 통해 복구 가능한 상황과 진짜 오류를 구분할 수 있습니다.

핵심 특징은: (1) 비트 플래그로 여러 조건 동시 표현 가능, (2) CPU가 자동으로 생성하여 핸들러에 전달, (3) x86_64 crate의 타입 안전한 래퍼로 쉽게 분석 가능. 이를 통해 정교한 메모리 관리 정책을 구현할 수 있습니다.

코드 예제

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

extern "x86-interrupt" fn page_fault_handler(
    stack_frame: InterruptStackFrame,
    error_code: PageFaultErrorCode,
) {
    let fault_addr = Cr2::read();

    // PRESENT 비트 확인: 페이지 부재 vs 권한 위반
    if !error_code.contains(PageFaultErrorCode::PRESENT) {
        // 페이지가 메모리에 없음 - 할당 필요
        handle_page_not_present(fault_addr);
    } else if error_code.contains(PageFaultErrorCode::WRITE) {
        // 쓰기 시도로 인한 폴트 - copy-on-write 가능
        handle_write_protection(fault_addr);
    } else if error_code.contains(PageFaultErrorCode::USER) {
        // 유저 모드에서 커널 페이지 접근 시도 - 보안 위반
        handle_privilege_violation(fault_addr);
    } else if error_code.contains(PageFaultErrorCode::INSTRUCTION_FETCH) {
        // 실행 불가 페이지에서 명령어 패치 - NX 비트 위반
        handle_execute_violation(fault_addr);
    }
}

설명

이것이 하는 일: 에러 코드의 각 비트를 검사하여 페이지 폴트가 왜 발생했는지 파악하고, 원인에 따라 다른 처리 함수를 호출하여 최적의 대응을 합니다. 첫 번째로, PRESENT 비트를 확인합니다.

이 비트가 0이면 페이지 테이블 엔트리가 없거나 PRESENT 플래그가 꺼진 상태로, 새로운 페이지를 할당하거나 스왑 인해야 합니다. 이것이 가장 흔한 경우이며, demand paging의 핵심입니다.

반대로 1이면 페이지는 존재하지만 다른 이유(권한, 예약 비트 등)로 실패한 것입니다. 그 다음으로, WRITE 비트를 검사합니다.

이 비트가 1이면 쓰기 시도로 인한 폴트입니다. 읽기 전용 페이지에 쓰려고 하거나, copy-on-write로 표시된 페이지를 수정하려는 경우입니다.

handle_write_protection 함수에서는 페이지가 공유 중이면 새 복사본을 만들고, 아니면 권한 오류로 처리합니다. 0이면 읽기 또는 실행 시도입니다.

세 번째로, USER 비트로 접근 모드를 확인합니다. 1이면 유저 모드(ring 3)에서 발생했고, 0이면 커널 모드(ring 0)에서 발생했습니다.

유저 모드에서 커널 전용 페이지를 접근하려 하면 보안 위반이므로 프로세스를 종료해야 합니다. 이는 KASLR이나 커널 메모리 보호에 중요합니다.

마지막으로, INSTRUCTION_FETCH 비트는 최신 CPU의 NX(No-Execute) 기능과 관련됩니다. 1이면 데이터 페이지에서 코드를 실행하려 한 것으로, 버퍼 오버플로우 공격을 막기 위해 거부해야 합니다.

스택이나 힙에서 코드 실행을 시도하는 전형적인 공격 패턴입니다. 여러분이 이 분석을 통해 각 상황에 최적화된 처리를 할 수 있습니다.

불필요한 메모리 할당을 줄이고, 보안을 강화하며, copy-on-write 같은 고급 기능을 구현할 수 있습니다. 실무에서는 에러 코드 통계를 수집하여 메모리 사용 패턴을 분석하고 성능을 개선합니다.

실전 팁

💡 contains() 메서드 대신 비트 연산으로 여러 조건을 동시에 체크할 수 있습니다: error_code.bits() & 0b00011로 PRESENT와 WRITE를 한 번에 확인하세요.

💡 RESERVED_WRITE 비트가 설정되면 페이지 테이블 구조가 손상된 것이므로 즉시 panic해야 합니다. 이는 복구 불가능한 치명적 오류입니다.

💡 에러 코드와 CR2 주소를 함께 로깅하면 메모리 버그를 추적하기 쉽습니다. 특히 같은 주소에서 반복되는 폴트 패턴은 버그의 신호입니다.

💡 WRITE 비트 없이 PRESENT가 설정된 경우는 읽기 시도인데, 이는 스왑 아웃된 페이지를 다시 로드하는 상황일 수 있습니다.

💡 실제 OS에서는 각 비트 조합별로 카운터를 두어 통계를 수집하세요. 예상치 못한 패턴이 나타나면 버그나 공격의 징후일 수 있습니다.


3. 페이지 테이블 워킹 - 가상 주소에서 물리 주소 찾기

시작하며

여러분이 페이지 폴트를 처리하려는데, 해당 가상 주소가 이미 매핑되어 있는지, 어떤 권한으로 설정되어 있는지 확인하고 싶으셨죠? 단순히 CR2 주소만 알고는 현재 페이지 테이블 상태를 파악할 수 없습니다.

이 문제는 페이지 폴트 핸들러가 지능적으로 동작하는 데 필수적입니다. 이미 매핑된 페이지라면 권한 문제일 것이고, 매핑되지 않았다면 새로 할당해야 합니다.

잘못 판단하면 중복 매핑이나 메모리 누수가 발생합니다. 바로 이럴 때 필요한 것이 페이지 테이블 워킹입니다.

4단계 페이지 테이블을 순회하여 가상 주소의 현재 상태를 정확히 파악할 수 있습니다.

개요

간단히 말해서, 페이지 테이블 워킹은 가상 주소를 입력받아 PML4 → PDPT → PD → PT의 4단계 테이블을 순회하며, 최종 페이지 테이블 엔트리를 찾아내는 과정입니다. x86_64 아키텍처에서는 48비트 가상 주소를 사용하며, 상위 비트들을 9비트씩 나누어 각 단계의 인덱스로 사용합니다.

각 단계의 테이블 엔트리는 다음 단계 테이블의 물리 주소를 가리키고, 최종 PT 엔트리가 실제 물리 페이지를 가리킵니다. 예를 들어, 페이지 폴트 핸들러에서 워킹을 통해 엔트리가 없으면 새 페이지 할당, 있으면 플래그만 수정하는 식으로 판단할 수 있습니다.

기존에는 모든 주소를 새로 매핑했다면, 워킹을 통해 기존 매핑을 재사용하거나 점진적으로 업데이트할 수 있습니다. 핵심 특징: (1) 재귀적 매핑을 통한 페이지 테이블 자체 접근, (2) 각 단계에서 PRESENT 플래그 확인으로 조기 종료, (3) 엔트리의 플래그를 통한 권한 및 속성 확인.

이를 통해 메모리 상태를 정확히 파악하고 올바른 결정을 내릴 수 있습니다.

코드 예제

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

// 가상 주소로 페이지 테이블 엔트리 찾기
fn walk_page_table(addr: VirtAddr) -> Option<&'static mut PageTableEntry> {
    // CR3에서 PML4 테이블 주소 가져오기
    let (level_4_table_frame, _) = Cr3::read();
    let phys = level_4_table_frame.start_address();
    let virt = phys_to_virt(phys); // 물리 주소를 가상 주소로 변환
    let level_4_table: &PageTable = unsafe { &*(virt.as_ptr()) };

    // 가상 주소를 인덱스로 분해
    let p4_index = addr.p4_index();
    let p3_index = addr.p3_index();
    let p2_index = addr.p2_index();
    let p1_index = addr.p1_index();

    // 4단계 순회
    let level_3_entry = &level_4_table[p4_index];
    if !level_3_entry.flags().contains(PageTableFlags::PRESENT) {
        return None; // 매핑 안 됨
    }

    // 2단계, 3단계도 동일하게 순회...
    let level_1_table = get_next_table(level_3_entry);
    Some(&mut level_1_table[p1_index])
}

설명

이것이 하는 일: 가상 주소를 받아 CR3 레지스터가 가리키는 최상위 페이지 테이블부터 시작하여, 4단계를 차례로 내려가며 최종 페이지 엔트리를 찾아냅니다. 각 단계에서 PRESENT 플래그를 확인하여 매핑 여부를 검증합니다.

첫 번째로, Cr3::read()로 현재 활성화된 PML4 테이블의 물리 주소를 읽습니다. CR3는 CPU의 제어 레지스터로, 현재 프로세스의 주소 공간 루트를 가리킵니다.

이 물리 주소를 가상 주소로 변환해야 접근할 수 있는데, 이를 위해 identity mapping이나 재귀 매핑을 사용합니다. phys_to_virt 함수는 프로젝트의 메모리 레이아웃에 따라 구현됩니다.

그 다음으로, 가상 주소의 각 비트 그룹을 인덱스로 추출합니다. x86_64에서 48비트 주소는 [47:39] PML4 인덱스, [38:30] PDPT 인덱스, [29:21] PD 인덱스, [20:12] PT 인덱스, [11:0] 페이지 내 오프셋으로 나뉩니다.

p4_index() 같은 메서드들이 이 비트 연산을 추상화해줍니다. 각 인덱스는 0~511 범위이며, 테이블 배열의 인덱스로 직접 사용됩니다.

세 번째로, 각 단계에서 엔트리를 읽고 PRESENT 플래그를 확인합니다. 플래그가 설정되어 있지 않으면 해당 경로의 페이지가 매핑되지 않은 것이므로 None을 반환합니다.

PRESENT가 있으면 엔트리에서 다음 단계 테이블의 물리 주소를 추출하고, 다시 가상 주소로 변환하여 접근합니다. 이 과정을 4단계 모두 반복합니다.

마지막으로, 최종 PT 엔트리에 도달하면 해당 엔트리의 mutable reference를 반환합니다. 이 엔트리를 통해 물리 주소를 얻거나, 플래그를 수정하거나, 새 매핑을 생성할 수 있습니다.

엔트리의 플래그에는 WRITABLE, USER_ACCESSIBLE, NO_EXECUTE 등이 있어 권한을 세밀하게 제어합니다. 여러분이 이 워킹 로직을 사용하면 페이지 폴트 핸들러에서 현재 상태를 정확히 파악할 수 있습니다.

중복 매핑을 방지하고, 기존 권한을 보존하면서 업데이트하며, 부분적으로 매핑된 경로를 완성할 수 있습니다. 실무에서는 TLB flush 최소화, huge page 지원, NUMA 고려 등 최적화 포인트가 많습니다.

실전 팁

💡 재귀 매핑(recursive mapping)을 설정하면 PML4의 마지막 엔트리가 자기 자신을 가리켜, 특정 가상 주소 범위로 모든 페이지 테이블에 접근할 수 있습니다.

💡 각 단계에서 HUGE_PAGE 플래그를 확인하세요. PD나 PDPT 레벨에서 이 플래그가 설정되면 2MB나 1GB 페이지이므로 더 이상 워킹하지 않아야 합니다.

💡 페이지 테이블 접근 자체가 페이지 폴트를 일으킬 수 있습니다. 핸들러에서 사용하는 페이지 테이블 매핑은 미리 고정되어 있어야 합니다.

💡 멀티코어 환경에서는 다른 CPU가 동시에 페이지 테이블을 수정할 수 있으므로, lock이나 atomic 연산을 사용해야 합니다.

💡 워킹 중간에 엔트리를 수정하면 TLB와 불일치가 생기므로, 수정 후 반드시 invlpg 명령으로 해당 페이지의 TLB를 무효화하세요.


4. 새 페이지 할당 및 매핑 - 메모리 부재 시 동적 할당하기

시작하며

여러분이 페이지 폴트 핸들러에서 페이지가 없다는 것을 확인했는데, 이제 어떻게 새 페이지를 할당하고 매핑해야 할지 고민되셨나요? 단순히 물리 메모리를 할당하는 것만으로는 부족하고, 페이지 테이블에 올바르게 연결해야 CPU가 사용할 수 있습니다.

이 단계는 demand paging의 핵심으로, 실제로 필요한 순간까지 메모리 할당을 지연시켜 효율성을 높입니다. 잘못 구현하면 메모리 누수, 중복 매핑, 권한 오류 등이 발생하여 시스템 안정성이 크게 떨어집니다.

바로 이럴 때 필요한 것이 프레임 할당과 매핑 로직입니다. 물리 메모리를 할당받아 페이지 테이블 엔트리를 생성하고, 적절한 플래그를 설정하여 안전하게 사용 가능한 상태로 만듭니다.

개요

간단히 말해서, 페이지 할당은 물리 메모리 프레임을 획득하고, 가상 주소와 연결하는 페이지 테이블 엔트리를 생성하며, 접근 권한 플래그를 설정하는 3단계 과정입니다. 실무 OS에서는 프레임 할당자(frame allocator)가 사용 가능한 물리 메모리를 추적하고, 요청 시 프레임을 제공합니다.

비트맵, 링크드 리스트, 버디 시스템 등 다양한 알고리즘이 있습니다. 매핑 후에는 TLB를 업데이트하여 CPU가 새 매핑을 인식하게 해야 합니다.

예를 들어, 사용자 프로그램이 malloc을 호출하면 커널이 가상 주소만 예약하고, 실제 접근 시 페이지 폴트를 통해 이 할당 로직이 실행됩니다. 기존에는 프로그램 시작 시 모든 메모리를 할당했다면, demand paging을 통해 실제 사용하는 페이지만 할당하여 메모리 사용량을 크게 줄일 수 있습니다.

핵심 특징: (1) 프레임 할당자를 통한 물리 메모리 관리, (2) 페이지 테이블의 4단계 모두 생성(필요 시), (3) 플래그를 통한 세밀한 권한 제어(읽기, 쓰기, 실행). 이를 통해 유연하고 효율적인 메모리 관리가 가능합니다.

코드 예제

use x86_64::structures::paging::{Mapper, Page, PhysFrame, Size4KiB, FrameAllocator, PageTableFlags};

// 페이지 폴트 시 새 페이지 할당
fn allocate_page(
    page: Page,
    mapper: &mut impl Mapper<Size4KiB>,
    frame_allocator: &mut impl FrameAllocator<Size4KiB>,
) -> Result<(), MapToError<Size4KiB>> {
    // 프레임 할당자에서 새 물리 프레임 획득
    let frame = frame_allocator
        .allocate_frame()
        .ok_or(MapToError::FrameAllocationFailed)?;

    // 페이지 테이블 플래그 설정 (PRESENT | WRITABLE | USER_ACCESSIBLE)
    let flags = PageTableFlags::PRESENT
        | PageTableFlags::WRITABLE
        | PageTableFlags::USER_ACCESSIBLE;

    // 매핑 생성 (가상 페이지 -> 물리 프레임)
    unsafe {
        mapper.map_to(page, frame, flags, frame_allocator)?.flush();
    }

    Ok(())
}

설명

이것이 하는 일: 페이지 폴트가 발생한 가상 주소에 대해 새로운 물리 메모리 프레임을 할당받아 페이지 테이블에 매핑하고, CPU가 즉시 사용할 수 있도록 TLB를 갱신합니다. 첫 번째로, frame_allocator.allocate_frame()을 호출하여 사용 가능한 물리 프레임을 얻습니다.

프레임 할당자는 부팅 시 메모리 맵을 분석하여 사용 가능한 영역을 추적하고 있습니다. 할당이 실패하면(OOM) MapToError::FrameAllocationFailed를 반환하여 상위 레이어에서 처리하게 합니다.

프레임은 4KiB 크기이며, 물리 주소가 4KiB 정렬되어 있습니다. 그 다음으로, 페이지 테이블 플래그를 구성합니다.

PRESENT는 필수이며, 이것이 없으면 CPU가 여전히 페이지 폴트를 발생시킵니다. WRITABLE은 쓰기 권한을, USER_ACCESSIBLE은 유저 모드 접근을 허용합니다.

상황에 따라 NO_EXECUTE(데이터 페이지), WRITE_THROUGH(캐시 정책), NO_CACHE(MMIO) 등을 추가할 수 있습니다. 플래그 선택이 보안과 성능에 직접 영향을 미칩니다.

세 번째로, mapper.map_to()로 실제 매핑을 생성합니다. 이 함수는 내부적으로 페이지 테이블 워킹을 수행하며, 중간 단계 테이블이 없으면 새로 할당합니다(frame_allocator를 재사용).

4단계 모두 생성되면 최종 PT 엔트리에 프레임 주소와 플래그를 기록합니다. unsafe 블록이 필요한 이유는 잘못된 매핑이 메모리 안전성을 깨뜨릴 수 있기 때문입니다.

마지막으로, flush()를 호출하여 TLB를 갱신합니다. TLB는 페이지 테이블 조회를 캐싱하는 하드웨어 구조인데, 새 매핑을 만들어도 TLB가 업데이트되지 않으면 CPU는 여전히 "매핑 없음"으로 인식합니다.

flush()invlpg 명령을 실행하여 해당 페이지의 TLB 엔트리만 무효화하므로 효율적입니다. 이제 페이지 폴트 핸들러에서 복귀하면 같은 명령이 재실행되며 성공합니다.

여러분이 이 할당 로직을 구현하면 프로그램이 필요한 만큼만 메모리를 사용하게 됩니다. 대용량 배열을 선언해도 실제 접근한 부분만 물리 메모리를 차지하고, 나머지는 가상 주소 공간만 예약됩니다.

실무에서는 사전 할당(pre-fault), 제로 페이지 공유, huge page 사용 등으로 더욱 최적화합니다.

실전 팁

💡 새로 할당한 페이지의 내용은 임의의 값일 수 있으므로, 보안을 위해 0으로 초기화하세요. 이전 프로세스의 민감한 데이터가 노출될 수 있습니다.

💡 프레임 할당 실패 시 페이지 회수(page reclaim)를 시도하세요. LRU 같은 알고리즘으로 사용하지 않는 페이지를 디스크로 스왑 아웃하여 메모리를 확보합니다.

💡 스택 페이지는 NO_EXECUTE 플래그를 설정하여 스택 실행 공격을 방지하세요. 코드 페이지는 WRITABLE을 제거하여 코드 변조를 막습니다.

💡 멀티코어에서 매핑 생성 후 다른 CPU도 TLB flush가 필요할 수 있습니다. IPI(Inter-Processor Interrupt)로 모든 코어에 shootdown을 전송하세요.

💡 연속된 여러 페이지를 매핑할 때는 루프로 하나씩 하지 말고, batch 매핑 API를 사용하여 TLB flush를 한 번만 하세요.


5. Copy-on-Write 구현 - 쓰기 시도 시 페이지 복사하기

시작하며

여러분이 fork() 시스템 콜을 구현하려는데, 부모 프로세스의 메모리를 자식에게 모두 복사하면 너무 느리고 메모리 낭비가 심하다는 걸 느끼셨나요? 대부분의 페이지는 읽기만 하거나 아예 사용하지 않는데, 미리 복사하는 것은 비효율적입니다.

이 문제는 프로세스 생성 성능을 크게 저하시키고, 메모리 부족 상황을 악화시킵니다. 리눅스나 BSD 같은 실제 OS들은 fork 시 수십 GB의 메모리를 즉시 복사하지 않습니다.

바로 이럴 때 필요한 것이 Copy-on-Write(COW)입니다. 처음에는 페이지를 공유하고 읽기 전용으로 표시한 뒤, 실제 쓰기 시도 시에만 페이지 폴트를 통해 복사하여 메모리와 시간을 절약합니다.

개요

간단히 말해서, COW는 여러 프로세스가 같은 물리 페이지를 읽기 전용으로 공유하다가, 어느 한쪽이 쓰기를 시도하면 페이지 폴트가 발생하고, 핸들러에서 새 복사본을 만들어 쓰기를 허용하는 지연 복사 기법입니다. 실무에서 fork는 COW 없이는 사용할 수 없을 정도로 느립니다.

fork 직후 exec를 호출하는 패턴(대부분의 셸 명령 실행)에서는 복사한 메모리를 즉시 버리게 되므로 낭비입니다. COW를 사용하면 fork는 페이지 테이블만 복사하고 물리 페이지는 공유하여 밀리초 단위로 완료됩니다.

예를 들어, 10GB 메모리를 사용하는 데이터베이스 프로세스를 fork해도 실제 복사는 수정되는 몇 MB만 일어납니다. 기존에는 모든 페이지를 즉시 복사했다면, COW를 통해 필요한 순간까지 지연시켜 대부분의 경우 복사를 아예 피할 수 있습니다.

핵심 특징: (1) 페이지 테이블 엔트리에서 WRITABLE 플래그 제거로 구현, (2) 참조 카운트로 페이지 공유 추적, (3) 쓰기 페이지 폴트 시 on-demand 복사. 이를 통해 fork, snapshot, 메모리 효율화 등을 구현합니다.

코드 예제

use x86_64::structures::paging::{Page, PageTableFlags};

// COW 페이지 폴트 핸들러
fn handle_cow_page_fault(page: Page) -> Result<(), &'static str> {
    let entry = walk_page_table(page.start_address())
        .ok_or("Page not mapped")?;

    // COW 페이지인지 확인 (커스텀 플래그 또는 별도 메타데이터)
    if !is_cow_page(page) {
        return Err("Not a COW page");
    }

    // 현재 물리 프레임 얻기
    let old_frame = entry.frame().map_err(|_| "No frame")?;

    // 참조 카운트 확인
    if get_ref_count(old_frame) == 1 {
        // 마지막 참조자이므로 복사 불필요, 쓰기 권한만 추가
        entry.set_flags(entry.flags() | PageTableFlags::WRITABLE);
    } else {
        // 새 프레임 할당 및 데이터 복사
        let new_frame = allocate_frame()?;
        copy_frame(old_frame, new_frame);

        // 페이지 테이블 업데이트
        entry.set_frame(new_frame,
            entry.flags() | PageTableFlags::WRITABLE);

        // 기존 프레임 참조 카운트 감소
        dec_ref_count(old_frame);
    }

    flush_tlb(page);
    Ok(())
}

설명

이것이 하는 일: 쓰기 페이지 폴트가 발생하면 해당 페이지가 COW 페이지인지 확인하고, 참조 카운트에 따라 복사 또는 권한 변경만 수행하여 최소한의 메모리 복사로 쓰기를 가능하게 합니다. 첫 번째로, 페이지 테이블 워킹으로 해당 페이지의 엔트리를 찾고, COW 마킹을 확인합니다.

COW 여부는 사용하지 않는 페이지 테이블 비트를 활용하거나, 별도의 메타데이터 구조(HashMap 등)로 추적할 수 있습니다. COW가 아닌 페이지에 쓰기 폴트가 발생하면 권한 위반이므로 프로세스를 종료해야 합니다.

그 다음으로, 현재 물리 프레임의 참조 카운트를 확인합니다. 참조 카운트는 얼마나 많은 페이지 테이블 엔트리가 이 프레임을 가리키는지 추적합니다.

fork 시 부모와 자식 모두 같은 프레임을 가리키므로 카운트는 2입니다. 만약 카운트가 1이라면 다른 프로세스는 이미 자신의 복사본을 만들었거나 종료한 것이므로, 이 프로세스가 유일한 소유자입니다.

이 경우 복사 없이 WRITABLE 플래그만 추가하면 됩니다. 세 번째로, 참조 카운트가 2 이상이면 실제 복사가 필요합니다.

새 프레임을 할당하고 copy_frame으로 4KiB 데이터를 복사합니다. 이 복사는 페이지 크기가 작아 빠르게 완료되며, memcpy나 rep movsb 같은 최적화된 명령을 사용합니다.

복사 후 페이지 테이블 엔트리를 새 프레임을 가리키도록 업데이트하고, WRITABLE 플래그를 추가합니다. 마지막으로, 기존 프레임의 참조 카운트를 감소시킵니다.

카운트가 0이 되면 프레임을 해제하여 재사용할 수 있게 합니다. TLB flush는 필수인데, 안 하면 CPU가 여전히 읽기 전용으로 인식하여 다시 폴트가 발생합니다.

이제 핸들러에서 복귀하면 쓰기 명령이 재실행되며 성공합니다. 여러분이 COW를 구현하면 fork 성능이 극적으로 향상됩니다.

수 GB 프로세스도 수 밀리초 내에 복제되고, 메모리 사용량도 실제 수정된 페이지만큼만 증가합니다. 실무에서는 transparent huge page COW, KSM(Kernel Samepage Merging), MADV_DONTFORK 같은 고급 최적화도 적용됩니다.

실전 팁

💡 fork 시 페이지 테이블을 복사할 때 부모와 자식 모두의 WRITABLE 플래그를 제거하고 참조 카운트를 증가시키세요. 부모의 쓰기도 COW를 발생시켜야 합니다.

💡 참조 카운트를 atomic 변수로 만들어 멀티코어에서 안전하게 증감하세요. race condition은 메모리 누수나 use-after-free를 일으킵니다.

💡 대용량 페이지를 연속으로 쓸 때는 연속된 여러 페이지를 한 번에 할당하여 단편화를 줄이세요.

💡 읽기 전용으로 의도된 페이지(코드 섹션 등)는 COW 마킹하지 말고 진짜 읽기 전용으로 유지하여 공유를 최대화하세요.

💡 COW 페이지는 캐시 일관성 문제를 일으킬 수 있습니다. 복사 전에 캐시를 flush하거나, cache-coherent 프로토콜을 사용하세요.


6. 스왑 인/아웃 처리 - 디스크와 메모리 간 페이지 이동

시작하며

여러분이 시스템 메모리가 부족한 상황에서 새 페이지를 할당하려는데, 프레임 할당자가 사용 가능한 프레임이 없다고 반환하는 상황을 겪으셨나요? 단순히 OOM 에러를 내면 프로세스가 죽는데, 실제 OS는 더 지능적으로 대응합니다.

이 문제는 제한된 물리 메모리로 훨씬 큰 가상 주소 공간을 지원해야 하는 모든 현대 OS의 과제입니다. 16GB 메모리로 100GB의 가상 메모리를 사용하는 프로그램도 실행할 수 있어야 합니다.

바로 이럴 때 필요한 것이 스왑(swap) 메커니즘입니다. 사용하지 않는 페이지를 디스크에 저장하고 메모리를 확보한 뒤(swap out), 나중에 접근하면 페이지 폴트를 통해 다시 로드(swap in)하여 가상 메모리를 구현합니다.

개요

간단히 말해서, 스왑은 물리 메모리가 부족할 때 오래 사용하지 않은 페이지를 디스크의 스왑 공간에 기록하고 메모리를 해제하며, 나중에 해당 페이지 접근 시 페이지 폴트에서 디스크로부터 다시 읽어오는 양방향 프로세스입니다. 실무에서 스왑은 메모리 오버커밋의 핵심입니다.

모든 프로세스의 가상 메모리 합계가 물리 메모리를 초과해도 실제 사용하는 working set만 메모리에 있으면 됩니다. LRU(Least Recently Used), Clock, ARC 같은 알고리즘으로 스왑할 페이지를 선택합니다.

예를 들어, 대용량 데이터베이스 버퍼 캐시는 일부만 메모리에 있고 나머지는 스왑되어 있다가 쿼리 실행 시 swap in됩니다. 기존에는 물리 메모리 크기가 한계였다면, 스왑을 통해 디스크 크기까지 확장하여 사실상 무제한 가상 메모리를 제공할 수 있습니다.

핵심 특징: (1) 페이지 테이블 엔트리에 스왑 위치 저장(PRESENT=0일 때), (2) 선택 알고리즘으로 victim 페이지 결정, (3) 비동기 I/O로 성능 유지. 이를 통해 제한된 메모리로 대규모 워크로드를 처리합니다.

코드 예제

// 스왑 아웃: 메모리 -> 디스크
fn swap_out_page(page: Page) -> Result<(), SwapError> {
    let entry = walk_page_table(page.start_address())?;
    let frame = entry.frame()?;

    // 스왑 공간에 페이지 쓰기
    let swap_offset = allocate_swap_slot()?;
    write_to_swap(swap_offset, frame)?;

    // 페이지 테이블 업데이트: PRESENT=0, 스왑 오프셋 저장
    let swap_pte = encode_swap_pte(swap_offset);
    *entry = swap_pte; // PRESENT 비트 꺼짐

    // 프레임 해제
    deallocate_frame(frame);
    flush_tlb(page);
    Ok(())
}

// 스왑 인: 디스크 -> 메모리
fn swap_in_page(page: Page) -> Result<(), SwapError> {
    let entry = walk_page_table(page.start_address())?;

    // 페이지 테이블에서 스왑 오프셋 추출
    let swap_offset = decode_swap_pte(*entry)?;

    // 새 프레임 할당 (필요 시 다른 페이지 스왑 아웃)
    let frame = allocate_frame_or_evict()?;

    // 스왑에서 데이터 읽기
    read_from_swap(swap_offset, frame)?;

    // 페이지 테이블 복구: PRESENT=1
    entry.set_frame(frame, PageTableFlags::PRESENT | PageTableFlags::WRITABLE);

    // 스왑 슬롯 해제
    deallocate_swap_slot(swap_offset);
    flush_tlb(page);
    Ok(())
}

설명

이것이 하는 일: 물리 메모리 압박 상황에서 사용하지 않는 페이지를 디스크로 옮겨 공간을 확보하고(swap out), 나중에 해당 페이지 접근 시 페이지 폴트 핸들러에서 디스크로부터 복구(swap in)하여 투명한 가상 메모리 계층을 제공합니다. 첫 번째로, swap out 시 victim 페이지를 선택합니다.

LRU 알고리즘은 각 페이지의 접근 시간을 추적하여 가장 오래 사용하지 않은 페이지를 선택합니다. x86_64의 ACCESSED 비트를 활용하면 하드웨어 지원을 받을 수 있습니다.

victim이 정해지면 해당 페이지의 데이터를 스왑 파일이나 스왑 파티션에 씁니다. 디스크 I/O는 느리므로 비동기로 처리하며, 여러 페이지를 배치로 쓰면 효율적입니다.

그 다음으로, 페이지 테이블 엔트리를 수정하여 PRESENT 비트를 끕니다. PRESENT=0이면 CPU가 페이지 폴트를 발생시키지만, OS는 엔트리의 나머지 비트를 자유롭게 사용할 수 있습니다.

여기에 스왑 오프셋(디스크 상의 위치)을 인코딩합니다. 예를 들어, 비트 1-51을 스왑 슬롯 번호로 사용하고, 비트 52-63을 스왑 파일 식별자로 사용할 수 있습니다.

엔트리를 업데이트한 후 TLB를 flush하고 프레임을 해제하여 재사용 가능하게 합니다. 세 번째로, swap in은 페이지 폴트 핸들러에서 PRESENT=0인 엔트리를 발견했을 때 실행됩니다.

엔트리가 완전히 0이 아니면(즉, 일부 비트가 설정되어 있으면) 스왑된 페이지입니다. 스왑 오프셋을 디코딩하고, 새 프레임을 할당합니다.

프레임이 없으면 재귀적으로 다른 페이지를 swap out해야 하는데, 무한 루프를 피하기 위해 일정 개수의 "pinned" 페이지(스왑 불가)를 유지합니다. 마지막으로, 디스크에서 데이터를 읽어 새 프레임에 로드하고, 페이지 테이블 엔트리를 업데이트하여 PRESENT=1로 설정하고 프레임을 가리키게 합니다.

원래 플래그(WRITABLE, USER 등)도 복구해야 하므로, swap out 시 플래그 정보도 함께 저장해야 합니다. TLB flush 후 스왑 슬롯을 해제하여 재사용 가능하게 하고, 페이지 폴트 핸들러에서 복귀하면 중단된 명령이 재실행되어 성공합니다.

여러분이 스왑을 구현하면 메모리 제약을 크게 완화할 수 있습니다. 대용량 데이터셋, 수많은 프로세스, 메모리 집약적 애플리케이션을 제한된 하드웨어에서 실행할 수 있습니다.

실무에서는 SSD 활용, 압축 스왑(zswap), 선행 읽기(prefetch), 스왑 우선순위 등으로 최적화합니다.

실전 팁

💡 dirty 페이지(DIRTY 비트 설정)만 디스크에 쓰고, clean 페이지는 단순히 버리세요. 파일 기반 페이지는 원본 파일에서 다시 읽으면 되므로 스왑이 불필요합니다.

💡 스왑 빈도가 너무 높으면(thrashing) 성능이 급락합니다. 페이지 폴트 비율을 모니터링하여 임계값을 넘으면 프로세스를 종료하거나 메모리를 추가하세요.

💡 anonymous 페이지(스택, 힙)와 file-backed 페이지를 분리하여 관리하면 효율적입니다. anonymous만 스왑하고 file-backed는 drop합니다.

💡 스왑 I/O는 병목이므로 비동기 I/O와 배치 처리를 활용하세요. 여러 페이지를 한 번에 쓰면 디스크 seek time을 줄일 수 있습니다.

💡 최신 시스템에서는 zRAM(압축된 메모리 블록 디바이스)을 스왑 장치로 사용하여 디스크 I/O를 피하고 CPU로 압축/해제하는 것이 더 빠를 수 있습니다.


7. 커널 페이지 폴트 처리 - 커널 모드 예외 안전하게 다루기

시작하며

여러분이 페이지 폴트 핸들러를 구현했는데, 유저 모드 폴트는 잘 처리되지만 커널 코드에서 폴트가 발생하면 시스템이 패닉하는 상황을 겪으셨나요? 커널 버그로 잘못된 포인터를 역참조하거나, 의도적으로 유저 공간 주소를 검증하는 경우도 있습니다.

이 문제는 시스템 안정성에 치명적입니다. 커널 폴트를 제대로 처리하지 못하면 전체 시스템이 다운되고, 복구가 불가능합니다.

또한 유저가 제공한 포인터를 안전하게 검증할 방법이 없어 보안 취약점이 됩니다. 바로 이럴 때 필요한 것이 커널 페이지 폴트 처리 로직입니다.

커널 모드 폴트를 감지하여 복구 가능한 상황(예: copy_from_user)과 치명적 버그를 구분하고, 적절히 대응합니다.

개요

간단히 말해서, 커널 페이지 폴트 처리는 에러 코드의 USER 비트를 확인하여 커널 모드 폴트를 식별하고, 허용된 예외(fixup table) 또는 치명적 버그로 분류하여, 안전하게 복구하거나 명확한 진단 정보와 함께 패닉하는 과정입니다. 실무 커널에서는 유저 공간 메모리 접근 함수(copy_from_user, copy_to_user)가 의도적으로 폴트를 발생시킬 수 있습니다.

유저가 잘못된 주소를 전달하면 폴트가 나는데, 이를 에러로 반환해야지 패닉하면 안 됩니다. 이를 위해 fixup table에 복구 주소를 등록합니다.

예를 들어, read 시스템 콜에서 유저 버퍼 주소가 유효하지 않으면 EFAULT를 반환해야 합니다. 기존에는 모든 커널 폴트를 치명적 오류로 처리했다면, fixup 메커니즘을 통해 예상 가능한 폴트를 안전하게 처리할 수 있습니다.

핵심 특징: (1) USER 비트로 커널 vs 유저 폴트 구분, (2) fixup table로 복구 가능한 주소 등록, (3) 상세한 디버그 정보(스택 트레이스, 레지스터 등) 출력. 이를 통해 커널 안정성과 디버깅 효율을 높입니다.

코드 예제

extern "x86-interrupt" fn page_fault_handler(
    mut stack_frame: InterruptStackFrame,
    error_code: PageFaultErrorCode,
) {
    let fault_addr = Cr2::read();

    // 커널 모드 폴트인지 확인
    if !error_code.contains(PageFaultErrorCode::USER) {
        // 명령어 포인터 가져오기
        let instruction_pointer = stack_frame.instruction_pointer.as_u64();

        // fixup table에서 복구 주소 찾기
        if let Some(fixup_addr) = lookup_fixup(instruction_pointer) {
            // 복구 가능: instruction pointer를 fixup 주소로 변경
            stack_frame.instruction_pointer = VirtAddr::new(fixup_addr);
            // 에러 코드를 레지스터에 저장 (예: RAX = -EFAULT)
            // 핸들러 복귀 시 fixup 주소로 점프하여 에러 처리
            return;
        }

        // 복구 불가능한 커널 버그
        println!("KERNEL PAGE FAULT at {:?}", fault_addr);
        println!("Instruction pointer: {:#x}", instruction_pointer);
        println!("Error code: {:?}", error_code);
        print_stack_trace(&stack_frame);
        panic!("Kernel page fault - system halted");
    }

    // 유저 모드 폴트는 정상 처리
    handle_user_page_fault(fault_addr, error_code);
}

설명

이것이 하는 일: 페이지 폴트가 커널 모드에서 발생했을 때 의도된 예외인지 버그인지 판단하여, 복구 가능하면 에러 처리 코드로 점프하고, 아니면 디버깅 정보를 출력한 후 시스템을 안전하게 중단합니다. 첫 번째로, 에러 코드의 USER 비트를 검사합니다.

이 비트가 0이면 폴트가 커널 모드(CPL=0)에서 발생한 것입니다. 커널 폴트는 훨씬 심각한데, 커널은 항상 유효한 메모리만 접근해야 하기 때문입니다.

유저 폴트는 프로세스만 종료하면 되지만, 커널 폴트는 전체 시스템 상태가 불확실해집니다. 그 다음으로, 폴트를 발생시킨 명령어의 주소(instruction pointer)를 스택 프레임에서 읽습니다.

이 주소를 fixup table에서 검색합니다. Fixup table은 컴파일 시간에 생성되는 테이블로, "주소 X에서 폴트 발생 시 주소 Y로 점프"라는 매핑을 저장합니다.

copy_from_user 같은 함수는 매크로나 링커 스크립트로 자동으로 fixup 엔트리를 등록합니다. 세 번째로, fixup 주소가 발견되면 복구 가능한 상황입니다.

스택 프레임의 instruction pointer를 fixup 주소로 변경합니다. 핸들러에서 복귀할 때 CPU는 이 변경된 주소로 점프하므로, 원래 폴트를 발생시킨 명령이 아닌 에러 처리 코드가 실행됩니다.

관례적으로 에러 코드(-EFAULT)를 RAX 레지스터에 저장하여 호출자에게 전달합니다. 이렇게 하면 copy_from_user가 실패를 반환하고, 시스템 콜이 에러로 종료됩니다.

마지막으로, fixup table에 엔트리가 없으면 예상치 못한 커널 버그입니다. 이는 null pointer dereference, 해제된 메모리 접근, 배열 오버플로우 등입니다.

이 경우 가능한 많은 디버깅 정보를 출력해야 합니다: 폴트 주소, 명령어 주소, 에러 코드, 스택 트레이스, 레지스터 값 등. panic!으로 시스템을 중단시켜 데이터 손상을 방지합니다.

일부 시스템은 커널 크래시 덤프를 생성하여 사후 분석을 가능하게 합니다. 여러분이 이 처리 로직을 구현하면 커널 버그로부터 시스템을 보호하고, 유저 입력을 안전하게 검증할 수 있습니다.

Fixup 메커니즘 없이는 모든 유저 포인터를 수동으로 검증해야 하는데, 이는 느리고 오류 가능성이 높습니다. 실무에서는 kASAN, UBSAN 같은 도구로 커널 메모리 버그를 사전에 감지합니다.

실전 팁

💡 fixup table은 성능에 영향을 주지 않습니다. 폴트가 발생할 때만 조회되므로, 정상 경로에서는 오버헤드가 없습니다.

💡 커널 스택 오버플로우로 인한 폴트는 더블 폴트로 이어질 수 있습니다. 가드 페이지(unmapped page)를 스택 끝에 두어 오버플로우를 조기 감지하세요.

💡 copy_from_user는 반드시 사용하고, 절대 유저 포인터를 직접 역참조하지 마세요. SMAP(Supervisor Mode Access Prevention)이 활성화되면 커널이 유저 메모리를 직접 접근할 수 없습니다.

💡 커널 폴트 로그를 분석하여 자주 발생하는 주소나 패턴을 찾으세요. 재현 가능한 버그는 디버깅이 훨씬 쉽습니다.

💡 프로덕션 커널에서는 oops 핸들러로 폴트를 로깅한 후 계속 실행하는 옵션도 있지만, 데이터 무결성을 보장할 수 없으므로 신중하게 사용하세요.


8. Guard Page 구현 - 스택 오버플로우 조기 감지

시작하며

여러분이 재귀 함수나 큰 로컬 배열로 인해 스택이 오버플로우되는 버그를 디버깅하려는데, 증상이 이상한 메모리 손상이나 임의의 크래시로 나타나 원인을 찾기 어려우셨나요? 스택이 인접 메모리를 침범하면 힙이나 다른 스택을 덮어써 진단이 매우 어렵습니다.

이 문제는 특히 커널 스택에서 치명적입니다. 커널 스택은 작고(보통 4-16KB), 오버플로우 시 중요한 커널 데이터를 손상시켜 시스템 전체가 불안정해집니다.

증상이 오버플로우 시점과 시간적으로 분리되어 나타나 디버깅이 악몽입니다. 바로 이럴 때 필요한 것이 Guard Page입니다.

스택 끝에 매핑되지 않은 페이지를 두어, 스택이 한계를 넘으면 즉시 페이지 폴트를 발생시켜 문제를 정확한 지점에서 감지합니다.

개요

간단히 말해서, Guard Page는 스택 메모리 영역 바로 아래(또는 위, 스택 성장 방향 따라)에 의도적으로 매핑하지 않은 페이지를 두어, 스택 오버플로우 시 페이지 폴트를 발생시켜 조기에 감지하는 메모리 보호 기법입니다. 실무에서 모든 스레드와 프로세스의 스택에 guard page를 설정하는 것이 표준입니다.

리눅스, Windows, macOS 모두 기본적으로 guard page를 사용합니다. 스택 크기를 미리 제한하기 어려운 경우(재귀 깊이가 입력에 의존하는 등) guard page가 유일한 안전 장치입니다.

예를 들어, JSON 파서가 깊이 중첩된 구조를 파싱할 때 스택 오버플로우가 발생할 수 있는데, guard page가 없으면 힙을 손상시킵니다. 기존에는 스택 오버플로우를 감지하지 못하고 메모리 손상이 발생했다면, guard page를 통해 즉시 감지하고 명확한 에러 메시지를 출력할 수 있습니다.

핵심 특징: (1) 매핑되지 않은 페이지로 구현하여 추가 오버헤드 없음, (2) 스택 범위를 자동으로 확장하는 기능과 결합 가능, (3) 멀티스레드 환경에서 각 스택 독립 보호. 이를 통해 스택 관련 버그를 조기에 명확하게 감지합니다.

코드 예제

use x86_64::structures::paging::{Page, PageTableFlags, Size4KiB};

// 스택 할당 시 guard page 포함
fn allocate_stack_with_guard(size_in_pages: usize) -> Result<Range<VirtAddr>, AllocError> {
    let total_pages = size_in_pages + 1; // 스택 + guard page
    let start = allocate_virtual_range(total_pages)?;

    // Guard page는 매핑하지 않음 (첫 번째 페이지)
    let guard_page = Page::containing_address(start);
    // 매핑하지 않으므로 아무것도 안 함

    // 실제 스택 페이지들 매핑 (guard page 다음부터)
    for i in 1..total_pages {
        let page = Page::containing_address(start + (i * 4096));
        let frame = allocate_frame()?;
        let flags = PageTableFlags::PRESENT
            | PageTableFlags::WRITABLE
            | PageTableFlags::NO_EXECUTE; // 스택은 실행 불가
        unsafe {
            mapper.map_to(page, frame, flags, &mut frame_allocator)?
                .flush();
        }
    }

    // 스택 포인터는 마지막 페이지 끝에서 시작
    let stack_top = start + (total_pages * 4096);
    Ok(start..stack_top)
}

설명

이것이 하는 일: 스택 메모리를 할당할 때 요청된 크기보다 1페이지 더 큰 가상 주소 범위를 예약하고, 첫 페이지는 의도적으로 매핑하지 않은 채 남겨두어, 스택이 한계를 넘어 성장하면 자동으로 페이지 폴트가 발생하게 합니다. 첫 번째로, 스택에 필요한 페이지 수에 1을 더하여 총 가상 주소 범위를 할당합니다.

예를 들어 16KB 스택이 필요하면 4페이지 + 1 guard = 5페이지를 예약합니다. 가상 주소 공간은 풍부하므로 추가 페이지 예약은 문제없습니다.

시작 주소를 얻으면 이것이 guard page의 주소가 됩니다. 그 다음으로, guard page는 의도적으로 매핑하지 않습니다.

페이지 테이블에 엔트리를 만들지 않거나, 엔트리를 만들되 PRESENT 비트를 끄는 방법이 있습니다. 두 방법 모두 해당 주소에 접근하면 페이지 폴트가 발생합니다.

x86_64에서 스택은 높은 주소에서 낮은 주소로 성장하므로, guard page는 스택 범위의 맨 아래(가장 낮은 주소)에 위치합니다. 세 번째로, 실제 스택 페이지들(guard 다음부터)을 매핑합니다.

각 페이지에 물리 프레임을 할당하고, PRESENT | WRITABLE 플래그를 설정합니다. 중요한 점은 NO_EXECUTE 플래그를 추가하여 스택에서 코드 실행을 막는 것입니다.

이는 버퍼 오버플로우 공격을 방지하는 DEP(Data Execution Prevention) 보호 기법입니다. 모든 페이지를 0으로 초기화하여 정보 누출을 방지합니다.

마지막으로, 스택 포인터를 설정합니다. x86_64에서 스택은 아래로 성장하므로, 스택 포인터(RSP)는 할당된 범위의 맨 위(가장 높은 주소)에서 시작합니다.

함수 호출이나 로컬 변수 할당으로 RSP가 감소하고, guard page에 도달하면 페이지 폴트가 발생합니다. 페이지 폴트 핸들러에서는 폴트 주소가 guard page 범위인지 확인하여 스택 오버플로우 에러를 명확히 보고합니다.

여러분이 guard page를 구현하면 스택 오버플로우 버그를 즉시 감지할 수 있습니다. 디버깅 시간이 크게 단축되고, 프로덕션 환경에서도 메모리 손상을 방지하여 안정성이 향상됩니다.

실무에서는 여러 guard page를 두거나, redzone을 추가하거나, stack canary와 결합하여 다층 방어를 구축합니다.

실전 팁

💡 스택 자동 확장 기능을 구현하려면, guard page 위에 추가 guard page를 두고, 접근 시 스택을 확장한 후 새 guard를 설정하세요. 리눅스의 스택 성장 메커니즘입니다.

💡 멀티스레드 환경에서는 각 스레드 스택마다 독립적인 guard page가 필요합니다. 스레드 생성 시 자동으로 설정하세요.

💡 커널 스택은 특히 작으므로(4-8KB) guard page가 필수입니다. 커널에서 큰 배열을 스택에 두지 말고 동적 할당을 사용하세요.

💡 guard page 폴트와 일반 페이지 폴트를 구분하기 위해, 스택 영역 메타데이터를 유지하고 핸들러에서 확인하세요.

💡 Address Sanitizer(ASAN)와 결합하면 더 세밀한 스택 버퍼 오버플로우 감지가 가능합니다. Redzone을 각 변수 주변에 두어 작은 오버플로우도 감지합니다.


9. Lazy Allocation 전략 - 실제 사용 시점에 메모리 할당

시작하며

여러분이 대용량 배열이나 버퍼를 선언하는 프로그램을 실행하는데, 초기화에 시간이 오래 걸리고 메모리도 많이 차지하는 걸 보셨나요? 그런데 실제로는 배열의 일부만 사용하는 경우가 많아 낭비입니다.

이 문제는 메모리 사용 효율성과 프로그램 시작 시간에 직접 영향을 미칩니다. 특히 가상 메모리 시스템에서는 미리 할당하는 것이 불필요한 경우가 많습니다.

malloc(1GB)를 호출해도 실제로 1GB를 다 사용하지 않을 수 있습니다. 바로 이럴 때 필요한 것이 Lazy Allocation입니다.

메모리 할당 요청 시 가상 주소만 예약하고 실제 물리 메모리는 할당하지 않다가, 첫 접근 시 페이지 폴트를 통해 할당하여 실제 사용량만큼만 메모리를 소비합니다.

개요

간단히 말해서, Lazy Allocation은 malloc이나 mmap 같은 메모리 할당 함수가 호출될 때 페이지 테이블 엔트리를 생성하지 않고 가상 주소 범위만 예약하며, 프로그램이 해당 주소를 실제로 읽거나 쓸 때 페이지 폴트 핸들러에서 비로소 물리 페이지를 할당하는 지연 할당 전략입니다. 실무에서 대부분의 현대 OS는 기본적으로 lazy allocation을 사용합니다.

리눅스의 overcommit, Windows의 reserved 메모리가 이 개념입니다. 메모리를 요청하는 것과 실제 사용하는 것을 분리하여, 시스템이 물리 메모리보다 훨씬 많은 가상 메모리를 할당할 수 있게 합니다.

예를 들어, 데이터베이스가 10GB 버퍼 풀을 할당해도 실제 사용하는 2GB만 물리 메모리를 차지합니다. 기존에는 할당 즉시 모든 페이지를 매핑했다면, lazy allocation을 통해 실제 사용 패턴에 맞춰 점진적으로 메모리를 할당하여 효율성을 극대화할 수 있습니다.

핵심 특징: (1) VMA(Virtual Memory Area) 구조로 예약된 범위 추적, (2) 첫 접근 시 zero-fill 페이지 자동 할당, (3) 메모리 오버커밋 가능. 이를 통해 메모리 사용 효율과 할당 속도를 크게 개선합니다.

코드 예제

// 가상 메모리 영역(VMA) 추적 구조
struct VirtualMemoryArea {
    start: VirtAddr,
    end: VirtAddr,
    flags: PageTableFlags,
    backing: BackingType, // Anonymous, File, Device 등
}

// Lazy allocation을 위한 메모리 할당
fn lazy_allocate(size: usize, flags: PageTableFlags) -> Result<VirtAddr, AllocError> {
    // 가상 주소 범위만 예약
    let start = find_free_virtual_range(size)?;
    let end = start + size;

    // VMA에 등록 (물리 페이지는 아직 할당 안 함)
    let vma = VirtualMemoryArea {
        start,
        end,
        flags,
        backing: BackingType::Anonymous,
    };
    register_vma(vma);

    // 페이지 테이블은 아직 매핑 안 함
    Ok(start)
}

// 페이지 폴트 핸들러에서 lazy 페이지 할당
fn handle_lazy_fault(fault_addr: VirtAddr) -> Result<(), PageFaultError> {
    // VMA에서 해당 주소 찾기
    let vma = find_vma(fault_addr).ok_or(PageFaultError::InvalidAccess)?;

    // VMA 범위 내이고 아직 매핑 안 됨 확인
    let page = Page::containing_address(fault_addr);

    // 물리 페이지 할당
    let frame = allocate_frame()?;
    zero_frame(frame); // 보안: 0으로 초기화

    // 페이지 테이블에 매핑
    unsafe {
        mapper.map_to(page, frame, vma.flags, &mut frame_allocator)?
            .flush();
    }

    Ok(())
}

설명

이것이 하는 일: malloc이나 mmap 호출 시 물리 메모리를 즉시 할당하지 않고 가상 주소 공간에 영역만 표시해두었다가, 프로그램이 실제로 해당 메모리를 사용하는 순간 페이지 폴트를 통해 필요한 페이지만 할당합니다. 첫 번째로, lazy_allocate 함수는 사용 가능한 가상 주소 범위를 찾습니다.

프로세스의 주소 공간을 구간 트리(interval tree)나 링크드 리스트로 관리하여, 겹치지 않는 빈 영역을 빠르게 찾을 수 있습니다. 요청된 크기만큼의 연속된 가상 주소를 예약하되, 물리 메모리는 전혀 할당하지 않습니다.

가상 주소는 64비트 시스템에서 거의 무제한이므로 큰 영역도 쉽게 예약할 수 있습니다. 그 다음으로, VMA(Virtual Memory Area) 구조를 생성하여 이 영역의 속성을 기록합니다.

VMA는 시작/끝 주소, 권한 플래그(읽기/쓰기/실행), 백킹 타입(anonymous 메모리인지, 파일 매핑인지 등)을 저장합니다. 이 정보는 나중에 페이지 폴트 시 어떻게 처리할지 결정하는 데 사용됩니다.

VMA를 프로세스의 메모리 맵에 등록하지만, 페이지 테이블은 아직 수정하지 않습니다. 세 번째로, 프로그램이 예약된 주소를 처음 읽거나 쓰면 페이지 폴트가 발생합니다.

handle_lazy_fault에서 폴트 주소가 어느 VMA에 속하는지 찾습니다. VMA가 없으면 진짜 invalid access이므로 segmentation fault로 프로세스를 종료합니다.

VMA가 있으면 합법적인 접근이므로 페이지를 할당합니다. 마지막으로, 새 물리 프레임을 할당하고 0으로 초기화합니다.

초기화가 중요한 이유는 이전에 해당 프레임을 사용한 프로세스의 데이터가 남아있을 수 있기 때문입니다(정보 누출 방지). 그 후 페이지 테이블에 매핑을 생성하고 VMA의 플래그를 적용합니다.

TLB flush 후 페이지 폴트 핸들러에서 복귀하면 동일한 명령이 재실행되며, 이번에는 페이지가 존재하므로 성공합니다. 이 과정이 프로그램에게는 완전히 투명합니다.

여러분이 lazy allocation을 구현하면 메모리 할당 속도가 극적으로 빨라집니다. malloc(1GB)가 O(1) 시간에 완료되고, 실제 메모리 사용량은 접근한 페이지만큼만 증가합니다.

실무에서는 huge page 지원, NUMA-aware 할당, transparent huge page 등과 결합하여 더욱 최적화합니다.

실전 팁

💡 Zero page sharing: 여러 프로세스의 zero-fill 페이지를 하나의 공유 zero 페이지로 매핑하고 COW로 표시하면, 쓰기 전까지 물리 메모리를 아낄 수 있습니다.

💡 메모리 오버커밋 비율을 설정하여 OOM을 제어하세요. 리눅스의 /proc/sys/vm/overcommit_ratio처럼 물리 메모리의 몇 배까지 할당을 허용할지 정합니다.

💡 mmap의 MAP_POPULATE 플래그는 lazy allocation을 무효화하고 즉시 매핑합니다. 성능이 중요한 경로에서 페이지 폴트를 피하려면 사용하세요.

💡 대용량 할당 시 huge page(2MB, 1GB)를 사용하면 페이지 테이블 오버헤드와 TLB 미스를 줄일 수 있습니다.

💡 VMA 수가 너무 많으면 조회 성능이 저하됩니다. 인접하고 속성이 같은 VMA는 병합하여 개수를 줄이세요.


10. 디버깅 및 로깅 전략 - 페이지 폴트 문제 추적하기

시작하며

여러분이 페이지 폴트 핸들러를 구현했는데, 예상치 못한 폴트가 발생하거나 성능 문제가 있을 때 원인을 파악하기 어려우셨나요? 페이지 폴트는 빈번하게 발생하고, 각 폴트의 맥락이 다르므로 디버깅이 복잡합니다.

이 문제는 OS 개발의 가장 어려운 부분 중 하나입니다. 페이지 폴트 핸들러 자체에 버그가 있으면 무한 루프나 더블 폴트가 발생하여 진단이 거의 불가능합니다.

또한 성능 문제는 증상만 보이고 원인을 찾기 어렵습니다. 바로 이럴 때 필요한 것이 체계적인 디버깅과 로깅 전략입니다.

페이지 폴트 정보를 구조화하여 기록하고, 통계를 수집하며, 조건부 브레이크포인트를 활용하여 문제를 효율적으로 추적합니다.

개요

간단히 말해서, 페이지 폴트 디버깅은 각 폴트의 주요 정보(주소, 에러 코드, 명령어 포인터, 스택 트레이스)를 로깅하고, 폴트 타입별 카운터를 유지하며, 시리얼 포트나 디버거를 통해 실시간으로 분석하는 프로세스입니다. 실무에서는 프로덕션 커널에서도 최소한의 페이지 폴트 통계는 항상 수집합니다.

/proc/vmstat (리눅스)나 성능 카운터(Windows)가 예시입니다. 개발 중에는 상세한 로깅을 활성화하여 모든 폴트를 추적합니다.

예를 들어, 페이지 폴트 발생률이 갑자기 증가하면 메모리 누수나 thrashing의 신호일 수 있습니다. 기존에는 페이지 폴트를 단순히 처리만 했다면, 로깅과 분석을 통해 시스템 동작을 깊이 이해하고 최적화 기회를 발견할 수 있습니다.

핵심 특징: (1) 구조화된 로그 포맷으로 자동 분석 가능, (2) 타입별 카운터로 패턴 파악, (3) ring buffer로 최근 N개 폴트 보존. 이를 통해 버그를 빠르게 찾고 성능을 개선합니다.

코드 예제

use core::sync::atomic::{AtomicU64, Ordering};

// 페이지 폴트 통계
static mut PAGE_FAULT_STATS: PageFaultStats = PageFaultStats::new();

struct PageFaultStats {
    total: AtomicU64,
    user_mode: AtomicU64,
    kernel_mode: AtomicU64,
    write_faults: AtomicU64,
    execute_faults: AtomicU64,
    not_present: AtomicU64,
    protection_violation: AtomicU64,
}

// 상세 페이지 폴트 로깅
extern "x86-interrupt" fn page_fault_handler(
    stack_frame: InterruptStackFrame,
    error_code: PageFaultErrorCode,
) {
    let fault_addr = Cr2::read();
    let ip = stack_frame.instruction_pointer;

    // 통계 업데이트
    unsafe {
        PAGE_FAULT_STATS.total.fetch_add(1, Ordering::Relaxed);

        if error_code.contains(PageFaultErrorCode::USER) {
            PAGE_FAULT_STATS.user_mode.fetch_add(1, Ordering::Relaxed);
        } else {
            PAGE_FAULT_STATS.kernel_mode.fetch_add(1, Ordering::Relaxed);
        }

        if !error_code.contains(PageFaultErrorCode::PRESENT) {
            PAGE_FAULT_STATS.not_present.fetch_add(1, Ordering::Relaxed);
        }
    }

    // 구조화된 로그 출력
    serial_println!(
        "PF: addr={:?} err={:?} ip={:#x} type={}",
        fault_addr,
        error_code,
        ip,
        classify_fault(error_code)
    );

    // 조건부 상세 로깅
    #[cfg(feature = "verbose_pf")]
    {
        print_stack_trace(&stack_frame);
        print_page_table_state(fault_addr);
    }

    // 실제 처리
    handle_page_fault(fault_addr, error_code, stack_frame);
}

// 폴트 분류 함수
fn classify_fault(error_code: PageFaultErrorCode) -> &'static str {
    if !error_code.contains(PageFaultErrorCode::PRESENT) {
        "NOT_PRESENT"
    } else if error_code.contains(PageFaultErrorCode::WRITE) {
        "WRITE_PROTECTION"
    } else if error_code.contains(PageFaultErrorCode::INSTRUCTION_FETCH) {
        "EXECUTE_VIOLATION"
    } else {
        "OTHER"
    }
}

설명

이것이 하는 일: 페이지 폴트가 발생할 때마다 핵심 정보를 구조화된 형식으로 기록하고, atomic 카운터로 통계를 수집하며, 필요 시 상세 정보를 출력하여 시스템 동작을 가시화하고 문제를 진단할 수 있게 합니다. 첫 번째로, atomic 변수로 각 폴트 타입의 발생 횟수를 추적합니다.

AtomicU64를 사용하여 멀티코어에서도 안전하게 증가시킬 수 있습니다. 총 폴트 수, 유저/커널 모드 비율, 원인별 분포 등을 실시간으로 파악할 수 있습니다.

이 카운터들은 오버헤드가 거의 없어 프로덕션에서도 항상 활성화할 수 있습니다. 주기적으로 이 통계를 읽어 그래프를 그리면 시스템 부하 패턴을 시각화할 수 있습니다.

그 다음으로, 각 페이지 폴트의 상세 정보를 로깅합니다. 시리얼 포트(serial_println!)로 출력하면 QEMU나 실제 하드웨어에서 호스트 머신으로 전송되어 분석할 수 있습니다.

로그 포맷을 구조화(JSON, key=value 등)하면 스크립트로 자동 파싱하여 패턴을 찾을 수 있습니다. 예를 들어, "같은 주소에서 반복되는 폴트"는 핸들러가 페이지를 제대로 매핑하지 못한 버그의 신호입니다.

세 번째로, 조건부 컴파일(#[cfg])로 상세 디버깅 정보를 선택적으로 활성화합니다. 스택 트레이스는 폴트를 발생시킨 코드 경로를 보여주고, 페이지 테이블 상태는 각 단계의 엔트리 값을 출력하여 매핑 문제를 찾습니다.

이런 상세 로깅은 오버헤드가 크므로 개발/디버깅 빌드에서만 활성화합니다. Release 빌드에서는 컴파일되지 않아 성능에 영향이 없습니다.

마지막으로, 폴트를 분류하는 헬퍼 함수로 가독성을 높입니다. "NOT_PRESENT", "WRITE_PROTECTION" 같은 문자열은 비트 플래그보다 이해하기 쉽습니다.

로그 분석 시 grep "WRITE_PROTECTION" log.txt | wc -l로 특정 타입의 빈도를 즉시 파악할 수 있습니다. 패턴이 보이면 해당 타입에 특화된 최적화를 적용할 수 있습니다.

여러분이 이런 로깅 시스템을 구축하면 페이지 폴트 관련 버그를 훨씬 빠르게 찾을 수 있습니다. 무작위로 발생하는 문제도 로그를 분석하면 재현 조건을 찾을 수 있습니다.

실무에서는 perf, eBPF, DTrace 같은 고급 도구로 더 상세한 프로파일링을 합니다.

실전 팁

💡 Ring buffer에 최근 100-1000개 폴트를 저장하면, 크래시 시 디버거로 히스토리를 분석할 수 있습니다. 순환 버퍼로 메모리 오버헤드를 제한하세요.

💡 특정 주소나 프로세스에 대한 조건부 브레이크포인트를 설정하세요: if fault_addr == 0xdeadbeef { breakpoint(); }

💡 페이지 폴트 레이턴시(핸들러 진입부터 복귀까지 시간)를 측정하여 성능 병목을 찾으세요. TSC(Time Stamp Counter)를 사용합니다.

💡 시리얼 출력이 느려 시스템 타이밍에 영향을 줄 수 있습니다. 프로덕션에서는 메모리 버퍼에 로그를 쌓고 별도 스레드가 flush하세요.

💡 페이지 폴트 스톰(초당 수만 건)이 발생하면 로깅 자체가 시스템을 멈춥니다. 샘플링(예: 100번 중 1번만 로깅)을 적용하세요.


#Rust#PageFault#ExceptionHandling#MemoryManagement#OSdev#시스템프로그래밍

댓글 (0)

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