이미지 로딩 중...

Rust로 만드는 나만의 OS - Bump Allocator 완벽 가이드 - 슬라이드 1/11
A

AI Generated

2025. 11. 13. · 4 Views

Rust로 만드는 나만의 OS - Bump Allocator 완벽 가이드

OS 커널 개발의 첫 단계인 Bump Allocator를 구현해봅니다. 메모리 할당의 기본 원리부터 실제 구현까지, Rust로 직접 만들어가는 과정을 통해 시스템 프로그래밍의 핵심을 이해할 수 있습니다.


목차

  1. Bump Allocator 기본 개념
  2. 메모리 정렬 처리
  3. GlobalAlloc Trait 구현
  4. Locked Wrapper 구현
  5. 힙 초기화
  6. 오류 처리와 Out of Memory
  7. 통계 및 디버깅 기능
  8. 전체 힙 리셋 기능
  9. 멀티코어 최적화
  10. 실전 통합 예제

1. Bump Allocator 기본 개념

시작하며

여러분이 OS 커널을 만들 때 가장 먼저 마주치는 문제가 무엇일까요? 바로 메모리 할당입니다.

일반 프로그램에서는 malloc이나 Box::new를 당연하게 사용하지만, 커널에서는 이런 표준 라이브러리 함수들을 사용할 수 없습니다. 왜냐하면 이런 메모리 할당 함수들 자체가 OS의 도움을 받아 작동하기 때문입니다.

OS를 만들고 있는데 OS의 도움을 받을 수는 없는 노릇이죠. 그래서 우리는 가장 기초적인 형태의 메모리 할당자부터 직접 구현해야 합니다.

바로 이럴 때 필요한 것이 Bump Allocator입니다. 이것은 가장 단순하면서도 효율적인 메모리 할당 방식으로, OS 개발의 첫 걸음을 내딛는 데 완벽한 시작점이 됩니다.

개요

간단히 말해서, Bump Allocator는 메모리를 순차적으로 할당하는 가장 단순한 형태의 메모리 할당자입니다. 여러분이 스택에 쌓인 책들을 상상해보세요.

새로운 책을 추가할 때마다 맨 위에 올려놓기만 하면 됩니다. Bump Allocator도 똑같이 작동합니다.

메모리 요청이 들어오면 현재 위치(포인터)에서 필요한 크기만큼 할당하고, 포인터를 그만큼 앞으로 이동시킵니다. 이 방식은 커널 초기화, 부트로더, 임베디드 시스템 등 메모리 해제가 필요 없거나 드문 환경에서 매우 효과적입니다.

기존의 복잡한 메모리 할당자(free list, buddy system 등)는 할당과 해제를 모두 지원하기 위해 복잡한 자료구조를 유지해야 했습니다. 하지만 Bump Allocator는 단순히 포인터 하나만 관리하면 되므로 구현이 쉽고 할당 속도가 매우 빠릅니다.

핵심 특징은 세 가지입니다: 1) O(1) 시간 복잡도로 매우 빠른 할당, 2) 포인터 하나만 유지하는 단순한 구조, 3) 개별 해제가 불가능하지만 전체 초기화는 가능. 이러한 특징들이 커널 개발 초기 단계에서 완벽한 선택이 되는 이유입니다.

코드 예제

use core::alloc::{GlobalAlloc, Layout};
use core::ptr;

pub struct BumpAllocator {
    heap_start: usize,
    heap_end: usize,
    next: usize,  // 다음 할당 위치를 가리키는 포인터
    allocations: usize,  // 할당 횟수 추적
}

impl BumpAllocator {
    pub const fn new() -> Self {
        BumpAllocator {
            heap_start: 0,
            heap_end: 0,
            next: 0,
            allocations: 0,
        }
    }

    // 힙 메모리 영역 초기화
    pub unsafe fn init(&mut self, heap_start: usize, heap_size: usize) {
        self.heap_start = heap_start;
        self.heap_end = heap_start + heap_size;
        self.next = heap_start;
    }
}

설명

이것이 하는 일: Bump Allocator는 메모리 힙 영역에서 순차적으로 메모리를 할당하는 역할을 합니다. 마치 노트에 글을 쓸 때 한 줄씩 아래로 내려가듯이, 메모리를 위에서 아래로 차례대로 할당합니다.

첫 번째로, 구조체의 필드들을 살펴보겠습니다. heap_start와 heap_end는 우리가 사용할 수 있는 메모리 영역의 경계를 나타냅니다.

이것은 마치 운동장의 시작선과 끝선 같은 것입니다. next 필드가 가장 중요한데, 이것은 다음번에 메모리를 할당할 위치를 가리키는 포인터입니다.

allocations는 통계 목적으로 얼마나 많은 할당이 일어났는지 추적합니다. 그 다음으로, new() 함수는 모든 필드를 0으로 초기화합니다.

이것은 아직 힙이 준비되지 않은 상태를 의미합니다. const fn으로 선언되어 있어서 컴파일 타임에 상수로 초기화할 수 있습니다.

이것은 커널에서 매우 중요한데, 동적 초기화가 불가능한 초기 단계에서도 사용할 수 있기 때문입니다. 마지막으로, init() 함수가 실제로 힙 메모리 영역을 설정합니다.

unsafe로 표시된 이유는 잘못된 메모리 주소를 전달하면 시스템 전체가 망가질 수 있기 때문입니다. 이 함수는 부트로더나 커널 초기화 코드에서 물리 메모리 맵을 파악한 후 한 번만 호출됩니다.

여러분이 이 코드를 사용하면 OS 커널에서 기본적인 메모리 할당 기능을 구현할 수 있습니다. 복잡한 자료구조 없이도 빠르고 안정적인 메모리 할당이 가능하며, 커널 초기화 과정에서 필요한 임시 객체들을 생성할 수 있습니다.

또한 나중에 더 정교한 할당자로 교체하기 전까지 사용할 수 있는 임시 솔루션으로도 완벽합니다.

실전 팁

💡 const fn을 사용하면 정적 초기화가 가능해서 커널 부팅 초기에도 사용할 수 있습니다. static ALLOCATOR: BumpAllocator = BumpAllocator::new() 형태로 선언하세요.

💡 heap_start와 heap_end 값을 검증하는 로직을 추가하세요. 잘못된 메모리 주소는 디버깅하기 매우 어려운 버그를 만듭니다.

💡 allocations 카운터를 통해 메모리 사용 패턴을 분석할 수 있습니다. 디버그 빌드에서 이 정보를 출력하면 메모리 누수를 찾는 데 도움이 됩니다.

💡 init() 함수는 멱등성(idempotent)을 보장하도록 구현하세요. 실수로 여러 번 호출되어도 안전하게 처리되어야 합니다.

💡 힙 크기는 페이지 크기(4KB)의 배수로 설정하는 것이 좋습니다. 이렇게 하면 나중에 페이지 테이블과 통합하기 쉽습니다.


2. 메모리 정렬 처리

시작하며

여러분이 메모리를 할당할 때 이런 문제를 겪어본 적 있나요? 할당은 성공했는데 프로그램이 갑자기 크래시되거나, 특정 CPU 아키텍처에서만 이상하게 동작하는 경우 말입니다.

이런 문제의 대부분은 메모리 정렬(alignment) 이슈 때문입니다. 현대의 CPU들은 메모리에 접근할 때 특정 경계에 정렬된 주소를 선호하거나 요구합니다.

예를 들어, 64비트 값은 8바이트 경계에, 32비트 값은 4바이트 경계에 정렬되어야 합니다. 잘못 정렬된 메모리 접근은 성능 저하나 하드웨어 예외를 발생시킵니다.

바로 이럴 때 필요한 것이 정렬 처리 로직입니다. Bump Allocator에서 메모리를 할당할 때 요청된 정렬 요구사항을 만족시키도록 포인터를 조정하는 과정이 필수적입니다.

개요

간단히 말해서, 메모리 정렬은 메모리 주소가 특정 배수에 위치하도록 보장하는 것입니다. 여러분이 주차장에 차를 세울 때를 생각해보세요.

각 차는 지정된 주차선 안에 정확히 들어가야 합니다. 메모리 정렬도 비슷합니다.

각 데이터 타입은 자신의 크기에 맞는 "주차선"에 위치해야 효율적으로 작동합니다. 예를 들어, x86_64 아키텍처에서 정렬되지 않은 메모리 접근은 추가적인 메모리 버스 사이클을 발생시켜 성능이 2배 이상 느려질 수 있습니다.

기존에는 프로그래머가 수동으로 패딩을 계산하고 추가해야 했습니다. 하지만 Rust의 Layout API를 사용하면 컴파일러가 자동으로 계산한 정렬 요구사항을 받아서 처리할 수 있습니다.

핵심 특징은: 1) align_up 연산을 통한 자동 정렬 조정, 2) Layout 구조체를 통한 타입 안전한 정렬 정보 전달, 3) 오버헤드 최소화를 위한 비트 연산 활용. 이러한 특징들이 안전하고 효율적인 메모리 할당을 가능하게 합니다.

코드 예제

// 주소를 정렬 경계로 올림
fn align_up(addr: usize, align: usize) -> usize {
    // align은 반드시 2의 거듭제곱이어야 함
    // 예: align=8이면 addr을 8의 배수로 올림
    let remainder = addr % align;
    if remainder == 0 {
        addr  // 이미 정렬됨
    } else {
        addr - remainder + align  // 다음 정렬 경계로
    }
}

// 비트 연산을 사용한 더 빠른 버전
fn align_up_fast(addr: usize, align: usize) -> usize {
    // (addr + align - 1) & !(align - 1)
    // align=8(0b1000)이면 mask=0b0111, !mask=0b...11111000
    (addr + align - 1) & !(align - 1)
}

// Layout을 사용한 안전한 정렬
unsafe impl GlobalAlloc for BumpAllocator {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        let alloc_start = align_up_fast(self.next, layout.align());
        // alloc_start부터 메모리 할당 시작
    }
}

설명

이것이 하는 일: 메모리 정렬 처리는 할당하려는 메모리의 시작 주소가 요청된 정렬 요구사항을 만족하도록 next 포인터를 조정하는 작업입니다. 첫 번째로, align_up 함수의 기본 버전을 살펴봅시다.

이 함수는 주소를 받아서 정렬 경계로 "올림"합니다. 예를 들어, addr이 13이고 align이 8이라면, 13을 8의 배수 중 가장 가까운 큰 수인 16으로 만듭니다.

remainder를 계산해서 0이면 이미 정렬된 것이고, 아니면 남은 바이트만큼 더하고 정렬 크기를 더해서 다음 경계로 이동합니다. 이 로직은 이해하기 쉽지만 나누기 연산 때문에 느립니다.

그 다음으로, align_up_fast 함수는 비트 연산을 활용한 최적화 버전입니다. 정렬 값은 항상 2의 거듭제곱(2, 4, 8, 16...)이라는 특성을 이용합니다.

align-1을 하면 하위 비트들이 모두 1이 되는 마스크가 만들어집니다. 예를 들어, 8(0b1000) - 1 = 7(0b0111)입니다.

이것을 NOT 연산하면 하위 비트들이 0이 되어 정렬 마스크가 됩니다. addr에 align-1을 더한 후 이 마스크와 AND 연산을 하면 하위 비트들이 제거되어 정렬된 주소가 나옵니다.

마지막으로, GlobalAlloc trait 구현에서 실제로 사용하는 모습입니다. layout.align()을 호출하면 Rust 컴파일러가 계산한 정렬 요구사항을 얻을 수 있습니다.

이 값으로 현재 next 포인터를 정렬하여 alloc_start를 계산합니다. 이렇게 하면 할당된 메모리가 항상 올바르게 정렬됨을 보장할 수 있습니다.

여러분이 이 코드를 사용하면 모든 타입의 메모리 할당이 자동으로 올바른 정렬을 갖게 됩니다. 수동으로 정렬을 계산할 필요가 없고, 컴파일러가 보장하는 타입 안전성을 유지하면서도 최대 성능을 얻을 수 있습니다.

또한 비트 연산 버전을 사용하면 나누기 연산을 피해서 수십 배 빠른 할당이 가능합니다.

실전 팁

💡 정렬 값이 2의 거듭제곱인지 검증하세요: debug_assert!(align.is_power_of_two()). 잘못된 정렬 값은 예측 불가능한 버그를 만듭니다.

💡 align_up 연산 후 heap_end를 초과하는지 반드시 체크하세요. 정렬 때문에 예상보다 많은 메모리를 사용할 수 있습니다.

💡 비트 연산 버전이 항상 빠르지만, 디버그 빌드에서는 나누기 버전을 사용해서 가독성을 높이는 것도 좋은 전략입니다.

💡 Layout::from_size_align() 사용 시 반환되는 Result를 반드시 처리하세요. 잘못된 정렬 값은 컴파일 타임에 잡히지 않을 수 있습니다.

💡 정렬로 인한 메모리 낭비를 추적하고 싶다면, 정렬 전후의 차이를 누적해서 통계를 만드세요.


3. GlobalAlloc Trait 구현

시작하며

여러분이 Rust로 OS 커널을 작성하면서 Box나 Vec 같은 표준 컬렉션을 사용하고 싶었던 적이 있나요? 하지만 no_std 환경에서는 이런 것들이 작동하지 않습니다.

이것은 Rust 표준 라이브러리의 메모리 할당 기능이 GlobalAlloc trait를 구현한 전역 할당자를 요구하기 때문입니다. 일반 프로그램에서는 OS가 제공하는 기본 할당자를 사용하지만, 커널에서는 우리가 직접 이 trait를 구현해야 합니다.

바로 이럴 때 필요한 것이 GlobalAlloc trait 구현입니다. 이것을 구현하면 우리가 만든 Bump Allocator를 Rust의 전역 할당자로 등록할 수 있고, Box, Vec, String 등 모든 힙 메모리 기능을 커널에서도 사용할 수 있게 됩니다.

개요

간단히 말해서, GlobalAlloc은 Rust의 전역 메모리 할당 인터페이스를 정의하는 trait입니다. 여러분이 플러그 소켓을 생각해보세요.

다양한 전자제품들이 있지만 모두 같은 표준 소켓에 꽂아서 전기를 받습니다. GlobalAlloc도 마찬가지입니다.

Box, Vec, Arc 등 다양한 타입들이 있지만, 모두 GlobalAlloc trait를 통해 메모리를 할당받습니다. 이 trait는 alloc()과 dealloc() 두 개의 필수 메서드만 요구하므로, 우리의 Bump Allocator에 이 두 메서드만 구현하면 됩니다.

기존에는 각 컬렉션이 자체 할당 로직을 가지고 있어서 일관성이 없었습니다. 하지만 GlobalAlloc trait 덕분에 모든 할당이 하나의 인터페이스를 통해 이루어지므로 일관성과 교체 가능성이 보장됩니다.

핵심 특징은: 1) alloc()과 dealloc() 두 메서드로 구성된 단순한 인터페이스, 2) #[global_allocator] 속성으로 전역 등록, 3) Layout 구조체를 통한 타입 안전한 메모리 요청. 이러한 특징들이 Rust의 메모리 안전성을 유지하면서도 유연한 할당자 구현을 가능하게 합니다.

코드 예제

use core::alloc::{GlobalAlloc, Layout};
use core::ptr::null_mut;
use spin::Mutex;

unsafe impl GlobalAlloc for Locked<BumpAllocator> {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        let mut allocator = self.lock();  // 뮤텍스 획득

        // 정렬된 시작 주소 계산
        let alloc_start = align_up(allocator.next, layout.align());
        let alloc_end = alloc_start + layout.size();

        // 힙 경계 체크
        if alloc_end > allocator.heap_end {
            return null_mut();  // 메모리 부족
        }

        // 포인터 업데이트
        allocator.next = alloc_end;
        allocator.allocations += 1;

        alloc_start as *mut u8  // 할당된 메모리 주소 반환
    }

    unsafe fn dealloc(&self, _ptr: *mut u8, _layout: Layout) {
        // Bump allocator는 개별 해제를 지원하지 않음
        // 아무 작업도 하지 않음
    }
}

설명

이것이 하는 일: GlobalAlloc trait 구현은 Rust 표준 라이브러리와 우리의 커스텀 할당자를 연결하는 다리 역할을 합니다. Box::new()나 Vec::push() 같은 함수들이 내부적으로 이 trait의 메서드를 호출하게 됩니다.

첫 번째로, alloc() 메서드의 동작을 단계별로 살펴봅시다. 먼저 self.lock()을 호출해서 뮤텍스를 획득합니다.

이것은 여러 CPU 코어에서 동시에 메모리를 할당하려 할 때 경쟁 상태(race condition)를 방지하기 위함입니다. 뮤텍스 없이는 두 코어가 동시에 next를 읽고 같은 메모리 영역을 할당받을 수 있습니다.

그 다음 align_up()으로 현재 포인터를 요청된 정렬에 맞춰 조정합니다. 그 다음으로, 메모리 경계 검사를 수행합니다.

alloc_end가 heap_end를 초과하면 메모리가 부족한 것이므로 null_mut()를 반환합니다. Rust에서 null 포인터는 할당 실패를 나타내는 표준 방법입니다.

이것을 받은 호출자는 panic하거나 OOM(Out of Memory) 처리를 수행합니다. 경계 검사를 통과하면 next 포인터를 alloc_end로 업데이트하고, allocations 카운터를 증가시킵니다.

마지막으로, dealloc() 메서드는 비어있습니다. Bump Allocator의 특성상 개별 메모리 블록을 해제할 수 없기 때문입니다.

이것은 문제가 아닙니다. 커널 초기화나 단기 실행 프로그램에서는 메모리를 할당만 하고 프로세스 종료 시 일괄 정리하는 패턴이 흔하기 때문입니다.

필요하다면 나중에 전체 힙을 리셋하는 메서드를 따로 추가할 수 있습니다. 여러분이 이 코드를 사용하면 Rust의 모든 표준 컬렉션을 커널에서 사용할 수 있게 됩니다.

Vec으로 동적 배열을 만들고, Box로 힙 객체를 생성하고, Arc로 참조 카운팅을 할 수 있습니다. 단, 메모리 해제가 없다는 점을 인지하고 용도에 맞게 사용해야 합니다.

장기 실행 서버보다는 부트로더, 커널 초기화, 단기 태스크에 적합합니다.

실전 팁

💡 Locked<T> wrapper를 사용해서 interior mutability를 제공하세요. spin 크레이트의 Mutex를 사용하면 std 없이도 동기화가 가능합니다.

💡 alloc() 실패 시 null_mut() 대신 패닉할지, 조용히 실패할지 정책을 정하세요. 디버그 빌드에서는 패닉으로 빠른 피드백을 받는 것이 좋습니다.

💡 allocations 카운터 외에 total_allocated_bytes도 추적하면 메모리 사용량을 모니터링하기 쉽습니다.

💡 layout.size()가 0인 경우를 특별히 처리하세요. 일부 컬렉션은 빈 상태에서 0바이트 할당을 요청합니다.

💡 멀티코어 환경에서는 lock contention을 줄이기 위해 CPU마다 별도의 할당자를 사용하는 것을 고려하세요.


4. Locked Wrapper 구현

시작하며

여러분이 멀티코어 시스템에서 메모리 할당자를 사용할 때 이런 버그를 경험해본 적 있나요? 단일 코어에서는 완벽하게 작동하던 코드가 멀티코어에서 실행하면 랜덤하게 크래시되거나 메모리가 손상되는 현상 말입니다.

이것은 전형적인 경쟁 상태(race condition) 문제입니다. 여러 CPU 코어가 동시에 할당자의 next 포인터를 읽고 쓰면, 데이터 경쟁이 발생해서 같은 메모리 영역이 두 번 할당되거나 포인터가 잘못된 값으로 업데이트될 수 있습니다.

이런 버그는 재현하기도 어렵고 디버깅하기도 매우 힘듭니다. 바로 이럴 때 필요한 것이 Locked wrapper입니다.

이것은 할당자를 뮤텍스로 감싸서 한 번에 하나의 코어만 접근할 수 있도록 보장합니다.

개요

간단히 말해서, Locked<T>는 타입 T를 뮤텍스로 감싸서 스레드 안전한 접근을 제공하는 wrapper입니다. 여러분이 화장실의 자물쇠를 생각해보세요.

한 사람이 들어가면 문을 잠그고, 나올 때 열어서 다음 사람이 들어갈 수 있게 합니다. Locked<T>도 똑같이 작동합니다.

한 CPU 코어가 lock()을 호출하면 배타적 접근권을 얻고, 작업을 마치고 락을 해제하면 다른 코어가 접근할 수 있습니다. Rust의 타입 시스템 덕분에 락을 획득하지 않고는 내부 데이터에 접근할 수 없도록 컴파일 타임에 보장됩니다.

기존의 C 스타일 뮤텍스는 프로그래머가 수동으로 lock/unlock을 쌍으로 호출해야 했고, 실수로 unlock을 빠뜨리면 데드락이 발생했습니다. 하지만 Rust의 Mutex는 RAII 패턴을 사용해서 MutexGuard가 스코프를 벗어나면 자동으로 락을 해제합니다.

핵심 특징은: 1) MutexGuard를 통한 자동 락 해제, 2) 타입 시스템으로 보장되는 배타적 접근, 3) no_std 환경에서 사용 가능한 spin lock. 이러한 특징들이 안전하고 사용하기 쉬운 동기화를 가능하게 합니다.

코드 예제

use spin::Mutex;

// Mutex로 감싼 할당자 wrapper
pub struct Locked<T> {
    inner: Mutex<T>,
}

impl<T> Locked<T> {
    pub const fn new(inner: T) -> Self {
        Locked {
            inner: Mutex::new(inner),
        }
    }

    // 뮤텍스를 획득하고 내부 값에 접근
    pub fn lock(&self) -> spin::MutexGuard<T> {
        self.inner.lock()
    }
}

// 전역 할당자 선언
#[global_allocator]
static ALLOCATOR: Locked<BumpAllocator> = Locked::new(BumpAllocator::new());

// 사용 예시
pub fn init_heap(heap_start: usize, heap_size: usize) {
    unsafe {
        ALLOCATOR.lock().init(heap_start, heap_size);
    }
}

설명

이것이 하는 일: Locked<T>는 제네릭 wrapper로서 어떤 타입이든 스레드 안전하게 만들어줍니다. 우리의 경우 BumpAllocator를 감싸서 여러 CPU 코어가 동시에 접근해도 안전하도록 만듭니다.

첫 번째로, 구조체 정의를 보면 inner 필드가 Mutex<T>로 선언되어 있습니다. spin 크레이트의 Mutex는 표준 라이브러리 없이도 사용할 수 있는 spinlock 기반 뮤텍스입니다.

Spinlock은 락을 기다리는 동안 CPU를 계속 돌리기 때문에 짧은 임계 영역(critical section)에 적합합니다. 메모리 할당은 보통 매우 빠르게 완료되므로 spinlock이 이상적입니다.

그 다음으로, new() 함수는 const fn으로 선언되어 있어서 컴파일 타임 상수로 초기화할 수 있습니다. 이것은 매우 중요한데, 전역 할당자는 static 변수로 선언되어야 하고, static 변수는 const 초기화가 필요하기 때문입니다.

Mutex::new()도 const fn이므로 전체 체인이 컴파일 타임에 평가됩니다. 마지막으로, lock() 메서드가 MutexGuard를 반환합니다.

이것은 RAII 패턴의 핵심입니다. MutexGuard가 Drop될 때 자동으로 뮤텍스를 해제하므로, 프로그래머가 명시적으로 unlock을 호출할 필요가 없습니다.

let mut allocator = ALLOCATOR.lock() 같은 코드에서 allocator 변수가 스코프를 벗어나면 자동으로 락이 해제됩니다. 조기 return이나 panic이 발생해도 안전합니다.

여러분이 이 코드를 사용하면 멀티코어 시스템에서도 안전하게 메모리 할당을 수행할 수 있습니다. 데이터 경쟁을 걱정할 필요 없이 여러 스레드나 인터럽트 핸들러에서 동시에 할당을 요청할 수 있습니다.

Rust의 타입 시스템이 컴파일 타임에 동기화 버그를 잡아주므로, 런타임에 예상치 못한 크래시가 발생할 가능성이 크게 줄어듭니다.

실전 팁

💡 #[global_allocator]는 프로그램당 하나만 선언할 수 있습니다. 여러 개 선언하면 컴파일 에러가 발생합니다.

💡 lock()은 블로킹 연산이므로 인터럽트 핸들러에서 사용 시 주의하세요. 인터럽트가 비활성화된 상태에서 호출해야 데드락을 피할 수 있습니다.

💡 spin 크레이트 대신 lock_api를 사용하면 더 유연한 락 전략을 선택할 수 있습니다(ticket lock, fair lock 등).

💡 디버깅을 위해 try_lock() 메서드를 추가하면 락 획득 실패 시 패닉 대신 Option을 반환받을 수 있습니다.

💡 프로덕션 환경에서는 락 경합(lock contention) 통계를 수집해서 병목을 찾으세요. 과도한 경합은 per-CPU 할당자로 해결할 수 있습니다.


5. 힙 초기화

시작하며

여러분이 OS를 부팅하고 커널이 시작될 때, 메모리 할당자를 어떻게 초기화해야 할지 고민해본 적 있나요? 물리 메모리 맵을 어떻게 읽고, 어느 영역을 힙으로 사용할지 결정하는 것은 까다로운 작업입니다.

부트로더는 BIOS나 UEFI로부터 메모리 맵을 받아와서 사용 가능한 영역들을 알려줍니다. 하지만 이 중에는 펌웨어가 예약한 영역, 커널 코드와 데이터 영역, MMIO(Memory-Mapped I/O) 영역 등 사용하면 안 되는 곳들이 섞여 있습니다.

잘못된 영역을 힙으로 설정하면 시스템이 부팅 중에 크래시됩니다. 바로 이럴 때 필요한 것이 체계적인 힙 초기화 프로세스입니다.

안전한 메모리 영역을 식별하고, 할당자를 그 영역으로 초기화하는 과정이 OS 부팅의 핵심 단계입니다.

개요

간단히 말해서, 힙 초기화는 물리 메모리 중 안전한 영역을 찾아서 할당자가 사용할 수 있도록 설정하는 과정입니다. 여러분이 새 아파트에 이사 왔다고 생각해보세요.

집주인이 "이 방들은 사용해도 되고, 저 방은 창고니까 건드리지 마세요"라고 알려줍니다. 힙 초기화도 비슷합니다.

부트로더가 메모리 맵을 통해 "이 메모리 영역은 사용 가능하고, 저 영역은 하드웨어가 예약했으니 건드리지 마세요"라고 알려줍니다. 이 정보를 바탕으로 적절한 크기의 힙 영역을 선택하고 할당자를 초기화합니다.

기존의 수동 초기화 방식은 하드코딩된 주소를 사용해서 시스템마다 다르게 작동했습니다. 하지만 부트로더 프로토콜(Multiboot, UEFI)을 따르면 런타임에 동적으로 메모리 맵을 읽어서 이식 가능한 코드를 작성할 수 있습니다.

핵심 특징은: 1) 부트로더로부터 메모리 맵 파싱, 2) 사용 가능한 영역 필터링 및 검증, 3) 적절한 크기의 힙 영역 할당. 이러한 특징들이 안정적이고 이식 가능한 커널 부팅을 가능하게 합니다.

코드 예제

use bootloader::bootinfo::{MemoryMap, MemoryRegionType};

// 힙 크기 상수 정의 (100KB)
pub const HEAP_START: usize = 0x_4444_4444_0000;
pub const HEAP_SIZE: usize = 100 * 1024;

// 부트로더로부터 받은 메모리 맵으로 힙 초기화
pub fn init_heap(memory_map: &MemoryMap) -> Result<(), &'static str> {
    // 사용 가능한 메모리 영역 찾기
    let usable_regions = memory_map
        .iter()
        .filter(|r| r.region_type == MemoryRegionType::Usable);

    // 충분히 큰 영역 선택
    for region in usable_regions {
        let region_start = region.range.start_addr() as usize;
        let region_end = region.range.end_addr() as usize;
        let region_size = region_end - region_start;

        if region_size >= HEAP_SIZE {
            // 할당자 초기화
            unsafe {
                ALLOCATOR.lock().init(region_start, HEAP_SIZE);
            }
            return Ok(());
        }
    }

    Err("No suitable memory region for heap")
}

설명

이것이 하는 일: 힙 초기화 함수는 부팅 과정의 핵심 단계로, 운영체제가 동적 메모리 할당을 사용할 수 있도록 준비합니다. 첫 번째로, 상수 정의 부분을 살펴봅시다.

HEAP_START는 가상 주소 공간에서 힙이 시작될 위치입니다. 0x_4444_4444_0000 같은 특이한 값을 사용하는 이유는 디버깅 시 힙 포인터를 쉽게 식별하기 위함입니다.

HEAP_SIZE는 100KB로 설정되어 있는데, 이것은 커널 초기화에 충분한 크기입니다. 나중에 더 정교한 메모리 관리자를 초기화한 후에는 더 큰 힙으로 전환할 수 있습니다.

그 다음으로, memory_map을 순회하면서 사용 가능한 영역을 필터링합니다. MemoryRegionType::Usable은 부트로더가 "이 영역은 안전하게 사용해도 됩니다"라고 표시한 영역입니다.

Reserved, AcpiReclaimable, MemoryMappedIO 같은 다른 타입들은 건드리면 안 됩니다. filter()를 사용해서 선언적으로 안전한 영역만 선택합니다.

마지막으로, 충분히 큰 첫 번째 영역을 찾아서 할당자를 초기화합니다. region_size >= HEAP_SIZE 조건으로 100KB 이상인 영역을 찾습니다.

요즘 시스템에서는 쉽게 찾을 수 있지만, 임베디드 시스템에서는 조각난 메모리 때문에 여러 영역을 결합해야 할 수도 있습니다. unsafe 블록 안에서 ALLOCATOR.lock().init()을 호출하는데, init() 함수가 unsafe인 이유는 잘못된 주소를 전달하면 정의되지 않은 동작이 발생하기 때문입니다.

여러분이 이 코드를 사용하면 다양한 하드웨어 플랫폼에서 자동으로 적합한 메모리 영역을 찾아 힙을 초기화할 수 있습니다. 하드코딩된 주소에 의존하지 않으므로 QEMU, 실제 하드웨어, 다양한 메모리 구성에서 모두 작동합니다.

또한 부트로더 프로토콜을 따르므로 Multiboot나 UEFI 같은 표준 부팅 방식과 호환됩니다.

실전 팁

💡 힙 초기화는 가능한 한 빨리, 하지만 페이지 테이블 설정 후에 해야 합니다. 가상 메모리가 활성화되기 전에는 물리 주소를 사용하세요.

💡 여러 영역을 결합해서 더 큰 힙을 만들고 싶다면, linked list allocator 같은 더 복잡한 할당자로 업그레이드를 고려하세요.

💡 디버그 빌드에서는 할당한 메모리 영역을 특정 패턴(0xAA)으로 채워서 초기화되지 않은 메모리 사용을 감지하세요.

💡 HEAP_SIZE를 너무 크게 설정하지 마세요. 초기 단계에서는 작은 힙으로 시작하고, 나중에 확장하는 것이 안전합니다.

💡 메모리 영역이 페이지 경계(4KB)에 정렬되어 있는지 확인하세요. 정렬되지 않으면 페이지 테이블 매핑 시 문제가 생길 수 있습니다.


6. 오류 처리와 Out of Memory

시작하며

여러분이 메모리 할당을 요청했는데 실패했을 때, 프로그램이 어떻게 반응해야 할지 고민해본 적 있나요? 단순히 크래시시키는 것이 맞을까요, 아니면 우아하게 복구할 방법이 있을까요?

일반 애플리케이션에서는 메모리 부족 시 OS가 페이지 파일을 사용하거나 다른 프로세스를 종료해서 메모리를 확보합니다. 하지만 커널에서는 우리가 바로 그 OS입니다.

메모리 부족 상황을 스스로 처리해야 하고, 잘못 처리하면 전체 시스템이 다운될 수 있습니다. 바로 이럴 때 필요한 것이 체계적인 OOM(Out of Memory) 처리입니다.

할당 실패를 감지하고, 적절히 대응하며, 치명적인 상황에서는 패닉하는 전략이 필요합니다.

개요

간단히 말해서, OOM 처리는 메모리 할당 실패를 감지하고 시스템을 안전한 상태로 유지하는 메커니즘입니다. 여러분이 배가 침몰할 때 비상 절차를 따르는 것을 생각해보세요.

먼저 구명조끼를 착용하고(graceful degradation), 배를 포기하고(패닉), 구조를 기다립니다(재부팅). OOM 처리도 단계적으로 접근합니다.

먼저 할당 실패를 탐지하고, 가능하면 복구를 시도하며, 불가능하면 시스템을 안전하게 정지시킵니다. Rust의 Result와 panic!

매크로를 활용하면 타입 안전한 오류 처리가 가능합니다. 기존의 C 스타일 코드는 NULL 포인터 반환에 의존했고, 프로그래머가 수동으로 체크해야 했습니다.

체크를 빠뜨리면 NULL 포인터 역참조로 크래시했습니다. 하지만 Rust는 Result<T, E>와 Option<T>를 사용해서 컴파일 타임에 오류 처리를 강제합니다.

핵심 특징은: 1) #[alloc_error_handler]를 통한 전역 OOM 핸들러, 2) Result를 사용한 명시적 오류 전파, 3) 복구 불가능한 상황에서의 명확한 패닉. 이러한 특징들이 예측 가능하고 안정적인 시스템을 만들어줍니다.

코드 예제

use core::alloc::Layout;

// 전역 할당 오류 핸들러
#[alloc_error_handler]
fn alloc_error_handler(layout: Layout) -> ! {
    panic!("allocation error: {:?}", layout)
}

// 메모리 상태 체크 함수
impl BumpAllocator {
    pub fn used_bytes(&self) -> usize {
        self.next - self.heap_start
    }

    pub fn free_bytes(&self) -> usize {
        self.heap_end - self.next
    }

    // 할당 가능 여부 미리 체크
    pub fn can_allocate(&self, layout: Layout) -> bool {
        let alloc_start = align_up(self.next, layout.align());
        let alloc_end = alloc_start + layout.size();
        alloc_end <= self.heap_end
    }
}

// 안전한 할당 wrapper
pub fn try_allocate_vec<T>(capacity: usize) -> Result<Vec<T>, &'static str> {
    let layout = Layout::array::<T>(capacity)
        .map_err(|_| "invalid layout")?;

    if !ALLOCATOR.lock().can_allocate(layout) {
        return Err("insufficient memory");
    }

    Ok(Vec::with_capacity(capacity))
}

설명

이것이 하는 일: OOM 처리 메커니즘은 메모리 고갈 상황을 감지하고 시스템이 안전하게 반응하도록 보장합니다. 첫 번째로, #[alloc_error_handler] 속성이 붙은 함수가 전역 할당 오류 핸들러입니다.

GlobalAlloc::alloc()이 null 포인터를 반환하면 Rust 런타임이 자동으로 이 핸들러를 호출합니다. 함수 시그니처가 -> !인 것에 주목하세요.

이것은 "never type"으로, 이 함수가 절대 반환하지 않음을 의미합니다. panic!을 호출해서 시스템을 정지시키거나, 무한 루프에 진입하거나, 재부팅을 트리거할 수 있습니다.

Layout 정보를 출력하면 어떤 크기의 할당이 실패했는지 디버깅할 수 있습니다. 그 다음으로, 메모리 상태를 모니터링하는 유틸리티 함수들입니다.

used_bytes()는 현재까지 할당된 바이트 수를, free_bytes()는 남은 공간을 계산합니다. 이 정보는 메모리 사용량을 추적하고, 임계값에 도달하면 경고를 발생시키는 데 유용합니다.

can_allocate()는 실제 할당 전에 성공 가능성을 체크합니다. 이것은 "ask for permission before acting" 패턴으로, 실패 가능성이 높은 작업을 시도하기 전에 사전 검증을 수행합니다.

마지막으로, try_allocate_vec() 같은 안전한 wrapper 함수를 만들어 사용합니다. Layout::array()가 Result를 반환하므로 ?를 사용해서 오류를 전파합니다.

can_allocate()로 사전 체크를 수행하고, 실패하면 명시적인 에러 메시지와 함께 Err를 반환합니다. 이렇게 하면 호출자가 할당 실패를 미리 알고 대응할 수 있습니다.

Vec::with_capacity() 호출이 실패하면 alloc_error_handler로 가게 되지만, 사전 체크 덕분에 이런 일은 거의 발생하지 않습니다. 여러분이 이 코드를 사용하면 메모리 부족 상황을 예측하고 대비할 수 있습니다.

대용량 할당을 시도하기 전에 can_allocate()로 체크해서 시스템 크래시를 방지하고, 실패 시 우아하게 기능을 축소할 수 있습니다. 또한 메모리 사용량을 모니터링해서 리소스 관리 정책을 구현할 수 있습니다.

실전 팁

💡 alloc_error_handler에서 로깅을 추가하면 메모리 부족 패턴을 분석할 수 있습니다. 단, 로깅 자체가 할당을 요구하면 무한 재귀에 빠질 수 있으니 주의하세요.

💡 free_bytes()가 특정 임계값(예: 10%) 이하로 떨어지면 경고를 발생시켜 proactive하게 대응하세요.

💡 디버그 빌드에서는 모든 할당에 대해 can_allocate() 체크를 강제하는 것을 고려하세요. 릴리즈 빌드에서는 성능을 위해 생략할 수 있습니다.

💡 대용량 할당(예: 1MB 이상)은 별도의 large object allocator를 사용하는 것을 고려하세요. Bump allocator는 작은 객체에 최적화되어 있습니다.

💡 OOM 상황에서 패닉 대신 재부팅을 선택하려면 플랫폼별 재부팅 함수를 호출하세요(예: x86의 triple fault).


7. 통계 및 디버깅 기능

시작하며

여러분이 커널을 개발하면서 메모리 할당 패턴을 분석하고 싶었던 적이 있나요? 어떤 코드가 얼마나 많은 메모리를 사용하는지, 할당이 얼마나 자주 일어나는지 알면 최적화에 큰 도움이 됩니다.

일반 프로그램에서는 valgrind, AddressSanitizer 같은 도구들을 사용할 수 있지만, 베어메탈 커널에서는 이런 도구들을 사용할 수 없습니다. 커널 자체가 가장 낮은 레벨에서 작동하기 때문에 외부 도구의 도움을 받을 수 없습니다.

따라서 할당자에 직접 통계 기능을 내장해야 합니다. 바로 이럴 때 필요한 것이 내장 통계 및 디버깅 기능입니다.

할당 횟수, 사용량, 정렬 낭비 등을 추적하면 메모리 사용 패턴을 이해하고 최적화할 수 있습니다.

개요

간단히 말해서, 통계 기능은 할당자의 동작을 추적하고 분석 가능한 데이터를 제공하는 메커니즘입니다. 여러분이 자동차의 계기판을 생각해보세요.

속도, 연료량, 엔진 온도 등을 실시간으로 보여줘서 운전자가 상황을 파악할 수 있게 합니다. 할당자의 통계 기능도 마찬가지입니다.

총 할당 횟수, 사용 중인 메모리, 남은 공간, 정렬 오버헤드 등을 추적해서 개발자가 메모리 사용 패턴을 이해할 수 있게 합니다. 이 정보는 성능 최적화, 메모리 누수 감지, 용량 계획에 필수적입니다.

기존의 할당자들은 통계 기능이 없거나 별도의 프로파일링 도구에 의존했습니다. 하지만 내장 통계 기능을 구현하면 런타임에 오버헤드 없이 지속적으로 데이터를 수집할 수 있습니다.

핵심 특징은: 1) 원자적 카운터를 사용한 멀티스레드 안전 통계, 2) Debug trait 구현으로 간편한 출력, 3) 조건부 컴파일로 릴리즈 빌드에서 오버헤드 제거. 이러한 특징들이 프로덕션 환경에서도 사용 가능한 디버깅 기능을 제공합니다.

코드 예제

use core::fmt;

// 통계 정보를 담는 구조체
#[derive(Debug, Clone, Copy)]
pub struct AllocatorStats {
    pub total_allocations: usize,
    pub total_deallocations: usize,
    pub bytes_allocated: usize,
    pub bytes_wasted_alignment: usize,  // 정렬로 인한 낭비
    pub peak_usage: usize,  // 최대 사용량
}

impl BumpAllocator {
    pub fn stats(&self) -> AllocatorStats {
        AllocatorStats {
            total_allocations: self.allocations,
            total_deallocations: 0,  // Bump는 해제 없음
            bytes_allocated: self.next - self.heap_start,
            bytes_wasted_alignment: self.wasted_bytes,
            peak_usage: self.next - self.heap_start,
        }
    }
}

// 할당 시 통계 업데이트
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
    let mut allocator = self.lock();
    let alloc_start = align_up(allocator.next, layout.align());

    // 정렬 낭비 계산
    let alignment_waste = alloc_start - allocator.next;
    allocator.wasted_bytes += alignment_waste;

    // ... 나머지 할당 로직 ...
}

// Debug 출력 구현
impl fmt::Debug for BumpAllocator {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        let stats = self.stats();
        f.debug_struct("BumpAllocator")
            .field("heap_range", &(self.heap_start..self.heap_end))
            .field("stats", &stats)
            .finish()
    }
}

설명

이것이 하는 일: 통계 및 디버깅 기능은 할당자의 동작을 투명하게 만들어 개발자가 메모리 사용을 이해하고 최적화할 수 있게 합니다. 첫 번째로, AllocatorStats 구조체가 모든 통계 정보를 담습니다.

total_allocations는 alloc() 호출 횟수를, bytes_allocated는 현재 사용 중인 총 바이트를 나타냅니다. bytes_wasted_alignment가 특히 흥미로운데, 이것은 메모리 정렬 때문에 낭비된 바이트를 추적합니다.

예를 들어, next가 13이고 8바이트 정렬이 필요하면 16으로 올림되어 3바이트가 낭비됩니다. 이런 낭비를 누적하면 전체 메모리 효율성을 평가할 수 있습니다.

peak_usage는 역사적 최대 사용량을 기록합니다. 그 다음으로, stats() 메서드가 현재 상태의 스냅샷을 반환합니다.

이것은 값 복사(Copy)를 사용하므로 락을 오래 잡고 있지 않아도 됩니다. 호출자는 반환된 AllocatorStats를 자유롭게 분석하거나 로깅할 수 있습니다.

total_deallocations가 항상 0인 이유는 Bump allocator의 특성상 개별 해제가 없기 때문입니다. 마지막으로, Debug trait 구현이 할당자를 쉽게 출력할 수 있게 합니다.

println!("{:?}", ALLOCATOR.lock()) 같은 코드로 현재 상태를 확인할 수 있습니다. debug_struct() 빌더 패턴을 사용하면 구조화된 출력을 얻을 수 있습니다.

heap_range는 Range<usize>로 표시되어 힙의 시작과 끝을 한눈에 볼 수 있습니다. 이것은 디버깅 세션에서 매우 유용하며, 시리얼 콘솔이나 화면 출력으로 바로 확인할 수 있습니다.

여러분이 이 코드를 사용하면 메모리 사용 패턴을 실시간으로 모니터링할 수 있습니다. 특정 작업 전후로 stats()를 호출해서 얼마나 많은 메모리를 사용했는지 측정하고, 최적화의 효과를 정량적으로 평가할 수 있습니다.

정렬 낭비가 많다면 할당 순서를 조정하거나 더 효율적인 할당자로 업그레이드하는 결정을 내릴 수 있습니다.

실전 팁

💡 릴리즈 빌드에서 통계 오버헤드를 제거하려면 #[cfg(debug_assertions)]로 조건부 컴파일하세요.

💡 peak_usage를 추적하려면 alloc() 마다 max() 연산을 수행하세요. 이것으로 메모리 사용량이 가장 높은 시점을 파악할 수 있습니다.

💡 주기적으로 stats를 로깅하면 시간에 따른 메모리 사용 추이를 분석할 수 있습니다. 메모리 누수는 선형 증가 패턴으로 나타납니다.

💡 bytes_wasted_alignment 비율이 10%를 넘으면 할당 순서를 재배치하거나 패딩을 줄이는 것을 고려하세요.

💡 통계 수집 자체가 캐시 미스를 유발할 수 있으므로, 핫 패스에서는 최소한의 카운터만 업데이트하고 복잡한 계산은 stats() 호출 시에만 수행하세요.


8. 전체 힙 리셋 기능

시작하며

여러분이 단기 작업을 반복적으로 수행하는 커널 서브시스템을 만들 때, 매번 메모리를 할당하고 개별적으로 해제하는 것이 비효율적이라고 느낀 적 있나요? 예를 들어, 네트워크 패킷 처리, 파일 시스템 캐시, 임시 버퍼 같은 경우 작업이 끝나면 모든 메모리를 한 번에 정리하고 다음 작업을 위해 리셋하는 것이 훨씬 효율적입니다.

개별 free() 호출은 오버헤드가 크고, 메모리 단편화를 유발하며, 복잡한 자료구조 유지 비용이 듭니다. 바로 이럴 때 필요한 것이 전체 힙 리셋 기능입니다.

Bump allocator의 강점을 극대화하는 이 기능은 포인터 하나만 초기화해서 모든 메모리를 순식간에 회수합니다.

개요

간단히 말해서, 힙 리셋은 할당 포인터를 시작 위치로 되돌려서 모든 할당을 무효화하고 힙을 재사용 가능하게 만드는 기능입니다. 여러분이 화이트보드에 그림을 그렸다가 지우개로 싹 지우는 것을 생각해보세요.

각 선을 하나씩 지우는 대신, 한 번에 전체를 지워버리는 것이 훨씬 빠릅니다. 힙 리셋도 똑같습니다.

next 포인터만 heap_start로 되돌리면 모든 할당이 즉시 무효화되고, 메모리를 처음부터 다시 사용할 수 있습니다. 이것은 O(1) 시간에 완료되며, 할당된 객체의 수와 무관합니다.

기존의 free list 기반 할당자는 각 객체를 해제할 때 리스트를 순회하고 병합해야 했습니다. O(n) 복잡도에 캐시 미스도 많이 발생합니다.

하지만 Bump allocator의 리셋은 단순한 포인터 대입이므로 극도로 빠릅니다. 핵심 특징은: 1) O(1) 시간 복잡도의 초고속 리셋, 2) 메모리 단편화 완전 제거, 3) 아레나 할당 패턴 지원.

이러한 특징들이 특정 워크로드에서 엄청난 성능 향상을 가져다줍니다.

코드 예제

impl BumpAllocator {
    // 전체 힙을 리셋 (모든 할당 무효화)
    pub unsafe fn reset(&mut self) {
        self.next = self.heap_start;
        self.allocations = 0;
        self.wasted_bytes = 0;
    }

    // 체크포인트 저장 및 복원
    pub fn checkpoint(&self) -> usize {
        self.next
    }

    pub unsafe fn restore(&mut self, checkpoint: usize) {
        debug_assert!(checkpoint >= self.heap_start);
        debug_assert!(checkpoint <= self.next);
        self.next = checkpoint;
    }
}

// 아레나 패턴 사용 예시
pub fn process_network_packets(packets: &[Packet]) {
    // 작업 시작 전 체크포인트
    let checkpoint = ALLOCATOR.lock().checkpoint();

    for packet in packets {
        // 임시 버퍼 할당 (Vec, String 등)
        let buffer = Vec::with_capacity(1024);
        // ... 패킷 처리 ...
    }

    // 모든 임시 할당을 한 번에 회수
    unsafe {
        ALLOCATOR.lock().restore(checkpoint);
    }
}

// 스코프 기반 자동 리셋
pub struct ArenaScope {
    checkpoint: usize,
}

impl ArenaScope {
    pub fn new() -> Self {
        ArenaScope {
            checkpoint: ALLOCATOR.lock().checkpoint(),
        }
    }
}

impl Drop for ArenaScope {
    fn drop(&mut self) {
        unsafe {
            ALLOCATOR.lock().restore(self.checkpoint);
        }
    }
}

설명

이것이 하는 일: 전체 힙 리셋 기능은 Bump allocator를 단순한 할당자에서 강력한 아레나 할당자로 변신시킵니다. 첫 번째로, reset() 메서드의 구현을 봅시다.

단 세 줄의 코드로 전체 힙을 초기화합니다. next를 heap_start로 되돌리면 모든 이전 할당이 논리적으로 무효화됩니다.

물리적인 메모리는 그대로 있지만, 할당자는 그것들을 더 이상 "사용 중"으로 간주하지 않고 덮어쓸 수 있습니다. allocations와 wasted_bytes도 0으로 리셋해서 통계를 초기화합니다.

unsafe로 표시된 이유는 호출자가 이전 할당에서 얻은 모든 포인터를 사용하지 않음을 보장해야 하기 때문입니다. 그 다음으로, checkpoint()와 restore() 메서드가 더 정교한 제어를 제공합니다.

checkpoint()는 현재 next 값을 저장하고, restore()는 저장된 위치로 되돌립니다. 이것은 전체 리셋보다 덜 과격한 방법으로, 특정 시점 이후의 할당만 무효화합니다.

debug_assert!로 체크포인트가 유효한 범위 내에 있는지 검증합니다. 이것은 중첩된 아레나나 계층적 메모리 관리에 유용합니다.

마지막으로, ArenaScope는 RAII 패턴을 활용한 자동 리셋 메커니즘입니다. 생성자에서 checkpoint를 저장하고, Drop 구현에서 자동으로 restore를 호출합니다.

이렇게 하면 스코프를 벗어날 때 자동으로 메모리가 회수되므로, 수동으로 restore를 호출할 필요가 없습니다. 조기 return이나 panic이 발생해도 안전하게 정리됩니다.

process_network_packets() 예시에서 볼 수 있듯이, 반복 작업에서 이 패턴은 매우 강력합니다. 여러분이 이 코드를 사용하면 메모리 집약적인 단기 작업의 성능을 극적으로 향상시킬 수 있습니다.

개별 free() 호출 없이 일괄 정리하므로 할당/해제 오버헤드가 거의 사라집니다. 게임 엔진의 프레임당 아레나, 웹 서버의 요청당 아레나, 컴파일러의 함수별 아레나 같은 패턴에 이상적입니다.

메모리 단편화도 완전히 제거되어 장기 실행 시에도 안정적인 성능을 유지합니다.

실전 팁

💡 ArenaScope를 사용할 때는 스코프 밖에서 할당된 객체를 참조하지 않도록 주의하세요. 댕글링 포인터는 디버깅하기 매우 어렵습니다.

💡 릴리즈 빌드에서도 debug_assertions를 일부 유지하려면 cfg!(debug_assertions) 대신 커스텀 feature flag를 사용하세요.

💡 리셋 전에 메모리를 특정 패턴(0xDE)으로 채우면 use-after-free 버그를 빠르게 감지할 수 있습니다.

💡 체크포인트를 스택에 저장하는 대신 TLS(Thread-Local Storage)에 저장하면 중첩된 아레나를 자동으로 관리할 수 있습니다.

💡 프로덕션 환경에서는 리셋 전에 할당된 객체들의 Drop을 호출할지 결정하세요. 대부분의 경우 생략해도 되지만, RAII 리소스(파일 핸들 등)가 있다면 수동 정리가 필요합니다.


9. 멀티코어 최적화

시작하며

여러분이 4코어 이상의 시스템에서 커널을 실행할 때, 단일 전역 할당자가 병목이 된다는 것을 발견한 적 있나요? 모든 CPU가 하나의 뮤텍스를 두고 경쟁하면서 대부분의 시간을 대기하는데 낭비합니다.

이것은 Amdahl의 법칙의 전형적인 사례입니다. 아무리 많은 코어를 추가해도 직렬화된 부분(여기서는 락을 잡는 것)이 전체 성능을 제한합니다.

프로파일링 도구로 보면 대부분의 CPU 시간이 spinlock에서 소모되는 것을 볼 수 있습니다. 바로 이럴 때 필요한 것이 Per-CPU 할당자 패턴입니다.

각 CPU 코어가 자신만의 전용 할당자를 가지면 락 경합이 사라지고 거의 선형에 가까운 확장성을 얻을 수 있습니다.

개요

간단히 말해서, Per-CPU 할당자는 각 CPU 코어에 독립적인 메모리 풀을 할당해서 동기화 오버헤드를 제거하는 기법입니다. 여러분이 은행 창구를 생각해보세요.

창구가 하나뿐이면 모든 고객이 한 줄로 서서 기다려야 합니다. 하지만 창구를 여러 개 만들면 고객들이 분산되어 대기 시간이 줄어듭니다.

Per-CPU 할당자도 마찬가지입니다. 각 코어가 자신만의 힙을 가지므로 다른 코어와 경쟁할 필요가 없습니다.

락을 전혀 잡지 않거나, 매우 드물게만 잡으므로 성능이 극적으로 향상됩니다. 기존의 전역 할당자는 모든 할당 요청이 하나의 뮤텍스를 통과해야 했습니다.

코어 수가 증가할수록 경합이 심해져서 확장성이 떨어졌습니다. 하지만 Per-CPU 패턴은 각 코어가 독립적으로 작동하므로 코어 수에 비례해서 처리량이 증가합니다.

핵심 특징은: 1) CPU ID 기반 할당자 선택, 2) 락 없는 할당으로 극대화된 성능, 3) 메모리 풀 고갈 시 폴백 메커니즘. 이러한 특징들이 현대 멀티코어 시스템에서 최대 성능을 끌어냅니다.

코드 예제

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

// CPU 코어 개수 상수
const MAX_CPUS: usize = 8;

// Per-CPU 할당자 배열
static PER_CPU_ALLOCATORS: [Locked<BumpAllocator>; MAX_CPUS] = [
    Locked::new(BumpAllocator::new()),
    Locked::new(BumpAllocator::new()),
    // ... 나머지 코어들 ...
];

// 현재 CPU ID 가져오기 (아키텍처 의존적)
#[cfg(target_arch = "x86_64")]
fn current_cpu_id() -> usize {
    use x86_64::registers::model_specific::Msr;
    let apic_id = unsafe { Msr::new(0x802).read() };
    (apic_id & 0xFF) as usize
}

// Per-CPU 할당자를 사용하는 GlobalAlloc 구현
unsafe impl GlobalAlloc for PerCpuAllocator {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        let cpu_id = current_cpu_id();

        // 현재 CPU의 할당자에서 시도
        let ptr = PER_CPU_ALLOCATORS[cpu_id].lock().alloc(layout);

        if !ptr.is_null() {
            return ptr;
        }

        // 실패 시 다른 CPU의 할당자에서 폴백
        for i in 0..MAX_CPUS {
            if i == cpu_id { continue; }
            let ptr = PER_CPU_ALLOCATORS[i].lock().alloc(layout);
            if !ptr.is_null() {
                return ptr;
            }
        }

        null_mut()  // 모든 풀 고갈
    }

    unsafe fn dealloc(&self, _ptr: *mut u8, _layout: Layout) {
        // Bump allocator는 해제 없음
    }
}

설명

이것이 하는 일: Per-CPU 할당자 패턴은 멀티코어 시스템에서 메모리 할당의 확장성을 극대화합니다. 첫 번째로, PER_CPU_ALLOCATORS 배열의 구조를 살펴봅시다.

각 CPU 코어마다 별도의 BumpAllocator 인스턴스가 있습니다. MAX_CPUS를 8로 설정했지만, 실제 시스템에 맞게 조정할 수 있습니다.

배열의 각 원소는 여전히 Locked로 감싸져 있는데, 이것은 인터럽트가 발생해서 같은 코어에서 여러 컨텍스트가 실행될 수 있기 때문입니다. 하지만 대부분의 경우 각 코어는 자신의 할당자만 접근하므로 락 경합이 거의 없습니다.

그 다음으로, current_cpu_id() 함수가 현재 실행 중인 CPU 코어를 식별합니다. x86_64에서는 APIC ID를 읽어서 코어 번호를 얻습니다.

이것은 아키텍처 의존적이므로 ARM, RISC-V 등에서는 다른 방법을 사용해야 합니다. 중요한 점은 이 함수가 매우 빠르게 실행되어야 한다는 것입니다.

캐시된 값을 사용하거나 레지스터에서 직접 읽는 것이 이상적입니다. 마지막으로, alloc() 구현의 폴백 로직을 봅시다.

먼저 현재 CPU의 할당자에서 할당을 시도합니다. 이것이 fast path로, 대부분의 경우 여기서 성공합니다.

실패하면(힙이 고갈되면) 다른 CPU들의 할당자를 순회하면서 시도합니다. 이것은 slow path로, 여러 락을 잡아야 하므로 느립니다.

하지만 메모리 부족 상황에서도 가용한 메모리를 최대한 활용할 수 있습니다. 모든 풀이 고갈되면 null을 반환합니다.

여러분이 이 코드를 사용하면 멀티코어 시스템에서 할당 성능이 코어 수에 비례해서 증가합니다. 4코어 시스템에서는 거의 4배, 8코어에서는 거의 8배의 처리량을 얻을 수 있습니다.

락 경합으로 인한 대기 시간이 사라지므로 레이턴시도 크게 개선됩니다. 이것은 고성능 네트워크 서버, 병렬 컴파일러, 멀티스레드 게임 엔진 같은 워크로드에 필수적입니다.

실전 팁

💡 각 CPU의 힙 크기를 동적으로 조정하려면 통계를 수집해서 사용률이 높은 코어에 더 많은 메모리를 할당하세요.

💡 current_cpu_id()를 호출하는 비용이 크다면 TLS(Thread-Local Storage)에 캐시하세요. 단, 마이그레이션을 비활성화해야 합니다.

💡 NUMA 시스템에서는 각 CPU의 할당자가 같은 NUMA 노드의 메모리를 사용하도록 배치하면 접근 레이턴시가 줄어듭니다.

💡 폴백 로직의 순서를 최적화하세요. 같은 NUMA 노드의 코어부터 시도하면 원격 메모리 접근을 피할 수 있습니다.

💡 프로덕션 환경에서는 각 코어의 할당/폴백 통계를 추적해서 불균형을 감지하고 조정하세요.


10. 실전 통합 예제

시작하며

여러분이 지금까지 배운 모든 개념들을 실제 OS 커널에 어떻게 통합해야 할지 궁금하지 않으셨나요? 이론은 이해했지만, 실제 부팅 시퀀스에서 언제, 어떻게 초기화하고 사용해야 하는지 명확하지 않을 수 있습니다.

실제 커널 개발에서는 많은 고려사항들이 있습니다. 부트로더와의 인터페이스, 페이지 테이블 설정 순서, 인터럽트 핸들러에서의 사용, 멀티태스킹 환경에서의 안전성 등 복잡한 요소들이 얽혀 있습니다.

잘못된 순서로 초기화하면 디버깅하기 어려운 버그가 발생합니다. 바로 이럴 때 필요한 것이 실전 통합 예제입니다.

부팅부터 멀티태스킹까지, 실제 커널에서 Bump Allocator를 어떻게 사용하는지 전체 흐름을 보여드리겠습니다.

개요

간단히 말해서, 실전 통합은 모든 개념을 연결해서 완전히 작동하는 OS 커널의 메모리 할당 시스템을 구축하는 과정입니다. 여러분이 레고 블록으로 집을 짓는 것을 생각해보세요.

개별 블록들(할당자, 정렬, 통계 등)은 이미 만들었지만, 이것들을 올바른 순서와 방법으로 조립해야 완성된 집이 됩니다. 실전 통합도 마찬가지입니다.

각 컴포넌트를 올바른 시점에 초기화하고, 서로 협력하도록 연결하며, 에러 처리와 폴백 메커니즘을 추가해서 견고한 시스템을 만듭니다. 기존의 튜토리얼은 각 개념을 따로 설명하지만 통합 방법은 생략하는 경우가 많았습니다.

하지만 실제 개발에서는 통합이 가장 어렵고 중요한 부분입니다. 올바른 통합은 안정성과 유지보수성을 크게 향상시킵니다.

핵심 특징은: 1) 부트로더부터 멀티태스킹까지 전체 흐름, 2) 에러 처리와 복구 메커니즘, 3) 실무에서 바로 사용 가능한 완전한 코드. 이러한 특징들이 학습에서 실전으로의 다리 역할을 합니다.

코드 예제

// main.rs - 커널 엔트리 포인트
#![no_std]
#![no_main]

use bootloader::{BootInfo, entry_point};
use core::panic::PanicInfo;

entry_point!(kernel_main);

fn kernel_main(boot_info: &'static BootInfo) -> ! {
    // 1. 시리얼 출력 초기화 (로깅용)
    serial::init();
    println!("Kernel started!");

    // 2. GDT, IDT 설정 (세그먼테이션, 인터럽트)
    gdt::init();
    interrupts::init();

    // 3. 페이지 테이블 초기화 (가상 메모리)
    let phys_mem_offset = VirtAddr::new(boot_info.physical_memory_offset);
    let mut mapper = unsafe { memory::init(phys_mem_offset) };

    // 4. 힙 할당자 초기화 ⭐ 핵심 부분
    memory::init_heap(&boot_info.memory_map)
        .expect("Failed to initialize heap");
    println!("Heap initialized: {} bytes", HEAP_SIZE);

    // 5. 할당 테스트
    test_allocator();

    // 6. 멀티태스킹 초기화
    task::init();

    // 7. 메인 루프
    println!("Entering main loop...");
    kernel_loop()
}

// 할당자 통합 테스트
fn test_allocator() {
    use alloc::vec::Vec;
    use alloc::boxed::Box;

    // Vec 테스트
    let mut vec = Vec::new();
    for i in 0..100 {
        vec.push(i);
    }
    println!("Vec test passed: {} elements", vec.len());

    // Box 테스트
    let boxed = Box::new(42);
    println!("Box test passed: {}", *boxed);

    // 통계 출력
    let stats = ALLOCATOR.lock().stats();
    println!("Allocator stats: {:#?}", stats);
}

// 패닉 핸들러
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
    println!("KERNEL PANIC: {}", info);

    // 할당자 상태 덤프 (디버깅용)
    if let Ok(allocator) = ALLOCATOR.try_lock() {
        println!("Allocator state: {:#?}", *allocator);
    }

    loop { x86_64::instructions::hlt(); }
}

// 인터럽트 핸들러에서 안전하게 할당
extern "x86-interrupt" fn keyboard_interrupt_handler(_stack_frame: InterruptStackFrame) {
    // 인터럽트 컨텍스트에서는 최소한의 할당만
    // 큰 할당은 deferred work로 이동
    let key = keyboard::read_scancode();

    // 안전: 짧은 크리티컬 섹션
    KEYBOARD_BUFFER.lock().push(key);
}

설명

이것이 하는 일: 실전 통합 예제는 실제 OS 커널에서 Bump Allocator를 사용하는 완전한 워크플로우를 보여줍니다. 첫 번째로, kernel_main()의 초기화 순서가 매우 중요합니다.

시리얼 출력을 가장 먼저 초기화하는 이유는 이후 단계에서 발생하는 오류를 출력하기 위함입니다. GDT와 IDT는 세그먼트와 인터럽트를 설정하는데, 이것들이 없으면 커널이 제대로 작동하지 않습니다.

페이지 테이블 초기화가 힙 초기화 전에 와야 하는 이유는 가상 메모리 매핑이 활성화되어야 부트로더가 제공한 메모리 맵을 신뢰할 수 있기 때문입니다. 그 다음으로, init_heap() 호출이 핵심입니다.

Result를 반환하므로 expect()로 오류를 처리합니다. 실패하면 의미 있는 에러 메시지와 함께 패닉합니다.

성공하면 HEAP_SIZE를 출력해서 확인합니다. test_allocator() 함수는 Vec과 Box를 실제로 사용해서 할당자가 작동하는지 검증합니다.

이것은 매우 중요한데, 초기 단계에서 문제를 발견하면 나중에 디버깅 지옥을 피할 수 있습니다. 마지막으로, 프로덕션 환경의 고려사항들입니다.

panic_handler에서 할당자 상태를 덤프하면 크래시 원인을 분석하기 쉽습니다. try_lock()을 사용하는 이유는 패닉이 이미 락을 잡고 있는 상태에서 발생했을 수 있기 때문입니다.

keyboard_interrupt_handler 예시는 인터럽트 컨텍스트에서 할당할 때의 주의사항을 보여줍니다. 인터럽트는 언제든 발생할 수 있고, 크리티컬 섹션을 짧게 유지해야 하므로 큰 할당은 피하고 작은 버퍼만 사용합니다.

여러분이 이 코드를 사용하면 실제로 작동하는 OS 커널에 메모리 할당 기능을 통합할 수 있습니다. 모든 에러 케이스가 처리되어 있고, 디버깅 정보가 풍부하며, 실무에서 바로 사용할 수 있는 수준의 견고함을 갖추고 있습니다.

이것을 기반으로 더 복잡한 할당자(slab, buddy system)로 발전시킬 수 있으며, 커널 개발의 다음 단계로 나아갈 수 있습니다.

실전 팁

💡 부팅 순서를 문서화하고 주석으로 남기세요. 몇 달 후에 코드를 보면 왜 이 순서인지 기억하기 어렵습니다.

💡 각 초기화 단계마다 println!으로 진행 상황을 출력하세요. 부팅 중 크래시 시 어느 단계에서 실패했는지 바로 알 수 있습니다.

💡 테스트 함수를 조건부 컴파일(#[cfg(test)])로 분리하면 릴리즈 빌드 크기를 줄일 수 있습니다.

💡 인터럽트 핸들러에서 할당이 필요하다면 per-CPU 버퍼를 미리 할당해두고 재사용하는 패턴을 고려하세요.

💡 QEMU에서 먼저 테스트하고, 안정화된 후 실제 하드웨어에서 테스트하세요. QEMU는 디버깅 기능이 풍부해서 초기 개발에 이상적입니다.


#Rust#OS개발#BumpAllocator#메모리관리#커널프로그래밍#시스템프로그래밍

댓글 (0)

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