이미지 로딩 중...

Rust로 만드는 나만의 OS 커널 메모리 레이아웃 완벽 가이드 - 슬라이드 1/11
A

AI Generated

2025. 11. 14. · 3 Views

Rust로 만드는 나만의 OS 커널 메모리 레이아웃 완벽 가이드

OS 개발에서 가장 중요한 커널 메모리 레이아웃 설계 방법을 다룹니다. Rust로 안전하고 효율적인 메모리 구조를 만드는 실전 기법을 배워보세요. 실무에서 바로 활용할 수 있는 메모리 관리 패턴을 제공합니다.


목차

  1. 커널 메모리 섹션 구조 - 링커 스크립트로 메모리 영역 정의하기
  2. 가상 메모리 맵 설계 - 높은 주소 커널과 낮은 주소 유저 공간 분리
  3. 페이지 테이블 구조체 - Rust로 안전하게 4단계 페이징 다루기
  4. 물리 메모리 할당자 - 비트맵 기반 프레임 할당기 구현
  5. 커널 힙 초기화 - 동적 메모리 할당을 위한 bump allocator
  6. 페이지 폴트 핸들러 - 요구 페이징과 스택 확장 구현
  7. TLB 관리 - 페이지 테이블 변경 시 캐시 무효화
  8. 커널 스택 관리 - 인터럽트와 시스템 콜을 위한 스택 전환
  9. 메모리 매핑 I/O - MMIO 영역 관리와 캐싱 비활성화
  10. 메모리 보호 확장 - NX 비트와 SMAP/SMEP 활성화

1. 커널 메모리 섹션 구조 - 링커 스크립트로 메모리 영역 정의하기

시작하며

여러분이 OS 커널을 개발할 때 "왜 커널이 이상한 주소에서 실행되지?"라는 의문을 가져본 적 있나요? 부트로더가 커널을 로드했는데 예상과 다른 메모리 위치에서 코드가 실행되거나, 전역 변수가 엉뚱한 값을 가지는 경우가 발생합니다.

이런 문제는 실제 OS 개발 현장에서 초기 단계에 반드시 마주치는 난관입니다. 커널의 각 섹션(.text, .data, .bss, .rodata)이 메모리 어디에 배치될지 명확히 정의하지 않으면, 부트로더와 커널 간의 계약이 깨지고 예측 불가능한 동작이 발생합니다.

바로 이럴 때 필요한 것이 링커 스크립트(Linker Script)입니다. 링커 스크립트를 통해 커널의 각 메모리 섹션을 정확한 물리/가상 주소에 배치하여 부팅 과정을 제어할 수 있습니다.

개요

간단히 말해서, 이 개념은 커널 바이너리의 각 섹션이 메모리 어디에 로드되고 실행될지를 명시적으로 정의하는 것입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, CPU는 부팅 시 특정 주소(예: 0x100000)에서 코드를 찾기 때문에 커널의 시작 지점이 정확히 그곳에 있어야 합니다.

또한 읽기 전용 데이터와 쓰기 가능한 데이터를 다른 메모리 페이지에 배치하여 보안을 강화할 수 있습니다. 예를 들어, 멀티부팅 환경에서 GRUB 같은 부트로더가 커널을 1MB 주소에 로드하길 기대한다면, 링커 스크립트로 정확히 그 위치를 지정해야 합니다.

전통적인 방법과의 비교를 해보면, 링커의 기본 설정에 의존했다면 섹션이 임의의 위치에 배치되어 하드웨어 제약을 위반할 수 있습니다. 이제는 링커 스크립트로 물리 주소, 가상 주소, 섹션 정렬, 심볼 위치까지 완벽하게 제어할 수 있습니다.

이 개념의 핵심 특징은 첫째, 물리 주소와 가상 주소를 분리하여 페이징을 준비할 수 있다는 점, 둘째, 섹션 정렬(alignment)을 통해 페이지 경계를 지킬 수 있다는 점, 셋째, 심볼(symbol)을 통해 Rust 코드에서 섹션 경계를 참조할 수 있다는 점입니다. 이러한 특징들이 안정적인 부팅과 메모리 보호의 기초가 됩니다.

코드 예제

/* linker.ld - 커널 링커 스크립트 */
ENTRY(_start)  /* 커널 진입점 심볼 */

SECTIONS
{
    . = 1M;  /* 커널을 1MB 물리 주소에 배치 */

    .boot : { *(.multiboot) }  /* 멀티부트 헤더 섹션 */

    .text : ALIGN(4K) {
        *(.text .text.*)  /* 코드 섹션들을 4KB 정렬 */
    }

    .rodata : ALIGN(4K) {
        *(.rodata .rodata.*)  /* 읽기 전용 데이터 */
    }

    .data : ALIGN(4K) {
        *(.data .data.*)  /* 초기화된 데이터 */
    }

    .bss : ALIGN(4K) {
        __bss_start = .;  /* BSS 시작 심볼 - Rust에서 참조 가능 */
        *(.bss .bss.*)
        __bss_end = .;    /* BSS 끝 심볼 */
    }
}

설명

이것이 하는 일: 이 링커 스크립트는 커널 바이너리가 물리 메모리의 1MB(0x100000) 위치에서 시작하도록 설정하고, 각 섹션을 4KB 페이지 경계에 정렬하여 배치합니다. 첫 번째로, ENTRY(_start)는 커널의 진입점을 _start 심볼로 지정합니다.

부트로더가 제어를 넘길 때 정확히 이 함수부터 실행이 시작됩니다. 왜 이렇게 하는지는 부트로더와의 명확한 계약을 맺기 위함입니다.

그 다음으로, . = 1M은 현재 위치 카운터를 1MB로 설정합니다.

이후 모든 섹션은 이 위치부터 순차적으로 배치됩니다. .text 섹션은 ALIGN(4K)로 4KB 경계에 정렬되어 페이지 테이블의 단위와 일치시킵니다.

내부에서는 (.text .text.)가 모든 입력 파일의 .text 섹션들을 수집합니다. 세 번째 단계로, .rodata와 .data, .bss 섹션도 각각 4KB 정렬됩니다.

특히 .bss 섹션에서는 __bss_start와 __bss_end 심볼을 정의하여, Rust 코드에서 이 영역을 0으로 초기화할 수 있게 합니다. 마지막으로 링커가 이 스크립트를 읽고 최종 커널 바이너리를 생성하면, 각 섹션이 정확한 오프셋과 크기를 가진 ELF 파일이 만들어집니다.

여러분이 이 코드를 사용하면 부트로더가 커널을 예측 가능한 위치에 로드하고, 페이징 활성화 전에 물리 주소로 정확히 접근할 수 있습니다. 또한 .rodata를 별도 섹션으로 분리하여 나중에 읽기 전용 페이지로 매핑할 수 있고, BSS 초기화를 자동화할 수 있습니다.

이는 커널의 보안과 안정성을 크게 향상시킵니다.

실전 팁

💡 4KB 정렬은 x86/x64 페이지 크기와 일치시키기 위함입니다. 나중에 페이징을 활성화할 때 각 섹션을 별도 페이지 권한으로 보호할 수 있습니다

💡 __bss_start 같은 심볼은 Rust에서 extern "C" { static __bss_start: u8; }로 참조할 수 있습니다. 부팅 시 이 영역을 0으로 초기화하는 루틴을 작성하세요

💡 멀티부트 헤더(.multiboot)는 반드시 바이너리의 첫 8KB 안에 있어야 합니다. 그래서 .boot 섹션을 가장 먼저 배치합니다

💡 링커 스크립트를 수정한 후에는 cargo clean으로 빌드 캐시를 정리하세요. 링커가 스크립트 변경을 항상 감지하지 못할 수 있습니다

💡 objdump -h kernel.elf로 최종 섹션 레이아웃을 검증하세요. VMA(가상 주소)와 LMA(로드 주소)가 예상과 일치하는지 확인할 수 있습니다


2. 가상 메모리 맵 설계 - 높은 주소 커널과 낮은 주소 유저 공간 분리

시작하며

여러분이 커널과 유저 프로세스를 동시에 실행할 때 "어떻게 주소 공간을 나눠야 하지?"라는 고민을 해본 적 있나요? 커널 코드와 유저 코드가 같은 가상 주소 범위를 쓰려고 하면 충돌이 발생하고, 프로세스 전환 시 주소 변환이 복잡해집니다.

이런 문제는 실제 OS 설계에서 핵심적인 아키텍처 결정입니다. 주소 공간을 잘못 분할하면 유저 프로그램이 커널 메모리에 접근하는 보안 취약점이 생기거나, 프로세스마다 주소 변환 오버헤드가 커집니다.

또한 공유 라이브러리나 메모리 매핑 파일의 주소 할당도 어려워집니다. 바로 이럴 때 필요한 것이 명확한 가상 메모리 맵 설계입니다.

일반적으로 x64에서는 높은 주소(0xFFFF_8000_0000_0000 이상)를 커널 공간으로, 낮은 주소(0x0000_0000_0000_0000~0x0000_7FFF_FFFF_FFFF)를 유저 공간으로 분리합니다. 이렇게 하면 페이지 테이블의 상위 비트만으로 커널/유저를 구분할 수 있습니다.

개요

간단히 말해서, 이 개념은 64비트 가상 주소 공간을 커널 영역과 유저 영역으로 명확히 분할하여 각 프로세스가 독립적인 주소 공간을 가지면서도 커널 코드는 모든 프로세스에서 동일한 주소로 매핑되도록 하는 것입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, CPU의 유저 모드와 커널 모드 전환 시 주소 공간 전체를 바꾸는 것은 TLB 플러시 등으로 매우 비용이 큽니다.

따라서 커널 부분은 모든 프로세스의 페이지 테이블에서 동일하게 매핑하고, 유저 부분만 프로세스별로 달라지게 합니다. 예를 들어, 시스템 콜 발생 시 페이지 테이블을 교체하지 않고도 커널 코드에 접근할 수 있어 성능이 크게 향상됩니다.

전통적인 방법과의 비교를 하자면, 32비트 시대에는 주소 공간이 부족해 3GB/1GB 분할을 사용했지만, 이제는 64비트의 광대한 주소 공간 덕분에 커널과 유저 각각 수백 TB를 할당할 수 있습니다. 기존에는 주소 충돌을 피하려고 복잡한 재배치를 했다면, 이제는 각 영역이 독립적으로 성장할 수 있습니다.

이 개념의 핵심 특징은 첫째, 높은 주소 커널 매핑으로 페이지 테이블 최상위 엔트리(PML4)만 공유하면 된다는 점, 둘째, 유저 공간은 0번지부터 시작해 프로그램 로더가 간단해진다는 점, 셋째, KASLR(Kernel Address Space Layout Randomization) 등 보안 기법을 적용하기 쉽다는 점입니다. 이러한 특징들이 현대 OS의 보안과 성능을 동시에 보장합니다.

코드 예제

// src/memory/layout.rs - 가상 메모리 레이아웃 상수 정의
pub const KERNEL_VIRT_BASE: usize = 0xFFFF_8000_0000_0000;  // 커널 시작 주소
pub const KERNEL_HEAP_START: usize = 0xFFFF_A000_0000_0000; // 커널 힙
pub const KERNEL_HEAP_SIZE: usize = 100 * 1024 * 1024;      // 100MB

pub const USER_STACK_TOP: usize = 0x0000_7FFF_FFFF_F000;   // 유저 스택 최상위
pub const USER_MMAP_START: usize = 0x0000_4000_0000_0000;  // mmap 영역

// 물리 주소를 커널 가상 주소로 변환
pub fn phys_to_virt(paddr: usize) -> usize {
    paddr + KERNEL_VIRT_BASE
}

// 커널 가상 주소를 물리 주소로 변환
pub fn virt_to_phys(vaddr: usize) -> usize {
    vaddr - KERNEL_VIRT_BASE
}

// 주소가 커널 공간인지 확인
pub fn is_kernel_address(vaddr: usize) -> bool {
    vaddr >= KERNEL_VIRT_BASE
}

설명

이것이 하는 일: 이 코드는 가상 메모리 공간을 커널과 유저로 명확히 분할하고, 물리 주소와 가상 주소 간의 변환 함수를 제공하여 커널 전역에서 일관된 주소 체계를 사용할 수 있게 합니다. 첫 번째로, KERNEL_VIRT_BASE는 0xFFFF_8000_0000_0000으로 설정되어 x64 정규 주소(canonical address)의 상위 절반을 나타냅니다.

x64는 실제로 48비트만 사용하므로 비트 47이 1인 주소는 모두 커널 영역입니다. 왜 이렇게 하는지는 페이지 테이블의 최상위 레벨(PML4)에서 상위 256개 엔트리를 커널 전용으로 고정할 수 있기 때문입니다.

그 다음으로, phys_to_virt 함수는 물리 주소에 KERNEL_VIRT_BASE를 더해 직접 매핑(direct mapping)을 구현합니다. 이렇게 하면 커널이 모든 물리 메모리에 간단한 덧셈만으로 접근할 수 있어, DMA 버퍼 할당이나 페이지 프레임 관리가 매우 효율적입니다.

내부에서는 단순한 산술 연산만 일어나므로 오버헤드가 거의 없습니다. 세 번째 단계로, USER_STACK_TOP은 유저 공간의 최상단에 스택을 배치합니다.

스택은 아래로 성장하므로 이 주소부터 감소하면서 할당됩니다. USER_MMAP_START는 파일 매핑이나 공유 메모리 영역의 시작점입니다.

is_kernel_address 함수는 페이지 폴트 핸들러에서 권한 위반을 빠르게 감지할 수 있게 합니다. 마지막으로 이 레이아웃을 페이지 테이블 초기화 코드에서 참조하여 커널 섹션들을 KERNEL_VIRT_BASE 이상의 주소로 재매핑합니다.

여러분이 이 코드를 사용하면 프로세스 생성 시 유저 페이지 테이블만 새로 만들고 커널 페이지는 복사하면 되므로 오버헤드가 줄어듭니다. 또한 커널 힙과 유저 힙이 완전히 분리되어 버퍼 오버플로우 같은 취약점이 다른 영역을 침범할 수 없습니다.

디버깅 시에도 주소만 보고 커널/유저를 즉시 구분할 수 있어 문제 파악이 빠릅니다.

실전 팁

💡 KERNEL_VIRT_BASE를 링커 스크립트의 VMA(Virtual Memory Address)로 설정하세요. 예: . = 1M + 0xFFFF800000000000; 이렇게 하면 커널 코드가 처음부터 높은 주소로 컴파일됩니다

💡 부팅 초기에는 페이징이 꺼져 있으므로 임시로 낮은 주소 identity mapping이 필요합니다. 페이징 활성화 직후 이 매핑을 제거하여 보안을 강화하세요

💡 KASLR을 구현하려면 KERNEL_VIRT_BASE에 부팅 시 난수 오프셋을 더하세요. 다만 2MB 정렬을 유지해야 huge page를 활용할 수 있습니다

💡 유저 공간에서 NULL 포인터 역참조를 감지하려면 최하위 페이지(0x0~0xFFF)를 매핑하지 마세요. 페이지 폴트로 즉시 버그를 찾을 수 있습니다

💡 멀티코어 시스템에서는 각 CPU마다 커널 스택이 필요합니다. KERNEL_VIRT_BASE 영역 내에 CPU별 스택 공간을 미리 예약하세요


3. 페이지 테이블 구조체 - Rust로 안전하게 4단계 페이징 다루기

시작하며

여러분이 페이지 테이블을 조작할 때 "잘못된 엔트리를 쓰면 커널이 패닉할 텐데 어떻게 안전하게 하지?"라는 두려움을 느낀 적 있나요? x64의 4단계 페이징(PML4 → PDPT → PD → PT)은 각 레벨마다 512개의 엔트리를 가지고, 비트 단위로 플래그를 설정해야 하는데, 실수로 Present 비트를 빼먹거나 물리 주소를 잘못 정렬하면 트리플 폴트가 발생합니다.

이런 문제는 실제 저수준 시스템 프로그래밍에서 가장 위험한 부분입니다. 어셈블리나 C로 작성하면 타입 안전성이 없어 버그를 런타임에만 발견하게 되고, 한 번 잘못 쓴 페이지 테이블 엔트리는 디버깅하기 매우 어렵습니다.

특히 권한 비트를 잘못 설정하면 보안 취약점이 됩니다. 바로 이럴 때 필요한 것이 Rust의 타입 시스템을 활용한 페이지 테이블 구조체입니다.

엔트리를 newtype 패턴으로 감싸고, 빌더 패턴으로 플래그를 설정하며, 물리 주소 정렬을 컴파일 타임에 검증하면 대부분의 버그를 사전에 방지할 수 있습니다.

개요

간단히 말해서, 이 개념은 x64 페이지 테이블의 각 레벨과 엔트리를 Rust 구조체로 모델링하여 비트 조작을 안전한 API로 추상화하는 것입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 페이지 테이블 엔트리는 52비트 물리 주소와 12비트 플래그로 구성된 64비트 값인데, 이를 직접 u64로 다루면 비트 마스킹 실수가 발생하기 쉽습니다.

구조체로 감싸면 set_present(), set_writable() 같은 메서드로 의도를 명확히 하고, 물리 주소는 PhysAddr 타입으로 정렬을 보장합니다. 예를 들어, 유저 페이지를 매핑할 때 USER 플래그를 깜빡하면 권한 위반이 발생하는데, 타입 시스템이 이를 강제할 수 있습니다.

전통적인 방법과의 비교를 하면, C에서는 #define PAGE_PRESENT 0x1 같은 매크로로 비트를 조작했지만, 여러 플래그를 OR로 합칠 때 실수가 잦았습니다. Rust에서는 bitflags!

매크로나 타입 안전한 enum으로 플래그를 표현하고, 잘못된 조합을 컴파일 에러로 잡을 수 있습니다. 이 개념의 핵심 특징은 첫째, 각 테이블 레벨을 별도 타입으로 만들어 레벨 혼동을 방지한다는 점, 둘째, PhysAddr와 VirtAddr를 구분하여 주소 변환 실수를 막는다는 점, 셋째, unsafe를 최소화하고 안전한 API 뒤로 숨긴다는 점입니다.

이러한 특징들이 커널 안정성을 크게 높입니다.

코드 예제

// src/memory/paging.rs - 페이지 테이블 엔트리 구조체
use bitflags::bitflags;

#[repr(transparent)]
pub struct PageTableEntry(u64);  // 64비트 엔트리를 newtype으로 감싸기

bitflags! {
    pub struct EntryFlags: u64 {
        const PRESENT = 1 << 0;      // 페이지가 메모리에 존재
        const WRITABLE = 1 << 1;     // 쓰기 가능
        const USER = 1 << 2;         // 유저 모드 접근 허용
        const WRITE_THROUGH = 1 << 3; // 라이트스루 캐싱
        const NO_CACHE = 1 << 4;     // 캐시 비활성화
        const ACCESSED = 1 << 5;     // CPU가 접근함
        const DIRTY = 1 << 6;        // 페이지가 수정됨
        const HUGE = 1 << 7;         // 2MB/1GB 페이지
        const NO_EXECUTE = 1 << 63;  // 실행 불가 (NX 비트)
    }
}

impl PageTableEntry {
    pub fn new(paddr: usize, flags: EntryFlags) -> Self {
        assert!(paddr % 4096 == 0, "물리 주소는 4KB 정렬 필요"); // 컴파일 타임 검증은 const fn에서
        PageTableEntry((paddr as u64 & 0x000F_FFFF_FFFF_F000) | flags.bits())
    }

    pub fn physical_address(&self) -> usize {
        (self.0 & 0x000F_FFFF_FFFF_F000) as usize  // 물리 주소 추출
    }

    pub fn flags(&self) -> EntryFlags {
        EntryFlags::from_bits_truncate(self.0)  // 플래그 추출
    }

    pub fn is_present(&self) -> bool {
        self.flags().contains(EntryFlags::PRESENT)
    }
}

설명

이것이 하는 일: 이 코드는 x64 페이지 테이블의 64비트 엔트리를 Rust 타입으로 감싸서, 비트 조작 대신 안전한 메서드로 물리 주소와 플래그를 설정하고 조회할 수 있게 합니다. 첫 번째로, #[repr(transparent)]는 PageTableEntry가 런타임에 u64와 동일한 메모리 레이아웃을 가지도록 합니다.

이렇게 하면 타입 안전성은 얻으면서도 성능 오버헤드는 없습니다. bitflags!

매크로는 EntryFlags를 비트 플래그 집합으로 만들어 contains(), insert(), remove() 같은 메서드를 제공합니다. 왜 이렇게 하는지는 플래그 조합의 의미를 코드로 명확히 표현하기 위함입니다.

그 다음으로, new() 메서드는 물리 주소가 4KB 정렬되었는지 assert!로 검증합니다. 프로덕션에서는 const fn과 const generic으로 컴파일 타임에 검사할 수 있습니다.

물리 주소를 하위 52비트로 마스킹하고(0x000F_FFFF_FFFF_F000), flags.bits()와 OR 연산으로 합칩니다. 내부에서는 단순한 비트 연산이지만, 외부 API는 타입 안전합니다.

세 번째 단계로, physical_address()는 엔트리에서 물리 주소만 추출하여 usize로 반환합니다. flags()는 from_bits_truncate()로 64비트 값에서 플래그를 복원합니다.

is_present()는 PRESENT 비트를 확인하여 페이지가 유효한지 빠르게 판단합니다. 마지막으로 페이지 테이블 순회 코드에서 이 구조체를 사용하면, 엔트리를 읽고 쓸 때 타입 시스템이 실수를 방지합니다.

여러분이 이 코드를 사용하면 페이지 매핑 시 플래그를 명확히 지정할 수 있고(EntryFlags::PRESENT | EntryFlags::WRITABLE), 정렬되지 않은 주소를 전달하면 즉시 패닉으로 버그를 발견합니다. 또한 NX 비트를 쉽게 설정하여 코드 인젝션 공격을 방어할 수 있고, 디버깅 시 Debug 트레잇으로 엔트리 내용을 가독성 있게 출력할 수 있습니다.

실전 팁

💡 페이지 테이블 자체도 페이지 정렬된 메모리에 할당해야 합니다. #[repr(align(4096))]를 사용하거나 alloc_pages()로 할당하세요

💡 재귀 페이지 테이블 기법을 사용하면 마지막 PML4 엔트리가 자기 자신을 가리켜 페이지 테이블을 편리하게 순회할 수 있습니다. 0xFFFF_FF80_0000_0000 같은 고정 주소로 접근 가능합니다

💡 TLB를 무효화하려면 invlpg 명령을 unsafe로 감싼 함수로 제공하세요. 엔트리를 수정한 후 반드시 TLB를 플러시해야 변경사항이 적용됩니다

💡 페이지 폴트 핸들러에서 엔트리를 읽을 때는 Accessed/Dirty 비트를 확인하여 페이지 교체 알고리즘에 활용하세요. CPU가 자동으로 설정합니다

💡 보안을 위해 커널 페이지에는 USER 비트를 절대 설정하지 마세요. 컴파일 타임에 강제하려면 KernelPage와 UserPage 타입을 분리하세요


4. 물리 메모리 할당자 - 비트맵 기반 프레임 할당기 구현

시작하며

여러분이 새로운 페이지 테이블이나 커널 힙을 위해 물리 메모리를 할당하려 할 때 "어떻게 빈 프레임을 효율적으로 찾지?"라는 고민을 해본 적 있나요? OS는 부팅 시 사용 가능한 물리 메모리 영역을 알아내고, 그 중에서 4KB 단위로 페이지 프레임을 할당하고 해제해야 합니다.

단순히 선형 탐색하면 메모리가 크면 클수록 느려집니다. 이런 문제는 실제 커널 개발에서 핵심 성능 이슈입니다.

메모리 할당은 페이지 폴트, 프로세스 생성, I/O 버퍼 할당 등 수시로 발생하므로, 할당자가 느리면 전체 시스템 성능이 떨어집니다. 또한 프래그멘테이션(단편화)을 관리하지 않으면 연속된 큰 메모리를 할당할 수 없게 됩니다.

바로 이럴 때 필요한 것이 비트맵 기반 물리 메모리 할당자입니다. 각 페이지 프레임을 비트 하나로 표현하여(0=비어있음, 1=사용중), 비트 연산으로 빠르게 빈 프레임을 찾고, 작은 메모리 오버헤드로 대용량 메모리를 관리할 수 있습니다.

64비트 워드 단위로 비트스캔하면 탐색 속도도 빠릅니다.

개요

간단히 말해서, 이 개념은 물리 메모리를 4KB 단위로 나누고, 각 프레임의 사용 여부를 비트맵 배열에 기록하여 O(n/64) 시간에 빈 프레임을 찾는 것입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 부트로더나 멀티부트 정보에서 사용 가능한 메모리 영역(예: 1MB~128MB)을 얻으면, 이를 프레임 단위로 추적해야 합니다.

비트맵 방식은 1GB 메모리를 관리하는 데 32KB 정도만 필요하여 오버헤드가 적고, 캐시 친화적입니다. 예를 들어, 페이지 테이블을 위해 연속된 4개의 프레임이 필요하면, 비트맵에서 연속된 4개의 0비트를 찾으면 됩니다.

전통적인 방법과의 비교를 하면, 연결 리스트 기반 free list는 각 프레임마다 포인터를 저장해야 해 메모리 낭비가 컸습니다. 또한 리스트 순회는 캐시 미스가 많아 느렸습니다.

비트맵은 연속된 메모리에 저장되어 프리페칭이 효과적이고, 비트 연산으로 병렬 처리가 가능합니다. 이 개념의 핵심 특징은 첫째, 각 프레임을 1비트로 표현하여 메모리 효율이 높다는 점, 둘째, trailing_zeros() 같은 SIMD 명령어로 빠르게 탐색할 수 있다는 점, 셋째, 할당과 해제가 O(1)~O(n/64)로 예측 가능하다는 점입니다.

이러한 특징들이 안정적인 메모리 관리를 가능하게 합니다.

코드 예제

// src/memory/frame_allocator.rs - 비트맵 기반 프레임 할당자
pub struct BitmapFrameAllocator {
    bitmap: &'static mut [u64],  // 각 비트가 프레임 하나를 나타냄
    start_frame: usize,           // 관리 시작 물리 프레임 번호
    total_frames: usize,
}

impl BitmapFrameAllocator {
    pub fn new(start_paddr: usize, end_paddr: usize, bitmap_ptr: *mut u64) -> Self {
        let start_frame = start_paddr / 4096;
        let total_frames = (end_paddr - start_paddr) / 4096;
        let bitmap_len = (total_frames + 63) / 64;  // 비트맵 워드 개수

        let bitmap = unsafe { core::slice::from_raw_parts_mut(bitmap_ptr, bitmap_len) };
        bitmap.fill(0);  // 모든 프레임을 사용 가능으로 초기화

        Self { bitmap, start_frame, total_frames }
    }

    pub fn allocate_frame(&mut self) -> Option<usize> {
        for (word_idx, word) in self.bitmap.iter_mut().enumerate() {
            if *word != u64::MAX {  // 빈 비트가 있는 워드 찾기
                let bit = word.trailing_ones() as usize;  // 첫 번째 0비트 찾기
                *word |= 1 << bit;  // 해당 비트를 1로 설정 (할당됨)
                return Some(self.start_frame + word_idx * 64 + bit);
            }
        }
        None  // 메모리 부족
    }

    pub fn deallocate_frame(&mut self, frame: usize) {
        let frame_offset = frame - self.start_frame;
        let word_idx = frame_offset / 64;
        let bit = frame_offset % 64;
        self.bitmap[word_idx] &= !(1 << bit);  // 비트를 0으로 (해제됨)
    }
}

설명

이것이 하는 일: 이 코드는 사용 가능한 물리 메모리 범위를 비트맵으로 표현하고, 각 비트가 4KB 프레임의 사용 여부를 나타내어 빠르게 빈 프레임을 할당하고 해제합니다. 첫 번째로, new()는 시작/끝 물리 주소를 받아 관리할 프레임 개수를 계산합니다.

비트맵 길이는 (total_frames + 63) / 64로 계산하여 올림합니다. 64비트 워드 배열을 만들고 fill(0)으로 모든 프레임을 비어있음(0)으로 초기화합니다.

왜 이렇게 하는지는 부팅 시 멀티부트 메모리 맵을 파싱하여 사용 가능한 영역만 비트맵에 포함시키기 위함입니다. 그 다음으로, allocate_frame()은 비트맵을 순회하며 u64::MAX가 아닌 워드(즉, 0비트가 있는 워드)를 찾습니다.

trailing_ones()는 하드웨어 명령어(x86의 BSF)로 첫 번째 0비트의 위치를 O(1)에 찾습니다. 해당 비트를 1로 설정하고, 워드 인덱스와 비트 오프셋으로 실제 프레임 번호를 계산합니다.

내부에서는 CPU의 비트스캔 명령어가 사용되어 매우 빠릅니다. 세 번째 단계로, deallocate_frame()은 프레임 번호를 받아 비트맵 상의 워드 인덱스와 비트 위치를 계산합니다.

!(1 << bit)로 해당 비트만 0으로 만드는 마스크를 만들고 AND 연산으로 비트를 클리어합니다. 이는 원자적 연산이므로 락 없이 안전합니다(단일 비트 조작).

마지막으로 페이지 테이블 할당 코드에서 이 할당자를 호출하여 물리 프레임을 얻고, 해제 시에는 참조 카운트가 0이 되면 deallocate_frame()을 호출합니다. 여러분이 이 코드를 사용하면 16GB 메모리를 512KB 비트맵으로 관리할 수 있어 메모리 오버헤드가 0.003%에 불과합니다.

할당 속도도 워스트 케이스에서 수백 나노초 수준으로 매우 빠르고, 프래그멘테이션 정보를 비트 패턴으로 시각화하기 쉽습니다. 또한 특정 물리 주소 범위(예: DMA 가능 영역)를 별도 비트맵으로 관리하여 제약 조건을 만족시킬 수 있습니다.

실전 팁

💡 UEFI나 멀티부트에서 받은 메모리 맵의 AVAILABLE 영역만 비트맵에 포함하고, 커널 자체가 차지한 프레임은 1로 마킹하세요. 그렇지 않으면 커널 코드를 덮어쓸 수 있습니다

💡 연속된 n개 프레임을 할당하려면 비트맵에서 n개의 연속 0비트를 찾아야 합니다. 바디 알고리즘(buddy allocator)과 결합하면 2^n 크기 블록을 효율적으로 관리할 수 있습니다

💡 멀티코어 환경에서는 각 CPU마다 로컬 프레임 풀을 두고, 비트맵은 글로벌 풀로 사용하세요. 락 경합을 줄여 확장성을 높일 수 있습니다

💡 해제된 프레임의 내용을 0으로 클리어하여 정보 유출을 방지하세요. deallocate_frame() 후 memset(frame_to_virt(frame), 0, 4096)을 호출합니다

💡 비트맵 자체도 커널 가상 주소 공간에 매핑해야 합니다. 부팅 초기에는 BSS 섹션에 정적 배열로 둘 수 있지만, 대용량 메모리에서는 동적으로 할당하세요


5. 커널 힙 초기화 - 동적 메모리 할당을 위한 bump allocator

시작하며

여러분이 커널에서 Box<T>나 Vec<T> 같은 동적 할당 타입을 사용하려 할 때 "GlobalAlloc을 어떻게 구현하지?"라는 막막함을 느낀 적 있나요? Rust의 alloc 크레이트는 표준 라이브러리 없이도 동적 할당을 지원하지만, 백엔드 할당자(allocator)를 직접 제공해야 합니다.

복잡한 할당자를 처음부터 만들면 버그 위험이 큽니다. 이런 문제는 실제 OS 개발 초기 단계에서 빠르게 프로토타입을 만들 때 자주 발생합니다.

커널 초기화 단계에서는 메모리 해제가 거의 없으므로 복잡한 할당자가 필요 없지만, alloc::vec::Vec 같은 편의 기능은 필수적입니다. 너무 이른 최적화는 개발 속도를 늦춥니다.

바로 이럴 때 필요한 것이 bump allocator(범프 할당자)입니다. 메모리 영역의 시작 포인터를 유지하고, 할당 요청이 오면 포인터를 앞으로 "범프"(bump)하여 이동시키는 단순한 방식입니다.

해제는 지원하지 않지만 구현이 매우 간단하고 빠르며, 초기 단계에 충분합니다. 나중에 slab allocator 같은 고급 할당자로 교체할 수 있습니다.

개요

간단히 말해서, 이 개념은 미리 할당된 커널 힙 영역에서 포인터를 순차적으로 증가시키며 메모리를 할당하는 단순 선형 할당자를 GlobalAlloc 트레잇으로 구현하는 것입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 커널 부팅 시 메모리 맵 파싱, 디바이스 트리 로딩, 드라이버 초기화 등에서 동적 할당이 필요하지만, 이 단계는 일회성이라 해제가 불필요합니다.

bump allocator는 할당만 지원하여 오버헤드가 거의 없고(단순 포인터 증가), 메타데이터도 필요 없습니다. 예를 들어, 드라이버 목록을 Vec<DriverInfo>로 만들 때, 부팅 후 이 벡터는 계속 살아있으므로 해제 기능이 없어도 문제없습니다.

전통적인 방법과의 비교를 하면, malloc/free 기반 할당자는 프리 리스트나 버디 시스템으로 복잡하지만, bump allocator는 20줄 정도로 구현 가능합니다. 나중에 런타임 메모리 관리가 중요해지면 linked_list_allocator 크레이트 같은 것으로 교체하면 됩니다.

초기 개발 속도가 크게 빨라집니다. 이 개념의 핵심 특징은 첫째, 할당이 O(1)이고 락도 spinlock 하나로 충분하다는 점, 둘째, 프래그멘테이션이 전혀 없다는 점(순차 할당), 셋째, Rust의 alloc 크레이트와 완벽히 통합된다는 점입니다.

이러한 특징들이 빠른 프로토타입과 점진적 개선을 가능하게 합니다.

코드 예제

// src/memory/bump_allocator.rs - 단순 범프 할당자
use core::alloc::{GlobalAlloc, Layout};
use core::ptr;
use spin::Mutex;

pub struct BumpAllocator {
    heap_start: usize,
    heap_end: usize,
    next: Mutex<usize>,  // 다음 할당 위치 (멀티코어용 락)
}

unsafe impl GlobalAlloc for BumpAllocator {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        let mut next = self.next.lock();

        let alloc_start = align_up(*next, layout.align());  // 요청된 정렬로 올림
        let alloc_end = alloc_start.checked_add(layout.size()).expect("힙 오버플로우");

        if alloc_end > self.heap_end {
            ptr::null_mut()  // 힙 공간 부족
        } else {
            *next = alloc_end;  // 포인터를 앞으로 이동
            alloc_start as *mut u8
        }
    }

    unsafe fn dealloc(&self, _ptr: *mut u8, _layout: Layout) {
        // 범프 할당자는 해제를 지원하지 않음 - 아무 일도 하지 않음
    }
}

fn align_up(addr: usize, align: usize) -> usize {
    (addr + align - 1) & !(align - 1)  // 비트 마스킹으로 정렬
}

#[global_allocator]
static ALLOCATOR: BumpAllocator = BumpAllocator {
    heap_start: 0,  // 초기화 함수에서 설정
    heap_end: 0,
    next: Mutex::new(0),
};

설명

이것이 하는 일: 이 코드는 GlobalAlloc 트레잇을 구현하여 Rust 컴파일러가 Box::new()나 Vec::push() 같은 표준 할당 함수를 이 커널 할당자로 라우팅하게 합니다. 첫 번째로, BumpAllocator 구조체는 heap_start부터 heap_end까지의 연속된 메모리 영역을 관리합니다.

next는 다음 할당 가능한 주소를 가리키며, Mutex로 감싸서 멀티코어 환경에서 동시 접근을 보호합니다. #[global_allocator] 속성은 이 할당자를 전역 할당자로 등록하여 컴파일러가 자동으로 사용하게 합니다.

왜 이렇게 하는지는 alloc 크레이트의 모든 타입이 GlobalAlloc에 의존하기 때문입니다. 그 다음으로, alloc() 메서드는 요청된 Layout(크기와 정렬)을 받아 align_up()으로 next를 정렬합니다.

예를 들어 16바이트 정렬이 필요한 f64 배열이면, next가 16의 배수가 되도록 올립니다. checked_add()로 오버플로우를 감지하고, 힙 끝을 넘으면 null을 반환하여 할당 실패를 알립니다.

내부에서는 단순히 next 값을 반환하고 포인터를 증가시키므로, 락을 제외하면 수십 나노초만 걸립니다. 세 번째 단계로, dealloc()은 비어있습니다.

bump allocator는 해제를 지원하지 않으므로 메모리가 계속 누적됩니다. 하지만 커널 초기화 단계에서는 대부분 한 번 할당된 데이터가 영구적이므로 문제없습니다.

align_up()은 비트 마스킹으로 정렬을 구현합니다. (addr + align - 1)로 올림하고, !(align - 1)로 하위 비트를 0으로 만들어 정렬합니다.

마지막으로 초기화 함수에서 heap_start/heap_end를 설정하면 즉시 Box<[u8]> 같은 타입을 사용할 수 있습니다. 여러분이 이 코드를 사용하면 5분 안에 동적 할당을 활성화하여 개발 속도를 크게 높일 수 있습니다.

alloc::collections::BTreeMap 같은 표준 컬렉션을 바로 쓸 수 있어 복잡한 자료구조 구현을 미룰 수 있고, 나중에 slab allocator로 교체해도 기존 코드는 변경 없이 동작합니다. 테스트 시에도 힙 사용량을 next - heap_start로 간단히 측정할 수 있습니다.

실전 팁

💡 커널 초기화에서 힙 영역을 물리 프레임 할당자로 확보하고 phys_to_virt()로 변환하여 heap_start를 설정하세요. 최소 1MB 정도면 초기 단계에 충분합니다

💡 힙이 꽉 차면 handle_alloc_error()가 호출됩니다. #[alloc_error_handler]로 커스텀 핸들러를 등록하여 패닉 메시지에 힙 사용량을 포함시키세요

💡 나중에 linked_list_allocator로 교체하려면, #[global_allocator] 선언만 바꾸면 됩니다. 기존 코드는 전혀 수정할 필요가 없습니다

💡 멀티코어에서 락 경합이 문제되면 CPU별 bump allocator를 두고, 중앙 할당자는 큰 블록만 관리하는 계층 구조를 만드세요

💡 디버깅 시 할당 추적을 위해 alloc()에서 매 할당마다 로그를 남기세요. 나중에 조건부 컴파일로 제거할 수 있습니다


6. 페이지 폴트 핸들러 - 요구 페이징과 스택 확장 구현

시작하며

여러분이 프로세스가 실행 중 예상치 못한 주소에 접근할 때 "이게 버그인가, 아니면 정상적인 페이지 폴트인가?"를 판단해야 하는 상황을 겪어본 적 있나요? CPU가 매핑되지 않은 가상 주소를 참조하면 페이지 폴트 예외가 발생하는데, 이는 버그일 수도 있지만, 요구 페이징(demand paging)이나 스택 자동 확장 같은 정상 동작일 수도 있습니다.

이런 문제는 실제 OS 개발에서 메모리 관리의 핵심 메커니즘입니다. 모든 페이지를 미리 할당하면 메모리가 낭비되므로, 실제 접근 시점에 페이지를 할당하는 lazy allocation이 효율적입니다.

하지만 페이지 폴트 핸들러가 잘못 작동하면 정상 프로그램도 크래시하거나, 악의적인 접근을 막지 못합니다. 바로 이럴 때 필요한 것이 지능적인 페이지 폴트 핸들러입니다.

CR2 레지스터로 폴트 주소를 읽고, 에러 코드로 원인(권한 위반, 페이지 없음 등)을 파악하여, 정책에 따라 페이지를 할당하거나 프로세스를 종료합니다. 스택 영역이면 자동 확장하고, 코드 영역 쓰기 시도면 SIGSEGV를 보냅니다.

개요

간단히 말해서, 이 개념은 페이지 폴트 예외 핸들러에서 폴트 원인을 분석하고, VMA(Virtual Memory Area) 정책에 따라 물리 프레임을 할당하여 페이지 테이블을 업데이트하거나 프로세스를 종료하는 것입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 프로그램이 큰 배열을 선언해도 실제 사용하는 부분만 물리 메모리를 차지하게 하여 메모리 효율을 극대화할 수 있습니다.

또한 스택은 초기에 작게 시작하고 필요할 때마다 확장하여, 스레드를 많이 만들어도 메모리 낭비가 적습니다. 예를 들어, mmap()으로 1GB 파일을 매핑해도 실제 읽은 페이지만 메모리에 올라가므로 빠르게 시작할 수 있습니다.

전통적인 방법과의 비교를 하면, 초기 Unix는 fork() 시 모든 페이지를 복사했지만, 이제는 CoW(Copy-on-Write)로 페이지 폴트를 활용하여 쓰기 시점에만 복사합니다. 단순한 할당 방식은 메모리를 낭비했지만, 요구 페이징은 실제 사용량만큼만 소비합니다.

이 개념의 핵심 특징은 첫째, CR2 레지스터로 정확한 폴트 주소를 알 수 있다는 점, 둘째, 에러 코드로 읽기/쓰기/실행 구분이 가능하다는 점, 셋째, VMA 정책으로 유연한 메모리 관리를 구현할 수 있다는 점입니다. 이러한 특징들이 현대 OS의 메모리 오버커밋과 가상화를 가능하게 합니다.

코드 예제

// src/interrupts/page_fault.rs - 페이지 폴트 핸들러
use x86_64::registers::control::Cr2;
use x86_64::structures::idt::PageFaultErrorCode;

pub extern "x86-interrupt" fn page_fault_handler(
    stack_frame: InterruptStackFrame,
    error_code: PageFaultErrorCode,
) {
    let fault_addr = Cr2::read().as_u64() as usize;  // 폴트 발생 가상 주소

    // 에러 코드 분석
    let caused_by_write = error_code.contains(PageFaultErrorCode::CAUSED_BY_WRITE);
    let present = error_code.contains(PageFaultErrorCode::PROTECTION_VIOLATION);
    let user_mode = error_code.contains(PageFaultErrorCode::USER_MODE);

    if !present && is_valid_stack_access(fault_addr) {
        // 스택 확장 시도
        if allocate_stack_page(fault_addr).is_ok() {
            return;  // 성공적으로 처리, 명령어 재실행
        }
    }

    // 정상적인 폴트가 아니면 프로세스 종료
    println!("페이지 폴트: addr={:#x}, write={}, user={}",
             fault_addr, caused_by_write, user_mode);
    println!("스택 프레임: {:#?}", stack_frame);
    panic!("복구 불가능한 페이지 폴트");
}

fn is_valid_stack_access(addr: usize) -> bool {
    let current = current_process();
    addr < current.stack_top && addr >= current.stack_top - MAX_STACK_SIZE
}

fn allocate_stack_page(addr: usize) -> Result<(), ()> {
    let page_addr = addr & !0xFFF;  // 4KB 정렬
    let frame = frame_allocator().allocate_frame().ok_or(())?;
    map_page(page_addr, frame, EntryFlags::WRITABLE | EntryFlags::USER)?;
    Ok(())
}

설명

이것이 하는 일: 이 코드는 x86-interrupt 호출 규약으로 페이지 폴트 예외를 받아, 하드웨어가 제공하는 정보를 분석하여 정책에 따라 메모리를 할당하거나 에러를 보고합니다. 첫 번째로, Cr2::read()는 CPU의 CR2 레지스터를 읽어 페이지 폴트를 유발한 정확한 가상 주소를 얻습니다.

이는 하드웨어가 예외 발생 시 자동으로 기록하는 값입니다. error_code는 폴트 원인을 비트 플래그로 알려줍니다.

CAUSED_BY_WRITE는 쓰기 시도, PROTECTION_VIOLATION은 페이지가 존재하지만 권한 위반, USER_MODE는 유저 모드에서 발생했음을 나타냅니다. 왜 이렇게 하는지는 폴트 원인에 따라 다른 정책을 적용하기 위함입니다.

그 다음으로, !present는 페이지가 매핑되지 않았음을 의미하므로 정상적인 요구 페이징 후보입니다. is_valid_stack_access()는 폴트 주소가 현재 프로세스의 스택 범위 내인지 확인합니다.

스택은 아래로 성장하므로 stack_top에서 MAX_STACK_SIZE 범위를 체크합니다. 내부에서는 프로세스 구조체의 VMA(Virtual Memory Area) 목록을 검색할 수도 있습니다.

세 번째 단계로, allocate_stack_page()는 폴트 주소를 4KB 경계로 내림하고, 물리 프레임 할당자로 빈 프레임을 얻어 페이지 테이블에 매핑합니다. WRITABLE | USER 플래그로 유저 모드 쓰기를 허용합니다.

매핑이 성공하면 핸들러가 리턴하고 CPU는 폴트를 유발한 명령어를 자동으로 재실행합니다. 이번에는 페이지가 존재하므로 정상 동작합니다.

마지막으로 복구 불가능한 폴트(예: NULL 포인터 역참조)는 panic!으로 프로세스를 종료합니다. 여러분이 이 코드를 사용하면 프로세스가 malloc(1GB)를 호출해도 즉시 반환되고, 실제 접근 시점에 페이지 폴트로 메모리가 할당됩니다.

스택도 초기 크기를 작게 설정해 스레드를 많이 만들 수 있고, 재귀 호출이 깊어지면 자동으로 확장됩니다. 디버깅 시에도 폴트 주소와 스택 프레임을 출력하여 버그 위치를 빠르게 파악할 수 있습니다.

실전 팁

💡 스택 가드 페이지를 구현하려면 스택 하단에 매핑되지 않은 페이지를 두세요. 스택 오버플로우 시 페이지 폴트로 즉시 감지할 수 있습니다

💡 CoW(Copy-on-Write)를 구현하려면 페이지를 읽기 전용으로 매핑하고, 쓰기 시도 시 폴트 핸들러에서 복사 후 쓰기 가능으로 변경하세요

💡 성능을 위해 핸들러 안에서는 최소한의 작업만 하고, 복잡한 정책은 인터럽트 활성화 후 처리하세요. 그렇지 않으면 인터럽트 지연이 커집니다

💡 mmap() 영역은 VMA 트리로 관리하여 O(log n)에 폴트 주소가 유효한지 검색하세요. RB-tree나 interval tree가 적합합니다

💡 INSTRUCTION_FETCH 비트로 실행 시도를 감지하여, 데이터 영역에서 코드 실행을 막는 DEP(Data Execution Prevention)를 구현할 수 있습니다


7. TLB 관리 - 페이지 테이블 변경 시 캐시 무효화

시작하며

여러분이 페이지 테이블을 수정한 후 "왜 새 매핑이 반영되지 않지?"라는 의문을 가진 적 있나요? 페이지 테이블 엔트리를 업데이트했는데 CPU가 여전히 이전 주소 변환을 사용하거나, 페이지를 해제했는데 접근이 되는 이상한 현상이 발생합니다.

이는 페이지 테이블 자체는 메모리에 변경되었지만, CPU 내부의 TLB(Translation Lookaside Buffer) 캐시가 낡은 정보를 가지고 있기 때문입니다. 이런 문제는 실제 OS 개발에서 매우 미묘하고 찾기 어려운 버그의 원인입니다.

TLB는 페이지 테이블 워킹의 성능 오버헤드를 줄이기 위한 하드웨어 캐시인데, 소프트웨어가 페이지 테이블을 변경하면 TLB와 불일치가 발생합니다. 멀티코어 시스템에서는 다른 CPU의 TLB도 무효화해야 하므로 더 복잡합니다.

바로 이럴 때 필요한 것이 명시적인 TLB 무효화(flush/invalidation)입니다. x86은 invlpg 명령어로 특정 페이지의 TLB 엔트리를 무효화하거나, CR3 레지스터 재로드로 전체 TLB를 플러시할 수 있습니다.

멀티코어에서는 IPI(Inter-Processor Interrupt)로 다른 CPU에게 TLB 플러시를 요청해야 합니다.

개요

간단히 말해서, 이 개념은 페이지 테이블을 수정한 후 invlpg나 CR3 재로드로 CPU의 TLB 캐시를 무효화하여 새로운 매핑이 즉시 적용되도록 하는 것입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, TLB 미스 시 페이지 테이블 워킹은 수십 사이클이 걸리므로 TLB 히트율이 성능에 큰 영향을 줍니다.

하지만 페이지 매핑을 변경한 후 TLB를 플러시하지 않으면, CPU는 낡은 캐시를 보고 잘못된 물리 주소에 접근하여 데이터 손상이나 보안 취약점이 발생합니다. 예를 들어, 프로세스가 종료되어 페이지를 해제했는데 TLB에 남아있으면, 다른 프로세스가 그 페이지를 할당받았을 때 정보 유출이 가능합니다.

전통적인 방법과의 비교를 하면, 초기 아키텍처는 소프트웨어가 TLB를 직접 관리했지만(MIPS 등), x86은 하드웨어 페이지 워킹이므로 소프트웨어는 무효화만 요청합니다. 하지만 무효화 비용이 크므로(특히 멀티코어), 배치 무효화 기법이 중요합니다.

이 개념의 핵심 특징은 첫째, invlpg는 단일 페이지만 무효화하여 비용이 적다는 점, 둘째, CR3 재로드는 전체 플러시지만 프로세스 전환 시 필요하다는 점, 셋째, PCID(Process Context ID)로 TLB 플러시를 줄일 수 있다는 점입니다. 이러한 특징들이 메모리 관리 성능을 좌우합니다.

코드 예제

// src/memory/tlb.rs - TLB 무효화 함수들
use x86_64::instructions::tlb;
use x86_64::VirtAddr;

/// 단일 페이지의 TLB 엔트리 무효화
pub fn flush_page(vaddr: VirtAddr) {
    unsafe {
        tlb::flush(vaddr);  // invlpg 명령어 래퍼
    }
}

/// 전체 TLB 플러시 (CR3 재로드)
pub fn flush_all() {
    use x86_64::registers::control::Cr3;
    let (frame, flags) = Cr3::read();
    unsafe {
        Cr3::write(frame, flags);  // CR3에 같은 값 쓰기로 TLB 플러시
    }
}

/// 페이지 매핑 후 TLB 무효화
pub fn map_page_and_flush(vaddr: VirtAddr, paddr: usize, flags: EntryFlags) {
    // 페이지 테이블에 엔트리 쓰기
    let page_table = current_page_table();
    page_table.map(vaddr, paddr, flags);

    // TLB 무효화 - 필수!
    flush_page(vaddr);

    // 멀티코어 시스템에서는 다른 CPU에게도 알림 필요
    #[cfg(feature = "smp")]
    send_tlb_shootdown_ipi(vaddr);
}

/// 범위 무효화 - 여러 페이지를 한 번에 처리
pub fn flush_range(start: VirtAddr, end: VirtAddr) {
    let page_count = (end.as_u64() - start.as_u64()) / 4096;

    if page_count > 32 {
        flush_all();  // 많으면 전체 플러시가 더 빠름
    } else {
        let mut addr = start;
        while addr < end {
            flush_page(addr);
            addr += 4096u64;
        }
    }
}

설명

이것이 하는 일: 이 코드는 x86_64 명령어를 래핑하여 페이지 테이블 변경과 TLB 무효화를 원자적으로 수행하고, 멀티코어 환경에서 일관성을 보장합니다. 첫 번째로, flush_page()는 x86의 invlpg 명령어를 실행하여 지정된 가상 주소의 TLB 엔트리만 무효화합니다.

이 명령어는 마이크로초 수준으로 빠르며, 다른 페이지의 TLB는 유지되므로 성능 영향이 적습니다. unsafe가 필요한 이유는 잘못된 시점에 호출하면 메모리 불일치가 발생할 수 있기 때문입니다.

왜 이렇게 하는지는 단일 페이지 매핑 변경 시 전체 TLB를 플러시하는 것은 낭비이기 때문입니다. 그 다음으로, flush_all()은 CR3 레지스터에 현재 값을 다시 쓰는 방식으로 전체 TLB를 플러시합니다.

CR3는 페이지 테이블 루트를 가리키는데, 이 레지스터를 쓰면 CPU는 모든 TLB 엔트리를 무효화합니다. 프로세스 전환 시 페이지 테이블이 완전히 바뀌므로 전체 플러시가 필요합니다.

내부에서는 수백 사이클이 걸리지만, 프로세스 전환 오버헤드에 비하면 작습니다. 세 번째 단계로, map_page_and_flush()는 페이지 매핑과 TLB 무효화를 함께 수행하는 헬퍼 함수입니다.

이렇게 묶으면 무효화를 깜빡할 위험이 없습니다. send_tlb_shootdown_ipi()는 멀티코어 환경에서 다른 CPU들에게 IPI를 보내 그들의 TLB도 무효화하도록 요청합니다.

그렇지 않으면 다른 코어가 낡은 TLB로 잘못된 메모리에 접근할 수 있습니다. flush_range()는 여러 페이지를 무효화할 때 최적화를 적용합니다.

32페이지 이상이면 invlpg를 반복하는 것보다 전체 플러시가 더 빠릅니다. 여러분이 이 코드를 사용하면 페이지 테이블 변경 후 즉시 새 매핑이 적용되어 버그를 방지할 수 있습니다.

성능 최적화를 위해 batch update를 구현할 때도, 마지막에 한 번만 TLB를 플러시하여 오버헤드를 줄일 수 있습니다. 디버깅 시에는 TLB 플러시 로그를 남겨 어느 코드 경로가 자주 플러시하는지 분석할 수 있습니다.

실전 팁

💡 PCID(Process Context Identifier) 기능이 있으면 프로세스마다 고유 ID를 할당하여 전환 시 TLB를 플러시하지 않을 수 있습니다. CPUID로 지원 여부를 확인하세요

💡 커널 페이지는 모든 프로세스에서 동일하므로 플러시하지 않는 것이 효율적입니다. Global 플래그를 설정하면 CR3 재로드 시에도 TLB가 유지됩니다

💡 TLB shootdown IPI는 비용이 크므로, 여러 변경을 모아서 한 번에 IPI를 보내세요. 비트맵으로 무효화할 페이지를 추적할 수 있습니다

💡 invpcid 명령어는 더 세밀한 TLB 제어를 제공합니다. 특정 PCID의 엔트리만 무효화하거나 글로벌 엔트리만 플러시할 수 있습니다

💡 성능 카운터로 TLB 미스율을 측정하세요. perf stat -e dTLB-load-misses로 리눅스에서 확인 가능하며, 커널도 유사한 방법을 쓸 수 있습니다


8. 커널 스택 관리 - 인터럽트와 시스템 콜을 위한 스택 전환

시작하며

여러분이 유저 프로세스가 시스템 콜을 호출할 때 "커널 코드는 어느 스택을 쓰지?"라는 궁금증을 가진 적 있나요? 유저 스택을 그대로 쓰면 유저가 스택 포인터를 조작하여 커널을 공격할 수 있고, 스택이 매핑되지 않았거나 작으면 커널이 페이지 폴트를 일으킵니다.

또한 인터럽트 발생 시 유저 스택이 유효하지 않을 수 있습니다. 이런 문제는 실제 OS 보안의 핵심입니다.

유저 모드에서 커널 모드로 전환할 때 스택도 함께 전환하지 않으면, 공격자가 스택을 조작하여 권한 상승을 시도할 수 있습니다. 또한 멀티코어 시스템에서는 각 CPU마다 독립적인 커널 스택이 필요하여 동시성을 보장해야 합니다.

바로 이럴 때 필요한 것이 커널 스택 전환 메커니즘입니다. x86_64는 TSS(Task State Segment)의 RSP0 필드에 커널 스택 포인터를 저장하고, 권한 레벨 변경 시 하드웨어가 자동으로 스택을 전환합니다.

각 CPU마다 TSS를 설정하고, 프로세스 전환 시 TSS의 RSP0를 업데이트하면 안전한 스택 격리가 보장됩니다.

개요

간단히 말해서, 이 개념은 TSS 구조체를 설정하여 CPU가 권한 레벨 변경(유저→커널) 시 자동으로 안전한 커널 스택으로 전환하도록 하는 것입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 시스템 콜이나 인터럽트 진입 시 CPU는 RIP, CS, RFLAGS, RSP, SS를 자동으로 스택에 푸시하는데, 이때 스택이 유효하지 않으면 더블 폴트가 발생합니다.

TSS의 RSP0에 미리 할당된 커널 스택 주소를 설정해두면, 하드웨어가 자동으로 전환하여 안전성을 보장합니다. 예를 들어, 유저 프로그램이 syscall 명령어를 실행하면, CPU는 TSS.RSP0로 스택을 바꾸고 커널 엔트리 포인트로 점프합니다.

전통적인 방법과의 비교를 하면, 32비트 모드는 TSS가 하드웨어 태스크 전환에 사용되었지만, 64비트에서는 스택 포인터 저장소로만 사용됩니다. 소프트웨어가 컨텍스트 스위칭을 담당하므로 더 유연하지만, TSS 설정은 여전히 필수입니다.

이 개념의 핵심 특징은 첫째, 하드웨어가 자동으로 스택을 전환하여 소프트웨어 실수를 방지한다는 점, 둘째, IST(Interrupt Stack Table)로 특정 예외마다 별도 스택을 지정할 수 있다는 점, 셋째, 프로세스별 커널 스택을 두어 동시성을 보장한다는 점입니다. 이러한 특징들이 격리와 안정성을 제공합니다.

코드 예제

// src/interrupts/tss.rs - TSS 설정과 스택 전환
use x86_64::structures::tss::TaskStateSegment;
use x86_64::VirtAddr;

pub const DOUBLE_FAULT_IST_INDEX: u16 = 0;  // IST 테이블 인덱스

lazy_static! {
    static ref TSS: TaskStateSegment = {
        let mut tss = TaskStateSegment::new();

        // 더블 폴트용 전용 스택 (IST)
        tss.interrupt_stack_table[DOUBLE_FAULT_IST_INDEX as usize] = {
            const STACK_SIZE: usize = 4096 * 5;
            static mut STACK: [u8; STACK_SIZE] = [0; STACK_SIZE];
            let stack_start = VirtAddr::from_ptr(unsafe { &STACK });
            stack_start + STACK_SIZE  // 스택은 아래로 성장하므로 끝 주소
        };

        // 커널 스택 (시스템 콜, 인터럽트용) - 프로세스 전환 시 업데이트됨
        tss.privilege_stack_table[0] = {
            const STACK_SIZE: usize = 4096 * 10;
            static mut KERNEL_STACK: [u8; STACK_SIZE] = [0; STACK_SIZE];
            let stack_start = VirtAddr::from_ptr(unsafe { &KERNEL_STACK });
            stack_start + STACK_SIZE
        };

        tss
    };
}

/// 프로세스 전환 시 호출 - 새 프로세스의 커널 스택으로 TSS 업데이트
pub fn set_kernel_stack(stack_top: VirtAddr) {
    use x86_64::instructions::tables::load_tss;
    // 실제로는 CPU 로컬 TSS를 수정해야 함
    // 여기서는 개념적 코드
    unsafe {
        let tss_ptr = &TSS as *const _ as *mut TaskStateSegment;
        (*tss_ptr).privilege_stack_table[0] = stack_top;
    }
}

설명

이것이 하는 일: 이 코드는 x86_64의 TSS 구조체를 초기화하여 권한 레벨 변경과 특정 예외 발생 시 CPU가 사용할 스택 주소를 미리 지정합니다. 첫 번째로, TaskStateSegment::new()로 TSS를 생성하고, interrupt_stack_table(IST)에 더블 폴트용 전용 스택을 할당합니다.

더블 폴트는 스택 자체가 문제일 때 발생할 수 있으므로 별도 스택이 필수입니다. 스택은 아래로 성장하므로 배열의 끝 주소(stack_start + STACK_SIZE)를 TSS에 저장합니다.

왜 이렇게 하는지는 하드웨어가 이 주소를 RSP에 로드하기 때문입니다. 그 다음으로, privilege_stack_table[0]는 링 0(커널 모드)의 스택 포인터를 저장합니다.

유저 프로세스(링 3)가 시스템 콜을 호출하면, CPU는 자동으로 이 주소를 RSP에 로드하고 유저 스택 정보(SS, RSP)를 커널 스택에 푸시합니다. 이 과정은 완전히 하드웨어가 처리하여 소프트웨어 개입 없이 안전합니다.

내부에서는 단순히 레지스터 복사와 메모리 쓰기만 일어나므로 오버헤드가 적습니다. 세 번째 단계로, set_kernel_stack()은 프로세스 컨텍스트 스위칭 시 호출되어 TSS의 커널 스택 포인터를 새 프로세스의 것으로 업데이트합니다.

각 프로세스는 독립적인 커널 스택을 가지므로, 프로세스 A가 시스템 콜 중일 때 프로세스 B로 전환되어도 스택이 섞이지 않습니다. 멀티코어에서는 각 CPU마다 별도 TSS를 GDT에 등록하여 동시 실행을 지원합니다.

마지막으로 IDT 설정 시 더블 폴트 핸들러의 IST 인덱스를 지정하면, 더블 폴트 발생 시 자동으로 전용 스택이 사용됩니다. 여러분이 이 코드를 사용하면 유저 프로그램이 스택 오버플로우를 일으켜도 커널 스택은 안전하게 유지되어 복구가 가능합니다.

공격자가 유저 스택을 조작해도 커널 코드는 영향을 받지 않아 보안이 강화됩니다. 디버깅 시에도 커널 스택과 유저 스택을 명확히 구분하여 백트레이스를 분석할 수 있습니다.

실전 팁

💡 GDT(Global Descriptor Table)에 TSS 디스크립터를 추가하고 ltr 명령어로 로드해야 TSS가 활성화됩니다. 부팅 시 한 번만 수행하면 됩니다

💡 각 CPU 코어마다 별도 TSS를 할당하여 gs 레지스터 기반 per-CPU 변수로 관리하세요. 이렇게 하면 락 없이 CPU 로컬 데이터에 접근할 수 있습니다

💡 커널 스택 크기는 보통 16KB~32KB입니다. 너무 작으면 깊은 호출 체인에서 오버플로우가 발생하고, 너무 크면 메모리 낭비입니다. 스택 가드 페이지로 오버플로우를 감지하세요

💡 IST는 7개 엔트리를 지원합니다. 더블 폴트 외에 NMI(Non-Maskable Interrupt)나 머신 체크 예외에도 별도 스택을 할당하면 안정성이 높아집니다

💡 프로세스 전환 시 TSS 업데이트를 깜빡하면 이전 프로세스의 커널 스택을 침범할 수 있습니다. 어서션으로 현재 스택 포인터가 유효한지 확인하세요


9. 메모리 매핑 I/O - MMIO 영역 관리와 캐싱 비활성화

시작하며

여러분이 디바이스 레지스터에 값을 쓴 후 "왜 하드웨어가 반응하지 않지?"라는 문제를 겪어본 적 있나요? PCIe 디바이스나 UART 같은 하드웨어는 메모리 주소로 매핑되어 있지만, 일반 메모리처럼 캐싱하면 CPU는 실제 하드웨어 대신 캐시를 읽어 잘못된 동작을 합니다.

또한 컴파일러가 메모리 접근을 최적화로 생략하거나 재배치할 수 있습니다. 이런 문제는 실제 디바이스 드라이버 개발에서 필수적으로 다뤄야 하는 이슈입니다.

MMIO(Memory-Mapped I/O) 영역은 일반 RAM과 다르게 부작용(side-effect)이 있어서, 읽기만으로도 하드웨어 상태가 바뀌거나, 쓰기 순서가 중요합니다. 캐싱을 활성화하면 여러 쓰기가 합쳐지거나, 오래된 값을 읽게 되어 디바이스 오작동이 발생합니다.

바로 이럴 때 필요한 것이 페이지 테이블의 캐싱 플래그 제어입니다. MMIO 영역을 매핑할 때 NO_CACHE와 WRITE_THROUGH 플래그를 설정하여 CPU가 캐시를 우회하고 직접 하드웨어에 접근하게 합니다.

또한 Rust의 volatile 접근으로 컴파일러 최적화를 막아 정확한 순서를 보장합니다.

개요

간단히 말해서, 이 개념은 디바이스 레지스터가 매핑된 물리 주소를 페이지 테이블에 등록할 때 캐싱을 비활성화하고, volatile 포인터로 접근하여 하드웨어와 직접 통신하는 것입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, UART 송신 레지스터에 값을 쓰면 하드웨어가 즉시 시리얼 포트로 전송하는데, 캐시에만 쓰고 실제 하드웨어에 도달하지 않으면 데이터가 전송되지 않습니다.

또한 인터럽트 상태 레지스터를 읽으면 하드웨어가 내부 상태를 클리어하는데, 캐시에서 읽으면 이런 부작용이 발생하지 않아 인터럽트가 계속 트리거됩니다. 예를 들어, NVMe 컨트롤러의 doorbell 레지스터는 쓰기 순서가 중요한데, 캐시가 재배치하면 커맨드 큐가 꼬입니다.

전통적인 방법과의 비교를 하면, 일부 아키텍처는 별도의 I/O 주소 공간(예: x86의 in/out 명령어)을 제공했지만, 현대 시스템은 대부분 MMIO로 통합되어 더 큰 주소 공간과 표준 로드/스토어 명령어를 활용합니다. 다만 캐싱 제어는 필수입니다.

이 개념의 핵심 특징은 첫째, 페이지 테이블 플래그로 캐싱 정책을 세밀하게 제어한다는 점, 둘째, volatile 접근으로 컴파일러 최적화를 막는다는 점, 셋째, PAT(Page Attribute Table)로 더 다양한 캐싱 모드를 지원한다는 점입니다. 이러한 특징들이 하드웨어 제어의 정확성을 보장합니다.

코드 예제

// src/memory/mmio.rs - MMIO 영역 매핑과 접근
use core::ptr::{read_volatile, write_volatile};
use x86_64::structures::paging::PageTableFlags;

/// MMIO 영역을 커널 가상 주소로 매핑
pub fn map_mmio(phys_addr: usize, size: usize) -> Result<*mut u8, ()> {
    let virt_addr = phys_to_virt(phys_addr);  // 커널 직접 매핑 영역 사용
    let page_count = (size + 4095) / 4096;

    for i in 0..page_count {
        let page_vaddr = virt_addr + i * 4096;
        let page_paddr = phys_addr + i * 4096;

        // 캐싱 비활성화 플래그 설정
        let flags = PageTableFlags::PRESENT
                  | PageTableFlags::WRITABLE
                  | PageTableFlags::NO_CACHE       // 캐시 비활성화
                  | PageTableFlags::WRITE_THROUGH; // 라이트스루

        map_page(page_vaddr, page_paddr, flags)?;
    }

    Ok(virt_addr as *mut u8)
}

/// MMIO 레지스터 읽기 - volatile 접근
pub unsafe fn mmio_read<T>(addr: *const T) -> T {
    read_volatile(addr)  // 컴파일러 최적화 방지
}

/// MMIO 레지스터 쓰기 - volatile 접근
pub unsafe fn mmio_write<T>(addr: *mut T, value: T) {
    write_volatile(addr, value);
}

/// 사용 예시: UART 드라이버
pub struct Uart {
    base: *mut u8,
}

impl Uart {
    pub fn new(phys_addr: usize) -> Result<Self, ()> {
        let base = map_mmio(phys_addr, 4096)?;
        Ok(Uart { base })
    }

    pub fn write_byte(&mut self, byte: u8) {
        unsafe {
            mmio_write(self.base.add(0), byte);  // 송신 레지스터 오프셋 0
        }
    }
}

설명

이것이 하는 일: 이 코드는 디바이스의 물리 주소를 커널 가상 주소로 매핑하면서 페이지 테이블에 캐싱 비활성화 플래그를 설정하고, volatile 함수로 컴파일러 최적화를 방지하여 정확한 하드웨어 접근을 보장합니다. 첫 번째로, map_mmio()는 물리 주소와 크기를 받아 페이지 단위로 매핑합니다.

phys_to_virt()로 커널의 직접 매핑 영역에 주소를 할당하여, 별도 가상 주소 할당 없이 고정 오프셋만 더합니다. PageTableFlags::NO_CACHE는 CPU가 이 페이지를 캐시하지 않도록 하고, WRITE_THROUGH는 쓰기가 즉시 메모리(실제로는 디바이스)에 도달하게 합니다.

왜 이렇게 하는지는 디바이스 레지스터는 읽을 때마다 다른 값을 반환하거나, 쓸 때마다 동작을 트리거하기 때문입니다. 그 다음으로, mmio_read()와 mmio_write()는 core::ptr의 volatile 함수를 래핑하여 제네릭 타입으로 다양한 레지스터 크기(u8, u16, u32, u64)를 지원합니다.

read_volatile()은 컴파일러에게 "이 읽기는 부작용이 있으니 최적화로 제거하거나 재배치하지 마"라고 알립니다. write_volatile()도 마찬가지로 쓰기 순서를 보장합니다.

내부에서는 일반 로드/스토어 명령어지만, 컴파일러가 메모리 배리어를 삽입하여 순서를 유지합니다. 세 번째 단계로, Uart 구조체는 MMIO 기반 드라이버의 예시입니다.

new()에서 물리 주소를 매핑하고, write_byte()에서 base 포인터의 오프셋 0(송신 레지스터)에 volatile 쓰기를 수행합니다. 여러 레지스터는 add() 메서드로 오프셋을 더해 접근합니다.

마지막으로 이 패턴을 다른 디바이스(PCIe, GPIO, 타이머 등)에도 적용하여 일관된 드라이버 인터페이스를 만들 수 있습니다. 여러분이 이 코드를 사용하면 디바이스 레지스터 접근이 항상 정확하게 하드웨어에 도달하여 오작동을 방지할 수 있습니다.

DMA 버퍼 같은 공유 메모리도 WRITE_COMBINE 캐싱 모드로 매핑하여 성능을 높일 수 있고, 디버깅 시 mmio_read/write 함수에 로그를 추가하여 모든 레지스터 접근을 추적할 수 있습니다.

실전 팁

💡 PAT(Page Attribute Table) MSR을 설정하면 NO_CACHE 외에 WRITE_COMBINE, WRITE_PROTECT 같은 세밀한 캐싱 모드를 사용할 수 있습니다. DMA 성능에 큰 영향을 줍니다

💡 일부 디바이스는 메모리 배리어가 필요합니다. fence(Ordering::SeqCst) 같은 atomic fence를 volatile 쓰기 후 추가하여 순서를 강제하세요

💡 MMIO 주소는 ACPI 테이블, PCI configuration space, 디바이스 트리 등에서 얻을 수 있습니다. 하드코딩하지 말고 동적으로 파싱하세요

💡 64비트 레지스터는 두 번의 32비트 접근으로 읽으면 중간에 값이 바뀔 수 있습니다. 가능하면 64비트 atomic 명령어나 하드웨어 락을 사용하세요

💡 강제 캐시 플러시가 필요한 경우 clflush 명령어를 사용하세요. 일부 디바이스는 coherency가 없어 캐시를 수동으로 관리해야 합니다


10. 메모리 보호 확장 - NX 비트와 SMAP/SMEP 활성화

시작하며

여러분이 버퍼 오버플로우 공격을 방어할 때 "데이터 영역에서 코드 실행을 어떻게 막지?"라는 고민을 해본 적 있나요? 공격자가 스택이나 힙에 악성 코드를 주입하고, 리턴 주소를 조작하여 그곳으로 점프하면 임의 코드가 실행됩니다.

전통적으로 x86은 읽기 가능한 페이지는 자동으로 실행 가능했습니다. 이런 문제는 실제 보안에서 ROP(Return-Oriented Programming) 같은 고급 공격의 기초입니다.

데이터와 코드를 구분하지 않으면 모든 메모리가 잠재적 공격 벡터가 되고, 커널 취약점 하나로 전체 시스템이 장악됩니다. 또한 유저 공간 메모리를 커널이 직접 참조하는 것도 Meltdown 같은 공격에 악용될 수 있습니다.

바로 이럴 때 필요한 것이 하드웨어 메모리 보호 기능입니다. NX(No-Execute) 비트는 페이지를 실행 불가능으로 표시하고, SMEP(Supervisor Mode Execution Prevention)는 커널이 유저 페이지를 실행하지 못하게 하며, SMAP(Supervisor Mode Access Prevention)은 명시적 허용 없이 유저 메모리 접근을 막습니다.

이러한 기능들을 활성화하면 공격 표면이 크게 줄어듭니다.

개요

간단히 말해서, 이 개념은 페이지 테이블의 NX 비트와 CPU 제어 레지스터(CR4)를 설정하여 데이터 실행 방지(DEP)와 유저 메모리 접근 제한을 하드웨어 수준에서 강제하는 것입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 스택과 힙은 데이터 저장소이지 코드 영역이 아닙니다.

NX 비트를 설정하면 CPU가 자동으로 실행 시도를 페이지 폴트로 막아 셸코드 인젝션을 방어합니다. SMEP은 커널 버그로 유저 제어 포인터를 실행하는 것을 막고, SMAP은 유저 포인터를 검증 없이 역참조하는 커널 버그를 잡아냅니다.

예를 들어, 시스템 콜에서 유저가 전달한 포인터를 그대로 읽으면 SMAP 위반으로 패닉하여 취약점을 즉시 노출시킵니다. 전통적인 방법과의 비교를 하면, 과거에는 소프트웨어 시뮬레이션으로 실행 권한을 추적했지만 오버헤드가 컸습니다.

NX 비트는 x86_64에서 표준이 되어 성능 영향 없이 보호를 제공하고, SMEP/SMAP은 비교적 최근(Ivy Bridge 이후)에 추가되어 커널 강화의 핵심 도구가 되었습니다. 이 개념의 핵심 특징은 첫째, NX는 페이지 테이블 엔트리의 비트 63으로 간단히 활성화된다는 점, 둘째, SMEP/SMAP은 CR4 레지스터 하나로 전역 활성화된다는 점, 셋째, STAC/CLAC 명령어로 일시적으로 SMAP을 우회할 수 있다는 점입니다.

이러한 특징들이 보안과 편의성을 모두 제공합니다.

코드 예제

// src/memory/protection.rs - 메모리 보호 기능 활성화
use x86_64::registers::control::{Cr4, Cr4Flags, Efer, EferFlags};

/// NX 비트 활성화 (EFER.NXE)
pub fn enable_no_execute() {
    unsafe {
        Efer::update(|flags| {
            *flags |= EferFlags::NO_EXECUTE_ENABLE;
        });
    }
}

/// SMEP/SMAP 활성화 (CR4)
pub fn enable_smep_smap() {
    unsafe {
        Cr4::update(|flags| {
            if supports_smep() {
                *flags |= Cr4Flags::SUPERVISOR_MODE_EXECUTION_PROTECTION;
            }
            if supports_smap() {
                *flags |= Cr4Flags::SUPERVISOR_MODE_ACCESS_PREVENTION;
            }
        });
    }
}

/// 기능 지원 여부 확인 (CPUID)
fn supports_smep() -> bool {
    use raw_cpuid::CpuId;
    CpuId::new()
        .get_extended_feature_info()
        .map_or(false, |f| f.has_smep())
}

/// 유저 메모리 접근 시 일시적으로 SMAP 해제
pub fn copy_from_user<T>(user_ptr: *const T) -> Result<T, ()> {
    unsafe {
        stac();  // SMAP 일시 해제 (STAC 명령어)
        let value = core::ptr::read_volatile(user_ptr);
        clac();  // SMAP 재활성화 (CLAC 명령어)
        Ok(value)
    }
}

/// 페이지 매핑 시 NX 설정
pub fn map_data_page(vaddr: usize, paddr: usize) {
    let flags = EntryFlags::PRESENT
              | EntryFlags::WRITABLE
              | EntryFlags::NO_EXECUTE;  // 데이터 페이지는 실행 불가
    map_page(vaddr, paddr, flags).unwrap();
}

설명

이것이 하는 일: 이 코드는 CPU의 확장 기능 레지스터(EFER, CR4)를 설정하여 하드웨어 메모리 보호를 활성화하고, 페이지 매핑 시 적절한 플래그를 사용하여 정책을 강제합니다. 첫 번째로, enable_no_execute()는 EFER MSR의 NXE 비트를 켜서 NX 기능을 활성화합니다.

이후 페이지 테이블 엔트리의 비트 63(NO_EXECUTE)이 1이면 CPU는 그 페이지에서 명령어 페치를 시도할 때 페이지 폴트를 발생시킵니다. 왜 이렇게 하는지는 스택, 힙, 데이터 섹션은 실행될 이유가 없으므로 기본적으로 NX를 설정해야 하기 때문입니다.

코드 섹션만 실행 가능하도록 합니다. 그 다음으로, enable_smep_smap()은 CPUID로 CPU 지원 여부를 확인한 후 CR4 레지스터의 해당 비트를 켭니다.

SMEP은 CPL=0(커널 모드)일 때 USER 비트가 설정된 페이지에서 명령어를 페치하려 하면 페이지 폴트를 일으킵니다. SMAP은 유저 페이지의 데이터를 읽거나 쓰려 할 때도 폴트를 발생시킵니다.

내부에서는 단순한 비트 플래그 설정이지만, 이후 모든 메모리 접근에 하드웨어 체크가 적용됩니다. 세 번째 단계로, copy_from_user()는 시스템 콜에서 유저 포인터를 안전하게 읽는 예시입니다.

stac() 명령어(Set AC flag)로 RFLAGS.AC를 켜면 SMAP이 일시적으로 비활성화되어 유저 메모리 접근이 가능합니다. 읽기 후 clac()로 즉시 재활성화하여 최소 권한 원칙을 지킵니다.

이렇게 명시적으로 표시함으로써 의도하지 않은 유저 메모리 접근을 방지합니다. map_data_page()는 데이터 영역 매핑 시 NO_EXECUTE를 자동으로 설정하는 헬퍼입니다.

마지막으로 페이지 폴트 핸들러에서 INSTRUCTION_FETCH 비트를 확인하여 NX 위반을 탐지하고 프로세스를 종료합니다. 여러분이 이 코드를 사용하면 버퍼 오버플로우 익스플로잇이 셸코드를 실행하지 못해 공격이 차단됩니다.

ROP 체인도 일부 방어되고(코드 영역만 실행 가능), 커널 버그로 유저 제어 주소를 실행하거나 접근하는 것도 즉시 패닉으로 드러납니다. 이는 취약점 개발 단계에서 버그를 빠르게 발견하게 하여 보안 품질을 높입니다.

실전 팁

💡 NX를 활성화하면 트램폴린 코드나 JIT 컴파일 영역은 명시적으로 실행 가능으로 매핑해야 합니다. mprotect() 같은 인터페이스로 동적 권한 변경을 지원하세요

💡 SMAP 위반을 디버깅할 때는 페이지 폴트 에러 코드의 USER_MODE와 PROTECTION_VIOLATION 비트를 확인하세요. SMAP 위반은 특유의 패턴을 가집니다

💡 copy_from_user() 같은 함수는 페이지 경계를 넘을 수 있으므로 여러 페이지에 걸쳐 복사할


#Rust#KernelDevelopment#MemoryLayout#SystemProgramming#OSdev#시스템프로그래밍

댓글 (0)

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