이미지 로딩 중...

Rust로 만드는 나만의 OS 19 힙 할당자 구현 완벽 가이드 - 슬라이드 1/11
A

AI Generated

2025. 11. 13. · 3 Views

Rust로 만드는 나만의 OS 19 힙 할당자 구현 완벽 가이드

운영체제 개발의 핵심인 힙 할당자를 Rust로 직접 구현해봅니다. Linked List Allocator부터 Fixed-Size Block Allocator까지, 실무에서 사용되는 메모리 관리 기법을 단계별로 학습하고, OS 개발의 깊이 있는 이해를 얻을 수 있습니다.


목차

  1. GlobalAllocator 트레잇 - OS에서 힙 메모리 관리의 시작점
  2. Linked List Allocator - 단순하지만 강력한 첫 번째 힙 관리 방식
  3. Fixed-Size Block Allocator - 고정 크기로 단편화를 근본적으로 해결
  4. Bump Allocator - 가장 빠른 할당자의 원리
  5. 메모리 정렬(Alignment) - 성능과 안정성의 숨은 열쇠
  6. alloc_error_handler - OOM 상황을 우아하게 처리하기
  7. Heap Initialization - 부트스트랩의 핵심 단계
  8. 동시성 안전(Concurrency Safety) - 멀티코어 환경의 필수 요소
  9. 메모리 단편화(Fragmentation) - 장기 실행의 숙적
  10. 힙 오버플로우와 언더플로우 방어 - 메모리 안전성의 최전선

1. GlobalAllocator 트레잇 - OS에서 힙 메모리 관리의 시작점

시작하며

여러분이 운영체제를 개발하면서 동적 메모리 할당이 필요한 순간을 맞이한 적 있나요? Vec, Box, String 같은 타입을 사용하려고 하는데 "no global allocator found"라는 에러 메시지를 마주한 경험이 있을 겁니다.

이런 문제는 일반 애플리케이션 개발에서는 경험할 수 없는, OS 개발만의 특별한 도전 과제입니다. 표준 라이브러리가 제공하는 기본 할당자가 없기 때문에, 우리가 직접 메모리 할당자를 구현하고 등록해야 합니다.

바로 이럴 때 필요한 것이 GlobalAllocator 트레잇입니다. 이것은 Rust의 메모리 할당 시스템과 우리가 만든 커스텀 할당자를 연결해주는 핵심 인터페이스입니다.

개요

간단히 말해서, GlobalAllocator는 Rust가 힙 메모리를 할당하고 해제할 때 호출할 함수들을 정의하는 표준 인터페이스입니다. 운영체제 개발에서는 표준 라이브러리의 기본 할당자를 사용할 수 없습니다.

왜냐하면 우리가 만드는 것이 바로 그 기반이 되는 운영체제이기 때문입니다. 따라서 alloc과 dealloc 메서드를 직접 구현하여, Rust 컴파일러가 동적 메모리가 필요할 때 우리의 코드를 호출하도록 만들어야 합니다.

기존 애플리케이션 개발에서는 운영체제가 제공하는 malloc/free를 사용했다면, 이제는 우리가 직접 그 malloc/free를 구현하는 단계로 나아가는 것입니다. 이 트레잇의 핵심 특징은 첫째, unsafe 함수로 구성되어 있어 메모리 안전성을 개발자가 직접 보장해야 하고, 둘째, #[global_allocator] 속성으로 전역 할당자를 지정할 수 있으며, 셋째, alloc과 dealloc이라는 저수준 API를 통해 바이트 단위로 메모리를 관리합니다.

이러한 특징들이 OS 개발에서 완전한 메모리 제어권을 제공하기 때문에 중요합니다.

코드 예제

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

// 더미 할당자 구조체
pub struct DummyAllocator;

unsafe impl GlobalAlloc for DummyAllocator {
    unsafe fn alloc(&self, _layout: Layout) -> *mut u8 {
        // 실제로는 메모리 할당 로직 구현
        null_mut()
    }

    unsafe fn dealloc(&self, _ptr: *mut u8, _layout: Layout) {
        // 실제로는 메모리 해제 로직 구현
        panic!("dealloc should never be called")
    }
}

// 전역 할당자 등록
#[global_allocator]
static ALLOCATOR: DummyAllocator = DummyAllocator;

설명

이것이 하는 일: GlobalAllocator 트레잇을 구현하면 Rust 컴파일러가 Box::new()나 Vec::push() 같은 힙 할당이 필요한 코드를 만날 때, 우리가 정의한 alloc 메서드를 자동으로 호출하게 됩니다. 첫 번째로, DummyAllocator 구조체를 정의하고 GlobalAlloc 트레잇을 구현합니다.

alloc 메서드는 Layout 파라미터를 받는데, 이것은 할당할 메모리의 크기와 정렬 요구사항을 담고 있습니다. 예를 들어 u64를 할당한다면, 크기는 8바이트이고 정렬은 8바이트 경계가 필요합니다.

이렇게 구체적인 메모리 레이아웃 정보를 받아서 적절한 메모리 블록을 찾아 반환하는 것이 alloc의 역할입니다. 그 다음으로, dealloc 메서드가 실행되면서 이전에 할당한 메모리를 회수합니다.

포인터와 함께 원래의 Layout 정보도 받는데, 이는 할당자가 어떤 크기의 블록을 해제해야 하는지 알 수 있도록 합니다. 내부에서는 해당 메모리 블록을 프리 리스트에 다시 추가하거나, 인접한 빈 블록들과 병합하는 등의 작업이 일어납니다.

마지막으로, #[global_allocator] 속성을 사용하여 우리의 할당자를 전역 할당자로 등록합니다. 이렇게 하면 크레이트 전체에서 발생하는 모든 힙 할당 요청이 이 할당자를 통과하게 됩니다.

여러분이 이 코드를 사용하면 운영체제 커널 내에서도 Rust의 고수준 컬렉션 타입들을 자유롭게 사용할 수 있습니다. Vec으로 동적 배열을 관리하고, BTreeMap으로 효율적인 검색 구조를 만들고, String으로 텍스트를 처리하는 등, 일반 Rust 프로그래밍에서 누리던 편의성을 OS 개발에서도 똑같이 활용할 수 있게 됩니다.

실전 팁

💡 처음에는 간단한 Dummy Allocator로 시작해서 패닉만 발생시키고, 점진적으로 실제 할당 로직을 추가하세요. 한 번에 완벽한 할당자를 만들려고 하면 디버깅이 매우 어렵습니다.

💡 alloc이 null_mut()를 반환하면 Rust는 OOM(Out of Memory) 상황으로 인식합니다. 프로덕션 환경에서는 적절한 에러 핸들링 전략을 수립하세요.

💡 Layout의 align() 요구사항을 반드시 지켜야 합니다. 잘못 정렬된 메모리 주소를 반환하면 undefined behavior가 발생하며, 특히 SIMD 명령어를 사용하는 코드에서 크래시가 발생할 수 있습니다.

💡 GlobalAlloc는 스레드 안전해야 합니다(Sync 트레잇 구현 필요). 멀티코어 환경에서는 스핀락이나 원자적 연산을 사용해 동시성 문제를 해결하세요.

💡 할당자 구현을 테스트할 때는 작은 크기부터 시작하세요. 1바이트, 8바이트, 4KB 페이지 크기 등 다양한 크기의 할당을 테스트하면서 경계 조건을 검증하세요.


2. Linked List Allocator - 단순하지만 강력한 첫 번째 힙 관리 방식

시작하며

여러분이 처음으로 실제 동작하는 힙 할당자를 구현하려고 할 때, 어떤 알고리즘을 선택해야 할지 고민하신 적 있나요? 복잡한 Buddy System이나 Slab Allocator는 이해하기도 어렵고, 구현하기는 더욱 어렵습니다.

이런 상황에서 가장 좋은 시작점은 바로 Linked List Allocator입니다. 빈 메모리 블록들을 연결 리스트로 관리하는 이 방식은 구현이 단순하면서도, 실제로 작동하는 완전한 메모리 할당자의 모든 핵심 개념을 담고 있습니다.

초기 OS 개발 단계에서 Linked List Allocator를 먼저 구현하면, 힙 메모리 관리의 기본 원리를 확실하게 이해하고, 이후 더 고급 알고리즘으로 발전시킬 수 있는 탄탄한 기반을 마련할 수 있습니다.

개요

간단히 말해서, Linked List Allocator는 사용 가능한 모든 메모리 블록을 연결 리스트로 연결하고, 할당 요청이 들어오면 리스트를 순회하면서 적합한 크기의 블록을 찾는 방식입니다. 왜 이 방식이 필요한가?

OS 개발 초기에는 복잡한 자료구조를 사용하기 어렵습니다. 하지만 Linked List는 메모리 블록 자체에 next 포인터를 저장하는 방식이라 별도의 메타데이터 공간이 거의 필요 없습니다.

예를 들어, 1MB의 힙 영역이 있다면, 그 안에 있는 빈 블록들끼리 서로를 가리키면서 자연스럽게 관리 구조가 형성됩니다. 전통적인 malloc 구현에서는 복잡한 트리 구조나 비트맵을 사용했다면, 이제는 단순한 연결 리스트만으로도 충분히 동작하는 할당자를 만들 수 있습니다.

물론 성능은 O(n)이지만, 초기 개발 단계에서는 정확성이 속도보다 중요합니다. 이 알고리즘의 핵심 특징은 첫째, First-Fit 전략으로 리스트의 앞에서부터 순회하며 첫 번째로 충분히 큰 블록을 선택하고, 둘째, 블록 분할(splitting)을 통해 큰 블록에서 필요한 만큼만 떼어내고 나머지는 다시 리스트에 추가하며, 셋째, 인접 블록 병합(coalescing)으로 메모리 단편화를 줄입니다.

이러한 특징들이 제한된 메모리를 효율적으로 재사용할 수 있게 해줍니다.

코드 예제

struct ListNode {
    size: usize,
    next: Option<&'static mut ListNode>,
}

pub struct LinkedListAllocator {
    head: Option<&'static mut ListNode>,
}

impl LinkedListAllocator {
    // 메모리 영역 초기화
    pub unsafe fn init(&mut self, heap_start: usize, heap_size: usize) {
        let node_ptr = heap_start as *mut ListNode;
        node_ptr.write(ListNode {
            size: heap_size,
            next: None,
        });
        self.head = Some(&mut *node_ptr);
    }

    // First-Fit으로 적합한 블록 찾기
    fn find_region(&mut self, size: usize, align: usize) -> Option<(&'static mut ListNode, usize)> {
        let mut current = &mut self.head;
        while let Some(ref mut region) = current {
            if let Ok(alloc_start) = Self::alloc_from_region(region, size, align) {
                let next = region.next.take();
                let ret = Some((current.take().unwrap(), alloc_start));
                *current = next;
                return ret;
            } else {
                current = &mut region.next;
            }
        }
        None
    }

    fn alloc_from_region(region: &ListNode, size: usize, align: usize) -> Result<usize, ()> {
        let alloc_start = align_up(region.start_addr(), align);
        let alloc_end = alloc_start.checked_add(size).ok_or(())?;
        if alloc_end > region.end_addr() {
            return Err(());
        }
        Ok(alloc_start)
    }
}

설명

이것이 하는 일: Linked List Allocator는 힙 영역을 여러 개의 메모리 블록으로 나누고, 사용 중이지 않은 블록들을 연결 리스트로 연결합니다. 할당 요청이 들어오면 리스트를 처음부터 순회하면서 요청한 크기와 정렬 조건을 만족하는 첫 번째 블록을 찾아 반환합니다.

첫 번째로, init 메서드에서 전체 힙 영역을 하나의 큰 빈 블록으로 초기화합니다. heap_start 주소에 ListNode 구조체를 직접 작성하는데, 이것이 핵심입니다.

별도의 메모리를 할당하지 않고, 힙 메모리 자체를 메타데이터 저장소로 사용하는 것입니다. 이 방식은 "in-place" 또는 "intrusive" 자료구조라고 불리며, 커널 개발에서 매우 흔하게 사용됩니다.

그 다음으로, find_region 메서드가 실행되면서 연결 리스트를 순회합니다. 각 ListNode에 대해 alloc_from_region을 호출하여 정렬 조건을 고려한 실제 할당 시작 주소를 계산합니다.

예를 들어 16바이트 정렬이 필요하다면, 블록의 시작 주소가 13번지여도 실제 할당은 16번지부터 시작해야 합니다. align_up 함수가 이런 정렬 계산을 담당합니다.

적합한 블록을 찾으면, 해당 블록을 리스트에서 제거하고 사용자에게 반환합니다. 만약 블록이 요청보다 훨씬 크다면, 필요한 만큼만 떼어내고 나머지 부분은 새로운 ListNode로 만들어 다시 리스트에 삽입합니다.

이 과정을 블록 분할(splitting)이라고 합니다. 메모리 해제 시에는 반환된 블록을 리스트에 다시 추가하는데, 이때 주소 순서를 유지하면서 삽입합니다.

그리고 인접한 블록들이 있다면 하나로 합치는 병합(coalescing) 작업을 수행합니다. 예를 들어 100-200번지 블록과 200-300번지 블록이 연속되어 있다면, 이를 100-300번지 하나의 큰 블록으로 만듭니다.

여러분이 이 알고리즘을 사용하면 최소한의 코드로 완전히 동작하는 메모리 할당자를 얻을 수 있습니다. 디버깅도 쉽고, 리스트를 순회하면서 현재 상태를 출력하면 메모리 상황을 한눈에 파악할 수 있습니다.

초기 OS 개발에서 필수적인 Vec, String 같은 타입들을 사용할 수 있게 되어, 개발 속도가 크게 향상됩니다.

실전 팁

💡 리스트 순회 중에 포인터를 잘못 다루면 전체 힙이 손상됩니다. 각 단계마다 assert를 추가해서 불변 조건(예: 블록 크기가 항상 양수, next 포인터가 유효한 범위 등)을 검증하세요.

💡 최소 블록 크기를 정의하세요(예: 16바이트). 너무 작은 블록들이 생기면 메타데이터 크기가 실제 데이터보다 커지는 역전 현상이 발생합니다.

💡 해제할 때 반드시 병합(coalescing)을 수행하세요. 그렇지 않으면 외부 단편화(external fragmentation)가 심해져서, 충분한 총 메모리가 있어도 큰 연속 블록을 할당할 수 없게 됩니다.

💡 디버깅을 위해 dump_list() 메서드를 구현하세요. 현재 프리 리스트의 모든 블록을 출력하면, 메모리 누수나 중복 해제 같은 버그를 빠르게 찾을 수 있습니다.

💡 성능이 중요해지면 Best-Fit이나 Worst-Fit 같은 다른 전략을 시도해보세요. First-Fit은 빠르지만 단편화가 심하고, Best-Fit은 느리지만 메모리 효율이 더 좋습니다.


3. Fixed-Size Block Allocator - 고정 크기로 단편화를 근본적으로 해결

시작하며

여러분이 Linked List Allocator를 사용하다 보면, 시간이 지날수록 작은 블록들이 여기저기 흩어지는 단편화 문제를 경험하게 됩니다. 10KB의 빈 공간이 있지만, 1KB씩 10개로 쪼개져 있어서 5KB 할당 요청을 처리하지 못하는 상황이 발생합니다.

이런 문제는 가변 크기 할당의 근본적인 한계입니다. 아무리 영리한 병합 알고리즘을 사용해도, 복잡한 할당 패턴이 반복되면 결국 메모리가 스위스 치즈처럼 구멍투성이가 됩니다.

바로 이럴 때 필요한 것이 Fixed-Size Block Allocator입니다. 몇 가지 고정된 크기의 블록만 제공함으로써, 단편화 문제를 아예 발생하지 않게 만드는 혁신적인 접근 방식입니다.

개요

간단히 말해서, Fixed-Size Block Allocator는 힙 메모리를 미리 정해진 크기의 블록들로 나누고, 각 크기별로 별도의 프리 리스트를 관리하는 방식입니다. 예를 들어 16, 32, 64, 128, 256바이트 같은 고정 크기들을 지원합니다.

왜 이 방식이 실무에서 중요한가? 실제 커널 개발을 보면, 대부분의 할당이 몇 가지 특정 크기로 집중됩니다.

프로세스 디스크립터는 항상 같은 크기이고, 파일 디스크립터도 마찬가지입니다. 이런 반복적인 할당 패턴에서는 고정 크기 블록이 완벽하게 들어맞습니다.

Linux 커널의 Slab Allocator도 바로 이 원리를 사용합니다. 전통적인 가변 크기 할당에서는 할당과 해제 때마다 블록을 분할하고 병합하는 복잡한 작업을 했다면, 이제는 단순히 프리 리스트에서 블록을 꺼내고 넣는 O(1) 연산만으로 충분합니다.

성능이 획기적으로 향상됩니다. 이 알당자의 핵심 특징은 첫째, 여러 개의 블록 크기를 지원하되 각각 독립적인 프리 리스트로 관리하고, 둘째, 할당 요청을 가장 가까운 큰 크기로 반올림하여(round up) 처리하며, 셋째, 모든 연산이 O(1) 시간 복잡도를 가집니다.

이러한 특징들이 실시간 시스템이나 성능이 중요한 커널 코드에서 예측 가능한 지연시간을 보장해줍니다.

코드 예제

const BLOCK_SIZES: &[usize] = &[8, 16, 32, 64, 128, 256, 512, 1024, 2048];

struct ListNode {
    next: Option<&'static mut ListNode>,
}

pub struct FixedSizeBlockAllocator {
    list_heads: [Option<&'static mut ListNode>; BLOCK_SIZES.len()],
    fallback_allocator: linked_list_allocator::Heap,
}

impl FixedSizeBlockAllocator {
    // 크기에 맞는 리스트 인덱스 찾기
    fn list_index(size: usize) -> Option<usize> {
        BLOCK_SIZES.iter().position(|&s| s >= size)
    }

    // 고정 크기 블록 할당
    fn alloc_block(&mut self, layout: Layout) -> *mut u8 {
        match Self::list_index(layout.size()) {
            Some(index) => {
                // 해당 크기의 프리 리스트에서 블록 가져오기
                match self.list_heads[index].take() {
                    Some(node) => {
                        self.list_heads[index] = node.next.take();
                        node as *mut ListNode as *mut u8
                    }
                    None => {
                        // 프리 리스트가 비어있으면 새 블록 할당
                        let block_size = BLOCK_SIZES[index];
                        let block_align = block_size;
                        let layout = Layout::from_size_align(block_size, block_align).unwrap();
                        self.fallback_allocator.allocate_first_fit(layout)
                            .ok()
                            .map_or(null_mut(), |allocation| allocation.as_ptr())
                    }
                }
            }
            None => {
                // 너무 큰 요청은 fallback allocator 사용
                self.fallback_allocator.allocate_first_fit(layout)
                    .ok()
                    .map_or(null_mut(), |allocation| allocation.as_ptr())
            }
        }
    }

    // 블록 해제
    fn dealloc_block(&mut self, ptr: *mut u8, layout: Layout) {
        match Self::list_index(layout.size()) {
            Some(index) => {
                let new_node = ListNode {
                    next: self.list_heads[index].take(),
                };
                let new_node_ptr = ptr as *mut ListNode;
                unsafe { new_node_ptr.write(new_node) };
                self.list_heads[index] = Some(unsafe { &mut *new_node_ptr });
            }
            None => {
                // 큰 블록은 fallback allocator로 해제
                unsafe {
                    self.fallback_allocator.deallocate(
                        core::ptr::NonNull::new_unchecked(ptr),
                        layout,
                    );
                }
            }
        }
    }
}

설명

이것이 하는 일: Fixed-Size Block Allocator는 힙 메모리를 8, 16, 32, 64바이트 같은 여러 고정 크기로 나누고, 각 크기별로 독립적인 프리 리스트를 유지합니다. 할당 요청이 들어오면 적절한 크기의 리스트에서 즉시 블록을 꺼내주고, 해제 시에는 다시 해당 리스트에 넣기만 하면 됩니다.

첫 번째로, list_index 함수가 요청된 크기를 분석하여 적절한 블록 크기를 선택합니다. 예를 들어 20바이트 할당 요청이 들어오면, BLOCK_SIZES 배열에서 20 이상인 첫 번째 값인 32를 찾습니다.

이것을 "크기 클래스(size class)"라고 부르며, Slab Allocator의 핵심 개념입니다. 이렇게 반올림함으로써 12바이트는 낭비되지만, 대신 관리가 매우 단순해집니다.

그 다음으로, alloc_block 메서드가 해당 크기의 프리 리스트를 확인합니다. list_heads[index]에 노드가 있다면, 단순히 리스트의 head를 떼어내서 반환합니다.

이것은 스택의 pop 연산과 동일하며, 포인터 몇 개만 조작하면 되므로 극도로 빠릅니다. 만약 프리 리스트가 비어있다면, fallback_allocator를 사용해서 새로운 블록을 큰 힙에서 할당받아옵니다.

메모리 해제는 더욱 간단합니다. dealloc_block에서는 반환된 메모리 주소에 새로운 ListNode를 작성하고, 해당 크기의 프리 리스트 맨 앞에 삽입합니다.

병합이나 분할 같은 복잡한 작업이 전혀 필요 없습니다. 32바이트 블록은 해제되어도 여전히 32바이트 블록이고, 다음 32바이트 할당 요청에 즉시 재사용됩니다.

핵심적인 최적화는 크기별 분리입니다. 작은 할당들(8-64바이트)은 대부분 프리 리스트에서 처리되어 매우 빠르고, 큰 할당들은 별도의 fallback allocator가 처리합니다.

이 이중 구조가 일반적인 경우의 성능을 극대화하면서도, 극단적인 경우(매우 큰 할당)도 처리할 수 있게 해줍니다. 여러분이 이 알고리즘을 사용하면 예측 가능한 성능을 얻을 수 있습니다.

할당이 얼마나 걸릴지 정확히 알 수 있으므로(항상 O(1)), 실시간 운영체제나 게임 엔진 같은 지연시간에 민감한 시스템에 이상적입니다. 또한 단편화가 없어서 장시간 실행되는 서버 애플리케이션에서도 안정적으로 동작합니다.

실전 팁

💡 BLOCK_SIZES 배열을 워크로드에 맞게 조정하세요. 프로파일링을 통해 가장 자주 할당되는 크기들을 찾아내고, 그 크기들을 배열에 추가하면 내부 단편화(internal fragmentation)를 줄일 수 있습니다.

💡 각 프리 리스트의 길이를 추적하는 카운터를 추가하세요. 특정 크기의 블록이 계속 고갈된다면, 해당 크기의 풀을 미리 확장하는 등의 최적화가 가능합니다.

💡 더블 프리(double free) 버그를 방지하려면, 디버그 빌드에서 매직 넘버를 사용하세요. 해제된 블록의 첫 8바이트에 0xDEADBEEF 같은 패턴을 쓰고, 할당 시 검증하면 중복 해제를 즉시 탐지할 수 있습니다.

💡 멀티코어 환경에서는 per-CPU 프리 리스트를 고려하세요. 각 CPU 코어가 자신만의 프리 리스트를 가지면, 락 경합 없이 할당/해제를 수행할 수 있어 확장성이 크게 향상됩니다.

💡 큰 할당(예: 4KB 이상)은 별도의 페이지 단위 할당자로 처리하세요. 고정 크기 블록으로 큰 객체를 다루면 메모리 낭비가 심하므로, 하이브리드 접근이 최선입니다.


4. Bump Allocator - 가장 빠른 할당자의 원리

시작하며

여러분이 성능이 극도로 중요한 상황에 처해본 적 있나요? 예를 들어 부트로더나 초기 커널 초기화 단계처럼, 몇 개의 메모리 할당만 필요하고 절대 해제하지 않는 경우가 있습니다.

이런 상황에서 복잡한 프리 리스트나 메타데이터를 관리하는 것은 과도한 오버헤드입니다. 필요한 것은 단지 "다음 사용 가능한 주소"를 추적하고, 요청이 들어오면 그 주소를 반환하고 포인터를 앞으로 이동시키는 것뿐입니다.

바로 이럴 때 필요한 것이 Bump Allocator(또는 Arena Allocator)입니다. 가장 단순하고 빠른 메모리 할당 방식으로, 해제가 필요 없는 시나리오에서 완벽한 선택입니다.

개요

간단히 말해서, Bump Allocator는 힙의 시작 주소와 현재 위치를 나타내는 포인터 하나만 유지하고, 할당 요청마다 포인터를 앞으로 "bump"(이동)시키는 방식입니다. 왜 이것이 중요한가?

부트로더, 컴파일러의 임시 데이터, 프레임 단위로 리셋되는 게임 로직 등에서는 메모리를 "일회용"으로 사용합니다. 프레임이 끝나면 전체 아레나를 한 번에 리셋하므로, 개별 해제가 전혀 필요 없습니다.

이런 패턴에서 Bump Allocator는 단 몇 개의 CPU 사이클만으로 할당을 완료합니다. 전통적인 할당자에서는 프리 리스트를 순회하거나 메타데이터를 업데이트하는 데 수십~수백 사이클이 걸렸다면, 이제는 포인터 하나를 증가시키는 것만으로 충분합니다.

이것은 이론상 가능한 가장 빠른 할당 방식입니다. 이 할당자의 핵심 특징은 첫째, 할당이 O(1)이고 극도로 빠르며(단순 포인터 증가), 둘째, 개별 해제가 불가능하고 전체 리셋만 지원하며, 셋째, 메모리 단편화가 전혀 발생하지 않습니다.

이러한 특징들이 단순성과 성능을 최대화하지만, 사용 패턴에 제약을 가합니다.

코드 예제

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;
    }

    // 할당 - 단순히 포인터를 증가시킴
    pub fn alloc(&mut self, layout: Layout) -> *mut u8 {
        // 정렬 조정
        let alloc_start = align_up(self.next, layout.align());
        let alloc_end = match alloc_start.checked_add(layout.size()) {
            Some(end) => end,
            None => return null_mut(),
        };

        // 힙 범위 검사
        if alloc_end > self.heap_end {
            return null_mut(); // OOM
        }

        // 포인터를 앞으로 이동
        self.next = alloc_end;
        self.allocations += 1;
        alloc_start as *mut u8
    }

    // 개별 해제는 지원하지 않음 (no-op)
    pub fn dealloc(&mut self, _ptr: *mut u8, _layout: Layout) {
        // 개별 해제 불가능
        // 실제 구현에서는 allocations를 감소시키고
        // 0이 되면 리셋하는 방식으로 구현 가능
    }

    // 전체 아레나 리셋
    pub fn reset(&mut self) {
        self.next = self.heap_start;
        self.allocations = 0;
    }
}

fn align_up(addr: usize, align: usize) -> usize {
    (addr + align - 1) & !(align - 1)
}

설명

이것이 하는 일: Bump Allocator는 마치 스택처럼 동작하지만, 명시적으로 해제하지 않고 계속 앞으로만 나아갑니다. 현재 위치를 나타내는 next 포인터가 있고, 할당 요청마다 이 포인터를 요청한 크기만큼 증가시켜 다음 사용 가능한 주소를 준비합니다.

첫 번째로, init 메서드에서 힙의 시작과 끝 주소를 설정합니다. 이것은 우리가 사용할 수 있는 메모리 풀의 경계를 정의합니다.

next 포인터는 heap_start로 초기화되어, 첫 번째 할당이 힙의 맨 처음부터 시작되도록 합니다. 그 다음으로, alloc 메서드가 실행될 때 가장 먼저 하는 일은 정렬 계산입니다.

align_up 함수는 비트 연산을 사용하여 현재 next 포인터를 요청된 정렬 경계로 올림합니다. 예를 들어 next가 13이고 8바이트 정렬이 필요하다면, 16으로 올립니다.

이 정렬 연산은 (addr + align - 1) & !(align - 1) 공식을 사용하는데, 이것은 CPU 레벨에서 단 2-3개의 명령어로 실행되는 매우 효율적인 연산입니다. 할당 크기를 더한 후에는 힙 범위를 검사합니다.

alloc_end가 heap_end를 초과하면 메모리가 부족한 상황이므로 null_mut()를 반환합니다. 이것이 OOM(Out of Memory) 처리입니다.

범위 내에 있다면, next 포인터를 alloc_end로 업데이트하고 allocations 카운터를 증가시킵니다. dealloc 메서드는 기본적으로 아무것도 하지 않습니다.

개별 해제를 지원하지 않기 때문입니다. 하지만 allocations 카운터를 추적함으로써, 모든 할당이 "논리적으로" 해제되었을 때 reset을 호출하는 최적화가 가능합니다.

reset 메서드는 단순히 next를 heap_start로 되돌리고 카운터를 0으로 만듭니다. 이 한 줄의 코드로 전체 힙이 재사용 가능한 상태로 돌아갑니다.

여러분이 이 할당자를 사용하면 극한의 성능을 얻을 수 있습니다. 특히 요청 처리나 프레임 렌더링처럼 명확한 "시작-끝" 경계가 있는 작업에서, 작업 시작 시 할당하고 끝에 한 번에 리셋하는 패턴으로 매우 효율적입니다.

메모리가 완전히 순차적으로 배치되어 캐시 지역성도 최상입니다.

실전 팁

💡 Bump Allocator는 임시 데이터에 적합합니다. HTTP 요청 처리, JSON 파싱, 컴파일러 AST 등 작업이 끝나면 전체를 버려도 되는 데이터에 사용하세요.

💡 게임 개발에서는 "frame arena" 패턴을 사용하세요. 매 프레임 시작 시 리셋하고, 프레임 내 임시 계산에 사용하면 가비지 컬렉션 없이도 메모리 관리가 자동화됩니다.

💡 여러 개의 Bump Allocator를 계층적으로 사용할 수 있습니다. 예를 들어 전역 아레나에서 스레드별 아레나를 할당하고, 각 스레드가 자신의 아레나에서 세부 할당을 수행하면 락이 전혀 필요 없습니다.

💡 디버그 빌드에서는 할당된 영역을 특정 패턴(예: 0xCC)으로 채우세요. 초기화되지 않은 메모리 사용을 즉시 발견할 수 있습니다.

💡 Bump Allocator를 fallback으로 사용하지 마세요. 해제가 불가능하므로 장시간 실행되는 서비스에서는 메모리가 계속 증가합니다. 명확한 리셋 시점이 있는 경우에만 사용해야 합니다.


5. 메모리 정렬(Alignment) - 성능과 안정성의 숨은 열쇠

시작하며

여러분이 할당자를 구현하면서 이상한 크래시를 경험한 적 있나요? 코드는 완벽해 보이는데, 특정 타입을 할당할 때만 프로그램이 죽거나, x86에서는 잘 동작하는데 ARM에서는 실패하는 경우가 있습니다.

이런 문제의 원인은 대부분 메모리 정렬(alignment)을 제대로 처리하지 않았기 때문입니다. CPU는 데이터가 특정 주소 경계에 정렬되어 있을 것으로 기대하는데, 우리가 반환한 주소가 그 기대를 어기면 undefined behavior가 발생합니다.

메모리 정렬은 할당자 구현에서 가장 중요하면서도 자주 간과되는 요소입니다. 제대로 이해하고 구현하면 성능도 향상되고, 크로스 플랫폼 안정성도 확보할 수 있습니다.

개요

간단히 말해서, 메모리 정렬이란 데이터가 특정 주소 경계(예: 4바이트, 8바이트, 16바이트)에서 시작해야 한다는 제약 조건입니다. 예를 들어 u64는 8바이트 정렬이 필요하므로, 주소가 8의 배수여야 합니다.

왜 정렬이 필요한가? 첫째, 많은 CPU 아키텍처(특히 ARM, MIPS)는 잘못 정렬된 메모리 접근 시 예외를 발생시킵니다.

둘째, x86처럼 허용하는 아키텍처에서도 잘못 정렬된 접근은 여러 메모리 사이클을 소비하여 성능이 크게 저하됩니다. 셋째, SIMD 명령어(SSE, AVX)는 16바이트 또는 32바이트 정렬을 엄격히 요구합니다.

전통적인 malloc은 항상 최대 정렬(보통 16바이트)을 보장했다면, Rust의 Layout 시스템은 타입별로 정확한 정렬 요구사항을 명시합니다. 이것이 더 효율적이지만, 할당자 구현자가 직접 처리해야 합니다.

정렬의 핵심 특징은 첫째, 정렬 값은 항상 2의 거듭제곱이고, 둘째, 더 큰 정렬은 더 작은 정렬을 포함하며(16바이트 정렬 주소는 자동으로 8, 4, 2, 1바이트 정렬도 만족), 셋째, 구조체의 정렬은 가장 큰 필드의 정렬과 같습니다. 이러한 규칙들을 이해하면 메모리 레이아웃을 완벽하게 제어할 수 있습니다.

코드 예제

use core::alloc::Layout;

// 정렬된 주소로 올림
pub fn align_up(addr: usize, align: usize) -> usize {
    // align은 2의 거듭제곱이어야 함
    debug_assert!(align.is_power_of_two());
    // (addr + align - 1) & !(align - 1)와 동일하지만 더 명확함
    let align_mask = align - 1;
    if addr & align_mask == 0 {
        addr // 이미 정렬됨
    } else {
        (addr | align_mask) + 1
    }
}

// 정렬된 주소로 내림
pub fn align_down(addr: usize, align: usize) -> usize {
    debug_assert!(align.is_power_of_two());
    addr & !(align - 1)
}

// 주소가 정렬되어 있는지 검사
pub fn is_aligned(addr: usize, align: usize) -> bool {
    debug_assert!(align.is_power_of_two());
    addr & (align - 1) == 0
}

// Layout을 고려한 안전한 할당 예제
pub fn alloc_with_alignment(start: usize, end: usize, layout: Layout) -> Option<usize> {
    let alloc_start = align_up(start, layout.align());
    let alloc_end = alloc_start.checked_add(layout.size())?;

    if alloc_end > end {
        None // 공간 부족
    } else {
        // 반환 주소가 올바르게 정렬되었는지 검증
        debug_assert!(is_aligned(alloc_start, layout.align()));
        Some(alloc_start)
    }
}

// 예제: 다양한 타입의 정렬 요구사항
#[repr(C)]
struct Example {
    a: u8,      // 1바이트 정렬
    // 3바이트 패딩
    b: u32,     // 4바이트 정렬
    c: u16,     // 2바이트 정렬
    // 6바이트 패딩 (전체 구조체는 8바이트 정렬)
    d: u64,     // 8바이트 정렬
}

설명

이것이 하는 일: 메모리 정렬 함수들은 주어진 주소를 특정 경계로 올리거나 내리는 작업을 수행합니다. 할당자는 이 함수들을 사용하여, 사용자가 요청한 정렬 조건을 만족하는 주소를 찾아내고 반환합니다.

첫 번째로, align_up 함수는 비트 연산을 사용하여 주소를 정렬 경계로 올립니다. 핵심 아이디어는 align - 1이 마스크로 작동한다는 것입니다.

예를 들어 8바이트 정렬(align=8)의 경우 마스크는 0b111이 됩니다. addr & align_mask가 0이면 이미 정렬되어 있고, 아니라면 다음 경계로 올려야 합니다.

(addr | align_mask) + 1 연산은 현재 경계까지의 비트를 모두 켜고 1을 더해 다음 경계로 넘어갑니다. align_down은 반대 방향으로 작동합니다.

addr & !(align - 1) 연산은 하위 비트들을 제거하여 이전 정렬 경계를 찾습니다. 예를 들어 주소 13(0b1101)을 8바이트 경계로 내리면, 0b1101 & 0b1000 = 0b1000 = 8이 됩니다.

이것은 메모리 블록의 시작 주소를 찾을 때 유용합니다. is_aligned 함수는 단순히 하위 비트들이 모두 0인지 검사합니다.

이것은 할당 후 검증이나, 디버그 어설션에서 매우 중요합니다. 프로덕션 코드에서도 critical section 진입 전에 정렬을 확인하면, 하드웨어 예외를 소프트웨어 에러로 전환하여 더 나은 디버깅 정보를 제공할 수 있습니다.

alloc_with_alignment 함수는 실제 할당 시나리오를 보여줍니다. 먼저 시작 주소를 정렬하고, 크기를 더한 후, 범위를 검사합니다.

여기서 중요한 것은 checked_add를 사용한다는 점입니다. 오버플로우를 방지하여, 주소 계산이 잘못되어 낮은 주소로 랩어라운드하는 보안 취약점을 막습니다.

구조체 예제를 보면, Example은 총 24바이트를 차지합니다. u8(1) + 패딩(3) + u32(4) + u16(2) + 패딩(6) + u64(8) = 24.

컴파일러가 자동으로 패딩을 삽입하여 각 필드의 정렬을 보장합니다. 전체 구조체의 정렬은 가장 큰 필드(u64)의 정렬인 8바이트입니다.

여러분이 이런 정렬 함수들을 올바르게 사용하면, 플랫폼에 관계없이 안정적으로 동작하는 할당자를 만들 수 있습니다. x86 개발 머신에서는 문제없이 보이던 코드가 ARM 보드에서 크래시하는 악몽 같은 상황을 피할 수 있습니다.

실전 팁

💡 항상 Layout에서 제공하는 align() 값을 사용하세요. 직접 타입의 크기를 정렬로 가정하면 틀릴 수 있습니다(예: [u8; 3]의 정렬은 3이 아니라 1입니다).

💡 정렬 계산에는 비트 연산을 사용하세요. 나눗셈/곱셈보다 훨씬 빠르고, 2의 거듭제곱이 아닌 값에 대해서는 컴파일 타임에 에러를 낼 수 있습니다.

💡 #[repr(C)]를 사용하면 C와 호환되는 레이아웃을 얻지만, #[repr(Rust)]는 컴파일러가 최적화할 수 있습니다. OS 개발에서 외부 인터페이스는 repr(C), 내부 구조체는 repr(Rust)를 사용하세요.

💡 SIMD 코드를 위해서는 최소 16바이트(SSE) 또는 32바이트(AVX) 정렬을 보장하세요. align_of::<__m128>()로 정렬 요구사항을 확인할 수 있습니다.

💡 캐시 라인 정렬(보통 64바이트)을 고려하면 false sharing을 방지할 수 있습니다. 멀티스레드 환경에서 자주 접근되는 데이터는 별도의 캐시 라인에 배치하세요.


6. alloc_error_handler - OOM 상황을 우아하게 처리하기

시작하며

여러분이 할당자 구현을 완료하고 테스트하던 중, 메모리가 부족한 상황을 만들어봤나요? null 포인터를 반환했는데 컴파일러가 어떻게 반응하는지, Rust는 이 상황을 어떻게 처리하는지 궁금했을 겁니다.

Rust는 기본적으로 메모리 할당 실패 시 즉시 프로그램을 종료합니다. 하지만 운영체제를 개발하는 우리에게는 이것이 충분하지 않습니다.

로그를 출력하거나, 메모리 통계를 보여주거나, 복구를 시도하는 등의 커스텀 처리가 필요합니다. 바로 이럴 때 필요한 것이 alloc_error_handler입니다.

메모리 할당 실패라는 치명적인 상황에서 우리만의 로직을 실행할 수 있게 해주는 마지막 방어선입니다.

개요

간단히 말해서, alloc_error_handler는 메모리 할당이 실패했을 때(alloc이 null을 반환했을 때) Rust 런타임이 호출하는 특별한 함수입니다. #[alloc_error_handler] 속성으로 지정합니다.

왜 이것이 OS 개발에서 중요한가? 일반 애플리케이션은 할당 실패 시 panic하고 운영체제에게 정리를 맡기면 되지만, 우리가 만드는 것이 바로 그 운영체제입니다.

할당 실패를 감지하고, 가능하면 복구하거나, 최소한 유용한 디버깅 정보를 출력한 후 깨끗하게 종료해야 합니다. 예를 들어 커널 패닉 메시지에 "메모리 부족: 요청 크기 4096바이트, 사용 가능 메모리 512바이트"라고 출력하는 것과, 그냥 묵묵히 멈추는 것은 천지 차이입니다.

표준 라이브러리가 있는 환경에서는 OOM 처리가 자동이지만, no_std 환경에서는 우리가 직접 정의해야 합니다. 이것은 panic_handler와 비슷한 역할을 하지만, 메모리 할당에 특화되어 있습니다.

이 핸들러의 핵심 특징은 첫째, 절대 반환하지 않습니다(diverging function, ! 반환 타입), 둘째, Layout 정보를 받아 어떤 할당이 실패했는지 알 수 있고, 셋째, 전체 시스템에 하나만 존재할 수 있습니다.

이러한 특징들이 시스템 전체의 OOM 정책을 일관되게 적용할 수 있게 해줍니다.

코드 예제

use alloc::alloc::Layout;
use core::alloc::GlobalAlloc;

// OOM 에러 핸들러 정의
#[alloc_error_handler]
fn alloc_error_handler(layout: Layout) -> ! {
    panic!(
        "메모리 할당 실패\n\
         요청 크기: {} 바이트\n\
         정렬 요구사항: {} 바이트\n\
         사용 가능한 힙 메모리가 부족합니다.",
        layout.size(),
        layout.align()
    );
}

// 더 고급 핸들러 예제
#[cfg(feature = "advanced_oom")]
#[alloc_error_handler]
fn alloc_error_handler(layout: Layout) -> ! {
    use crate::memory::stats;
    use crate::vga_buffer::WRITER;

    // 화면에 에러 메시지 출력
    let mut writer = WRITER.lock();
    writeln!(
        writer,
        "\n!!! 치명적 에러: 메모리 부족 !!!\n\
         요청: {} 바이트 (정렬: {})\n\
         현재 힙 사용량: {} / {} 바이트\n\
         할당 횟수: {}\n\
         프리 블록 수: {}",
        layout.size(),
        layout.align(),
        stats::used_memory(),
        stats::total_memory(),
        stats::allocation_count(),
        stats::free_block_count()
    ).unwrap();

    // 스택 트레이스 출력 (가능하다면)
    #[cfg(feature = "backtrace")]
    {
        writeln!(writer, "\n스택 트레이스:").unwrap();
        crate::backtrace::print_backtrace(&mut writer);
    }

    // 시스템 정지
    loop {
        x86_64::instructions::hlt();
    }
}

설명

이것이 하는 일: alloc_error_handler는 GlobalAlloc::alloc이 null 포인터를 반환했을 때, Rust 컴파일러가 자동으로 삽입한 코드에 의해 호출됩니다. 이것은 프로그램의 마지막 순간에 실행되는 코드로, 개발자에게 무엇이 잘못되었는지 알려주는 역할을 합니다.

첫 번째로, 기본 구현은 Layout 파라미터를 받습니다. 이것은 실패한 할당 요청의 크기와 정렬 정보를 담고 있습니다.

단순히 panic!을 호출하여 이 정보를 출력하는 것만으로도, "알 수 없는 에러로 프로그램이 종료되었습니다"보다는 훨씬 나은 사용자 경험을 제공합니다. panic!

메시지는 panic_handler에게 전달되어 화면이나 시리얼 포트로 출력됩니다. 고급 구현에서는 더 많은 컨텍스트 정보를 수집합니다.

메모리 통계 모듈에서 현재 힙 사용량, 총 할당 횟수, 남은 프리 블록 수 같은 정보를 가져와 함께 출력합니다. 이런 정보는 메모리 누수를 추적하거나, 메모리 사용 패턴을 분석하는 데 매우 유용합니다.

예를 들어 "총 메모리는 충분한데 프리 블록이 많다"면 단편화 문제이고, "할당 횟수가 비정상적으로 많다"면 어딘가에서 메모리 누수가 있다는 신호입니다. 스택 트레이스를 출력하는 것은 더욱 강력합니다.

어떤 코드 경로에서 할당이 실패했는지 알 수 있으므로, "어디서 메모리를 많이 사용하는가?"라는 질문에 직접 답할 수 있습니다. Rust의 백트레이스는 DWARF 디버그 정보를 사용하므로, 개발 빌드에서는 함수 이름과 파일 위치까지 정확히 보여줍니다.

마지막 단계는 시스템을 안전한 상태로 만드는 것입니다. 단순히 panic하면 unwinding이 시작되지만, no_std 환경에서는 보통 unwinding을 지원하지 않으므로 즉시 abort됩니다.

대신 명시적으로 hlt 명령어를 무한 루프로 실행하여 CPU를 멈춥니다. 이것은 전력도 절약하고, 디버거가 연결되어 있다면 상태를 검사할 기회도 줍니다.

여러분이 제대로 된 alloc_error_handler를 구현하면, 디버깅 시간을 극적으로 줄일 수 있습니다. "왜 프로그램이 멈췄지?"라고 몇 시간을 헤매는 대신, "128바이트 할당이 process::fork에서 실패했다"는 명확한 정보를 얻게 됩니다.

실전 팁

💡 alloc_error_handler 안에서는 절대 추가 할당을 하지 마세요. 이미 메모리가 부족한 상황에서 Vec이나 String을 사용하면 무한 재귀에 빠집니다. 스택 기반 버퍼나 정적 변수만 사용하세요.

💡 시리얼 포트로 에러를 출력하세요. VGA 버퍼는 제한적이고, 재부팅하면 사라지지만, 시리얼 출력은 호스트 머신에 로그로 남아서 나중에 분석할 수 있습니다.

💡 가능하다면 메모리 덤프를 생성하세요. 힙의 처음 몇 KB를 16진수로 출력하면, 메모리 손상이나 잘못된 포인터를 육안으로 확인할 수 있습니다.

💡 테스트 환경에서는 OOM을 의도적으로 유발하세요. 매우 큰 할당을 요청하거나, 반복적으로 작은 할당을 수행하여 핸들러가 제대로 동작하는지 검증하세요.

💡 리커버리 전략을 고려하세요. 일부 시스템에서는 비필수 캐시를 해제하거나, 백그라운드 태스크를 종료하여 메모리를 확보한 후 재시도할 수 있습니다. 하지만 이것은 매우 복잡하므로, 초기에는 단순히 패닉하는 것이 안전합니다.


7. Heap Initialization - 부트스트랩의 핵심 단계

시작하며

여러분이 아무리 완벽한 할당자를 구현해도, 실제로 사용하려면 "어디에서 메모리를 가져올 것인가?"라는 질문에 답해야 합니다. 부트로더가 우리에게 넘겨준 메모리 맵에서 사용 가능한 영역을 찾고, 그것을 힙으로 초기화하는 과정이 필요합니다.

이 과정은 닭과 달걀 문제를 내포하고 있습니다. 힙을 초기화하려면 메모리 맵을 파싱해야 하는데, 메모리 맵을 파싱하려면 보통 동적 할당이 필요합니다.

이 순환 의존성을 깨는 것이 힙 초기화의 핵심 과제입니다. 제대로 된 힙 초기화 전략을 수립하면, 커널의 나머지 부분은 메모리 관리의 복잡성을 전혀 신경 쓰지 않고 자유롭게 Vec, Box, BTreeMap을 사용할 수 있게 됩니다.

개요

간단히 말해서, 힙 초기화는 부트로더가 제공한 메모리 맵에서 사용 가능한 물리 메모리를 찾고, 그것을 가상 메모리에 매핑한 후, 할당자에게 "이 영역을 힙으로 사용하세요"라고 알려주는 과정입니다. 왜 이것이 복잡한가?

첫째, 부트로더마다 메모리 맵 형식이 다릅니다(Multiboot, UEFI, Limine 등). 둘째, 물리 메모리를 직접 사용할 수 없고 페이지 테이블을 통해 가상 주소로 매핑해야 합니다.

셋째, 초기화 순서가 중요합니다 - 페이지 테이블이 먼저 설정되어야 하고, 그 다음에 힙을 초기화할 수 있습니다. 예를 들어 UEFI 환경에서는 수백 개의 메모리 영역이 있을 수 있고, 그 중에서 "EfiConventionalMemory" 타입만 사용할 수 있습니다.

전통적인 OS 개발에서는 고정 크기 힙을 사용했다면(예: 리눅스 초기 버전의 640KB), 현대적인 접근은 동적으로 사용 가능한 메모리를 찾고, 필요에 따라 힙을 확장합니다. Rust의 타입 시스템은 이런 복잡한 초기화를 안전하게 만들어줍니다.

힙 초기화의 핵심 특징은 첫째, 부트 정보에서 메모리 맵을 파싱하고, 둘째, 사용 가능한 영역을 가상 주소 공간에 매핑하며, 셋째, 할당자의 init 메서드를 호출하여 실제 사용 가능하게 만듭니다. 이 세 단계를 올바른 순서로 수행하면, 커널이 완전한 동적 메모리 기능을 갖추게 됩니다.

코드 예제

use bootloader::bootinfo::{MemoryMap, MemoryRegionType};
use x86_64::{
    structures::paging::{Mapper, Page, PageTableFlags, Size4KiB},
    VirtAddr,
};

// 힙의 가상 주소 범위 정의
pub const HEAP_START: usize = 0x_4444_4444_0000;
pub const HEAP_SIZE: usize = 1024 * 1024; // 1 MiB

// 힙 초기화 함수
pub fn init_heap(
    mapper: &mut impl Mapper<Size4KiB>,
    frame_allocator: &mut impl FrameAllocator<Size4KiB>,
) -> Result<(), MapToError<Size4KiB>> {
    // 힙 영역을 위한 페이지 범위 생성
    let page_range = {
        let heap_start = VirtAddr::new(HEAP_START as u64);
        let heap_end = heap_start + HEAP_SIZE - 1u64;
        let heap_start_page = Page::containing_address(heap_start);
        let heap_end_page = Page::containing_address(heap_end);
        Page::range_inclusive(heap_start_page, heap_end_page)
    };

    // 각 페이지를 물리 프레임에 매핑
    for page in page_range {
        let frame = frame_allocator
            .allocate_frame()
            .ok_or(MapToError::FrameAllocationFailed)?;

        // 읽기/쓰기 가능, 유저 접근 불가
        let flags = PageTableFlags::PRESENT
            | PageTableFlags::WRITABLE;

        unsafe {
            mapper.map_to(page, frame, flags, frame_allocator)?
                .flush();
        }
    }

    // 할당자 초기화
    unsafe {
        ALLOCATOR.lock().init(HEAP_START, HEAP_SIZE);
    }

    Ok(())
}

// 메모리 맵에서 사용 가능한 메모리 찾기
pub fn find_usable_memory(memory_map: &MemoryMap) -> impl Iterator<Item = PhysFrame> + '_ {
    memory_map
        .iter()
        .filter(|r| r.region_type == MemoryRegionType::Usable)
        .map(|r| r.range.start_addr()..r.range.end_addr())
        .flat_map(|r| r.step_by(4096))
        .map(|addr| PhysFrame::containing_address(PhysAddr::new(addr)))
}

설명

이것이 하는 일: 힙 초기화는 운영체제 부팅 과정에서 매우 이른 시점에 수행되는 중요한 작업입니다. 물리 메모리를 추상화하여 커널 코드가 자유롭게 힙 할당을 사용할 수 있게 만드는 다리 역할을 합니다.

첫 번째로, 힙의 가상 주소 공간을 결정합니다. HEAP_START는 커널 주소 공간의 특정 영역을 지정하는데, 보통 높은 주소(예: 0x4444_4444_0000)를 사용합니다.

이것은 유저 공간과 명확히 구분되고, 페이지 테이블에서 쉽게 식별할 수 있는 "매직 넘버"입니다. HEAP_SIZE는 초기 힙 크기로, 작게 시작해서 나중에 확장할 수 있습니다.

1MB는 초기 부팅과 드라이버 로딩에 충분한 크기입니다. 그 다음으로, 페이지 범위를 계산합니다.

VirtAddr에서 Page로 변환하는 과정에서 주소를 4KB 경계로 내림/올림하여, 전체 힙 영역을 커버하는 페이지들의 범위를 얻습니다. 예를 들어 1MB 힙은 256개의 4KB 페이지가 필요합니다.

Page::range_inclusive는 이 256개의 페이지를 반복할 수 있는 이터레이터를 반환합니다. 각 페이지에 대해, frame_allocator.allocate_frame()을 호출하여 물리 메모리 프레임을 할당받습니다.

이것은 실제 RAM의 4KB 청크를 우리에게 주는 것입니다. 그리고 mapper.map_to()를 호출하여 페이지 테이블에 "가상 페이지 X는 물리 프레임 Y에 매핑된다"는 엔트리를 추가합니다.

PageTableFlags는 이 매핑의 권한을 지정하는데, PRESENT(유효함)와 WRITABLE(쓰기 가능)은 필수이고, USER_ACCESSIBLE는 제외하여 커널 전용으로 만듭니다. 매핑이 완료되면 TLB(Translation Lookaside Buffer)를 플러시해야 합니다.

flush() 메서드는 해당 페이지에 대한 이전 TLB 엔트리를 무효화하여, CPU가 새로운 매핑을 사용하도록 강제합니다. 이것을 잊으면 이상한 페이지 폴트가 발생할 수 있습니다.

마지막으로, ALLOCATOR.lock().init()을 호출합니다. 이제 물리 메모리가 준비되었으므로, 할당자에게 "이 가상 주소 범위를 힙으로 사용하세요"라고 알려줍니다.

이 순간부터 Box::new()나 Vec::new() 같은 코드가 정상적으로 작동하기 시작합니다. 여러분이 이 초기화를 올바르게 수행하면, 커널 개발의 난이도가 크게 낮아집니다.

Rust의 고수준 컬렉션들을 자유롭게 사용할 수 있어서, C 스타일의 수동 메모리 관리에서 벗어날 수 있습니다. 파일 시스템, 네트워크 스택, 프로세스 스케줄러 같은 복잡한 서브시스템을 구현할 때, Vec<Process>나 BTreeMap<Pid, Process> 같은 자연스러운 자료구조를 사용할 수 있습니다.

실전 팁

💡 힙 크기를 동적으로 조정하는 메커니즘을 구현하세요. init 시에는 1MB로 시작하고, OOM이 발생하면 자동으로 확장하는 방식이 유연합니다. Linux의 brk/sbrk 시스템콜이 이 원리를 사용합니다.

💡 힙 영역을 커널 심볼 맵에 추가하세요. 디버거나 스택 트레이스에서 "0x4444_4444_1234 주소는 힙 내부"라고 명확히 표시되면 디버깅이 훨씬 쉽습니다.

💡 초기화 실패를 절대 무시하지 마세요. map_to()가 에러를 반환하면 즉시 패닉하세요. 힙 없이 실행되는 커널은 곧 크래시하므로, 빨리 실패하는 것이 낫습니다.

💡 메모리 맵 파싱에 static 배열을 사용하세요. bootloader 크레이트가 제공하는 이터레이터는 힙 할당이 필요 없으므로, 닭과 달걀 문제를 우아하게 해결합니다.

💡 여러 힙을 고려하세요. 커널 힙, 유저 힙, DMA 힙을 분리하면 보안과 안정성이 향상됩니다. 각각 다른 가상 주소 범위와 권한을 가질 수 있습니다.


8. 동시성 안전(Concurrency Safety) - 멀티코어 환경의 필수 요소

시작하며

여러분이 싱글 코어 환경에서 완벽하게 동작하는 할당자를 만들었는데, 멀티코어 시스템에서 테스트하자마자 무작위로 크래시하는 경험을 해본 적 있나요? 할당 중에 데이터가 손상되거나, 같은 블록이 두 번 할당되는 이상한 버그가 발생합니다.

이런 문제는 동시성(concurrency) 버그의 전형적인 증상입니다. 여러 CPU 코어가 동시에 할당자의 내부 자료구조를 수정하면서, 서로의 변경 사항을 덮어쓰거나 일관성이 깨진 상태를 보게 됩니다.

멀티코어 프로세서가 표준이 된 현대 시스템에서, 할당자의 동시성 안전성은 선택이 아닌 필수입니다. 올바른 동기화 전략을 수립하면, 성능 저하 없이 스레드 안전한 메모리 관리를 달성할 수 있습니다.

개요

간단히 말해서, 동시성 안전성이란 여러 스레드나 CPU 코어가 동시에 할당자를 사용해도 데이터 손상이나 race condition이 발생하지 않는 성질입니다. Rust에서는 Sync 트레잇으로 표현됩니다.

왜 할당자에 특별한 주의가 필요한가? 할당자는 시스템의 모든 곳에서 사용되는 공유 자원입니다.

어떤 스레드도 예고 없이 alloc을 호출할 수 있고, 여러 코어가 동시에 프리 리스트를 수정하려고 시도할 수 있습니다. 예를 들어 코어 0이 리스트의 head를 읽는 순간, 코어 1이 head를 변경하면, 코어 0은 이제 유효하지 않은 포인터를 가지고 작업하게 됩니다.

이것은 즉시 크래시나 메모리 손상으로 이어집니다. 전통적인 C malloc은 거친 잠금(coarse-grained lock)으로 전체 할당자를 보호했다면, 현대적인 접근은 세밀한 잠금(fine-grained lock)이나 lock-free 알고리즘을 사용합니다.

Rust의 타입 시스템은 이런 동기화를 컴파일 타임에 검증하여, 실수를 방지합니다. 동시성 안전 할당자의 핵심 특징은 첫째, Spin lock이나 Mutex로 critical section을 보호하고, 둘째, 원자적 연산(atomic operations)으로 카운터나 플래그를 업데이트하며, 셋째, per-CPU 자료구조로 락 경합을 줄입니다.

이러한 기법들이 정확성을 보장하면서도 확장성(scalability)을 유지하게 해줍니다.

코드 예제

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

// 스핀락으로 보호되는 할당자
pub struct LockedAllocator {
    inner: Mutex<LinkedListAllocator>,
}

impl LockedAllocator {
    pub const fn new() -> Self {
        LockedAllocator {
            inner: Mutex::new(LinkedListAllocator::new()),
        }
    }

    pub unsafe fn init(&self, heap_start: usize, heap_size: usize) {
        self.inner.lock().init(heap_start, heap_size);
    }
}

// GlobalAlloc 구현 - 자동으로 Sync
unsafe impl GlobalAlloc for LockedAllocator {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        // 뮤텍스 획득 - 다른 코어는 여기서 대기
        let mut allocator = self.inner.lock();
        allocator.alloc(layout)
        // 뮤텍스 자동 해제
    }

    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
        let mut allocator = self.inner.lock();
        allocator.dealloc(ptr, layout)
    }
}

// 전역 할당자 등록
#[global_allocator]
static ALLOCATOR: LockedAllocator = LockedAllocator::new();

// 더 고급: Per-CPU 할당자로 락 경합 제거
use core::sync::atomic::{AtomicUsize, Ordering};

pub struct PerCpuAllocator {
    allocators: [Mutex<FixedSizeBlockAllocator>; MAX_CPUS],
    cpu_count: AtomicUsize,
}

impl PerCpuAllocator {
    fn get_allocator(&self) -> &Mutex<FixedSizeBlockAllocator> {
        let cpu_id = current_cpu_id();
        &self.allocators[cpu_id % MAX_CPUS]
    }
}

unsafe impl GlobalAlloc for PerCpuAllocator {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        // 현재 CPU의 할당자만 락
        let mut allocator = self.get_allocator().lock();
        allocator.alloc(layout)
    }

    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
        // 어떤 CPU의 블록인지 확인 (더 복잡함)
        let mut allocator = self.get_allocator().lock();
        allocator.dealloc(ptr, layout)
    }
}

설명

이것이 하는 일: 동시성 안전한 할당자는 내부 상태를 동기화 프리미티브로 보호하여, 여러 스레드의 동시 접근이 서로 간섭하지 않도록 만듭니다. Rust의 소유권 시스템과 결합하여, 컴파일 타임에 스레드 안전성을 보장합니다.

첫 번째로, LockedAllocator는 가장 단순한 접근입니다. Mutex<LinkedListAllocator>로 전체 할당자를 감싸서, 한 번에 하나의 스레드만 할당/해제를 수행하도록 강제합니다.

self.inner.lock()은 뮤텍스를 획득하려고 시도하는데, 이미 다른 스레드가 가지고 있다면 현재 스레드는 스핀(busy-wait)합니다. spin 크레이트의 Mutex는 운영체제 서비스 없이 동작하므로, 커널 개발에 이상적입니다.

Mutex가 해제되는 시점이 중요합니다. allocator 변수는 MutexGuard 타입인데, 이것이 스코프를 벗어나면 자동으로 락을 해제합니다.

이것은 RAII(Resource Acquisition Is Initialization) 패턴으로, 실수로 락을 해제하지 않는 버그를 방지합니다. 심지어 패닉이 발생해도 unwinding 중에 락이 해제되어 데드락을 막습니다.

GlobalAlloc 트레잇을 구현하면 Rust 컴파일러가 자동으로 Sync를 요구합니다. 이것은 "이 타입을 여러 스레드에서 공유해도 안전한가?"를 컴파일 타임에 검증하는 것입니다.

Mutex<T>는 T: Send이면 자동으로 Sync를 구현하므로, 우리의 LockedAllocator도 자동으로 스레드 안전해집니다. 더 고급 기법인 Per-CPU Allocator는 확장성을 극대화합니다.

각 CPU 코어가 자신만의 할당자를 가지므로, 코어 0과 코어 1이 동시에 할당을 수행해도 서로 다른 뮤텍스를 사용합니다. 락 경합(lock contention)이 거의 없어져서, 코어 수가 증가해도 성능이 선형적으로 확장됩니다.

단점은 해제 시 원래 할당한 CPU를 추적해야 하거나, 모든 CPU의 할당자를 검색해야 한다는 복잡성입니다. current_cpu_id()는 APIC ID나 CPU 레지스터를 읽어서 현재 실행 중인 코어를 식별합니다.

이것은 매우 빠른 연산이지만(몇 사이클), 정확한 구현이 필요합니다. 잘못된 CPU ID를 반환하면 다른 코어의 할당자에 접근하여 여전히 race condition이 발생할 수 있습니다.

여러분이 이런 동시성 안전 기법을 적용하면, 멀티코어 시스템에서도 안정적으로 동작하는 할당자를 얻습니다. 4코어, 8코어, 심지어 64코어 서버에서도 문제없이 확장되며, 하이젠버그 같은 간헐적 버그에서 해방됩니다.

실전 팁

💡 Spin lock은 짧은 critical section에만 사용하세요. 할당 작업이 오래 걸린다면(예: 페이지 폴트 처리), 스핀하는 CPU 사이클이 낭비됩니다. 이 경우 sleep-waiting Mutex를 고려하세요.

💡 락 순서(lock ordering)를 명확히 정의하세요. 여러 뮤텍스를 사용한다면, 항상 같은 순서로 획득해야 데드락을 피할 수 있습니다. 예: "항상 allocator 락 → statistics 락" 순서.

💡 Atomic 연산을 적극 활용하세요. 단순 카운터나 플래그는 Mutex 없이 AtomicUsize로 충분합니다. Ordering::Relaxed, Acquire, Release의 의미를 정확히 이해하세요.

💡 락 없는(lock-free) 알고리즘을 연구하세요. CAS(Compare-And-Swap)를 사용한 스택이나 큐는 뮤텍스보다 훨씬 빠르지만, 구현이 매우 어렵고 미묘한 버그가 숨어있을 수 있습니다.

💡 경합을 측정하세요. 각 뮤텍스에 대해 "획득 시도 횟수"와 "스핀한 횟수"를 추적하면, 병목 지점을 정확히 파악하여 최적화할 수 있습니다.


9. 메모리 단편화(Fragmentation) - 장기 실행의 숙적

시작하며

여러분이 완벽한 할당자를 만들어서 며칠간 실행했는데, 시간이 지날수록 점점 느려지고 할당 실패가 증가하는 현상을 경험한 적 있나요? 메모리 사용량을 체크해보니 50%밖에 사용하지 않았는데도, 큰 블록 할당이 실패합니다.

이것이 바로 메모리 단편화(fragmentation)의 전형적인 증상입니다. 시간이 지나면서 메모리가 작은 조각들로 나뉘어져, 총 여유 공간은 충분하지만 연속된 큰 블록이 없어지는 현상입니다.

메모리 단편화는 장시간 실행되는 서버나 임베디드 시스템에서 치명적입니다. 단편화를 이해하고 완화하는 전략을 수립하면, 시스템의 장기 안정성을 크게 향상시킬 수 있습니다.

개요

간단히 말해서, 메모리 단편화는 두 가지 형태로 나타납니다: 외부 단편화(external fragmentation)는 빈 블록들이 여기저기 흩어져 있어 큰 할당을 처리할 수 없는 상태이고, 내부 단편화(internal fragmentation)는 할당된 블록 내부에 사용되지 않는 공간이 낭비되는 상태입니다. 왜 이것이 할당자 설계의 핵심 과제인가?

외부 단편화는 가변 크기 할당의 근본적인 문제입니다. 100바이트 할당 후 50바이트 할당, 다시 100바이트 해제를 반복하면, 메모리가 50바이트 조각들로 쪼개집니다.

결국 200바이트 요청은 실패하는데, 총 여유 공간은 충분합니다. 실제 웹 서버나 데이터베이스에서 이런 패턴이 흔히 발생하여, 재시작 없이는 정상 동작이 불가능해집니다.

전통적인 접근에서는 주기적인 defragmentation(압축)을 수행했다면, 현대적인 설계는 단편화를 예방하는 데 집중합니다. Buddy System, Slab Allocator, Segregated Free Lists 같은 알고리즘은 모두 단편화를 줄이기 위해 고안되었습니다.

단편화 문제의 핵심 특징은 첫째, 시간이 지날수록 악화되는 누적 현상이고, 둘째, 워크로드 패턴에 크게 의존하며, 셋째, 완전한 해결책은 없고 완화만 가능합니다. 이러한 특성을 이해하면, 자신의 시스템에 맞는 최적의 전략을 선택할 수 있습니다.

코드 예제

// 외부 단편화 예제
pub fn demonstrate_external_fragmentation() {
    let mut allocator = LinkedListAllocator::new();
    unsafe { allocator.init(0x1000, 1024) }; // 1KB 힙

    // 시나리오: 100바이트 블록 5개 할당
    let mut blocks = vec![];
    for _ in 0..5 {
        let layout = Layout::from_size_align(100, 8).unwrap();
        blocks.push(allocator.alloc(layout));
    }
    // 메모리: [100][100][100][100][100][remaining]

    // 1, 3, 5번째 블록 해제
    unsafe {
        allocator.dealloc(blocks[0], Layout::from_size_align(100, 8).unwrap());
        allocator.dealloc(blocks[2], Layout::from_size_align(100, 8).unwrap());
        allocator.dealloc(blocks[4], Layout::from_size_align(100, 8).unwrap());
    }
    // 메모리: [free][100][free][100][free][remaining]
    // 총 여유: 300바이트지만 가장 큰 연속 블록은 100바이트!

    // 200바이트 할당 시도 - 실패!
    let big_layout = Layout::from_size_align(200, 8).unwrap();
    let result = allocator.alloc(big_layout);
    assert_eq!(result, null_mut()); // 할당 실패
}

// 병합(Coalescing)으로 단편화 완화
pub fn coalesce_free_blocks(
    list: &mut ListNode,
    ptr: *mut u8,
    size: usize,
) {
    let addr = ptr as usize;
    let mut current = list;

    // 삽입 위치 찾기 (주소 순서 유지)
    while let Some(ref mut next) = current.next {
        let next_addr = next as *const _ as usize;

        if next_addr > addr {
            // 여기에 삽입
            let new_node = ListNode {
                size,
                next: current.next.take(),
            };

            // 인접 블록과 병합 시도
            if addr + size == next_addr {
                // 다음 블록과 병합
                new_node.size += next.size;
                new_node.next = next.next.take();
            }

            if current.end_addr() == addr {
                // 이전 블록과 병합
                current.size += new_node.size;
                current.next = new_node.next;
            } else {
                unsafe {
                    let new_node_ptr = ptr as *mut ListNode;
                    new_node_ptr.write(new_node);
                    current.next = Some(&mut *new_node_ptr);
                }
            }
            return;
        }

        current = next;
    }
}

// 단편화 측정
pub struct FragmentationStats {
    pub total_free: usize,
    pub largest_block: usize,
    pub block_count: usize,
}

impl FragmentationStats {
    pub fn fragmentation_ratio(&self) -> f64 {
        if self.total_free == 0 {
            return 0.0;
        }
        1.0 - (self.largest_block as f64 / self.total_free as f64)
    }
}

설명

이것이 하는 일: 단편화 예제는 왜 순진한 할당 전략이 장기적으로 실패하는지 보여주고, 병합 알고리즘은 인접한 빈 블록들을 하나로 합쳐 큰 블록을 만들어냅니다. 첫 번째로, demonstrate_external_fragmentation 함수는 실제로 단편화가 발생하는 시나리오를 시뮬레이션합니다.

100바이트 블록 5개를 할당한 후, 홀수 번째 블록만 해제합니다. 이제 메모리는 "빈-사용-빈-사용-빈" 패턴이 되어, 각 빈 블록은 100바이트씩 총 300바이트가 비어있지만, 200바이트 할당은 실패합니다.

이것이 외부 단편화의 본질입니다 - 공간은 충분하지만 위치가 문제입니다. coalesce_free_blocks 함수는 이 문제를 완화합니다.

블록을 해제할 때 단순히 프리 리스트에 추가하는 것이 아니라, 주소 순서를 유지하면서 삽입하고, 인접한 블록들을 검사합니다. addr + size == next_addr이면 현재 블록의 끝이 다음 블록의 시작과 정확히 맞닿아있으므로, 두 블록을 하나로 병합할 수 있습니다.

크기를 더하고 next 포인터를 조정하면, 두 개의 작은 블록이 하나의 큰 블록으로 바뀝니다. 양방향 병합도 수행합니다.

이전 블록과도 인접하다면(current.end_addr() == addr), 새로 해제된 블록을 이전 블록에 흡수시킵니다. 이런 적극적인 병합 덕분에, "빈-빈-빈" 패턴이 발생하면 즉시 하나의 큰 빈 블록으로 변환되어, 큰 할당 요청도 처리할 수 있게 됩니다.

FragmentationStats는 단편화를 정량화합니다. fragmentation_ratio는 0(단편화 없음)에서 1(극심한 단편화) 사이의 값입니다.

예를 들어 총 1000바이트가 비었는데 가장 큰 블록이 100바이트라면, ratio는 0.9로 매우 심각한 상태입니다. 반대로 가장 큰 블록이 900바이트라면 ratio는 0.1로 건강한 상태입니다.

이 메트릭을 주기적으로 로깅하면, 단편화 추세를 모니터링할 수 있습니다. 실제 운영에서는 이 통계를 보고 대응 전략을 수립합니다.

Ratio가 0.7을 넘으면 경고를 발생시키고, 0.9를 넘으면 시스템 재시작을 예약할 수 있습니다. 또는 특정 임계값에서 압축(compaction)을 트리거하여, 사용 중인 블록들을 이동시켜 빈 공간을 하나로 모을 수도 있습니다(단, 매우 복잡하고 위험합니다).

여러분이 이런 단편화 인식을 갖추면, 할당자 선택과 설계에서 올바른 결정을 내릴 수 있습니다. 단기 실행 프로그램에서는 Bump Allocator로 충분하지만, 24/7 서버에서는 반드시 병합을 지원하는 할당자가 필요합니다.

워크로드 분석을 통해 단편화가 심할 것으로 예상되면, Fixed-Size Block Allocator로 근본적으로 문제를 제거하는 것도 현명한 선택입니다.

실전 팁

💡 Best-Fit 전략은 단편화를 악화시킬 수 있습니다. 작은 빈 공간들을 남기는 경향이 있어서, 오히려 First-Fit이나 Next-Fit이 장기적으로 더 나을 수 있습니다. 워크로드별로 실험하세요.

💡 크기별로 다른 힙을 사용하세요. 작은 할당(< 256바이트)과 큰 할당(> 4KB)을 분리하면, 서로 단편화에 영향을 주지 않습니다. jemalloc이 이 전략을 사용합니다.

💡 정기적인 "메모리 압박(memory pressure)" 이벤트를 발생시키세요. 주기적으로 캐시를 비우거나 임시 버퍼를 해제하여, 큰 연속 블록을 만들 기회를 제공합니다.

💡 압축(compaction)은 최후의 수단입니다. 객체를 이동시키려면 모든 포인터를 업데이트해야 하는데, 이것은 가비지 컬렉션이 있는 언어에서만 안전합니다. Rust에서는 거의 불가능합니다.

💡 단편화 패턴을 프로파일링하세요. 로그에서 "할당 크기의 분포"와 "수명 패턴"을 분석하면, 어떤 크기의 블록들이 문제인지 정확히 파악하여 맞춤형 솔루션을 만들 수 있습니다.


10. 힙 오버플로우와 언더플로우 방어 - 메모리 안전성의 최전선

시작하며

여러분이 할당자를 열심히 만들었지만, 사용자 코드가 할당받은 경계를 넘어 쓰거나(overflow), 이미 해제한 메모리를 다시 사용하는(use-after-free) 버그를 막을 방법이 필요한 적 있나요? 이런 메모리 안전성 버그는 보안 취약점의 주요 원인입니다.

할당자는 단순히 메모리를 나눠주는 것 이상의 역할을 할 수 있습니다. 적절한 방어 기법을 추가하면, 버그를 조기에 탐지하고, 공격자의 익스플로잇을 차단하며, 디버깅 정보를 제공할 수 있습니다.

메모리 안전성은 Rust의 핵심 가치이지만, unsafe 코드와 하드웨어 상호작용이 많은 OS 개발에서는 추가적인 런타임 검증이 필수입니다. 할당자 레벨의 방어는 시스템 전체의 보안을 강화하는 효과적인 방법입니다.

개요

간단히 말해서, 힙 오버플로우 방어는 할당된 블록의 앞뒤에 "가드 영역"이나 "매직 넘버"를 배치하여, 경계를 넘는 쓰기를 탐지하는 기법입니다. 언더플로우 방어는 이미 해제된 메모리를 특수 패턴으로 채워, 사용하려는 시도를 감지합니다.

왜 이것이 중요한가? 역사적으로 HeartBleed, WannaCry 같은 대형 보안 사고들이 모두 힙 오버플로우에서 시작되었습니다.

공격자는 경계를 넘어 쓰기를 통해 인접한 객체를 조작하고, 결국 임의 코드 실행까지 도달합니다. 할당자가 이런 시도를 즉시 탐지하면, 작은 버그가 치명적인 취약점으로 발전하는 것을 막을 수 있습니다.

전통적인 디버그 malloc(예: glibc의 MALLOC_CHECK_)은 성능 오버헤드가 커서 프로덕션에서 사용하기 어려웠다면, 현대적인 기법들(Guard Pages, ASLR, Stack Canary 같은)은 저렴한 비용으로 높은 보안을 제공합니다. 방어 기법의 핵심 특징은 첫째, 디버그 빌드에서는 공격적인 검증을 수행하고, 둘째, 릴리스 빌드에서는 성능 영향이 적은 기법만 유지하며, 셋째, 탐지 즉시 시스템을 정지시켜 추가 피해를 방지합니다.

이러한 다층 방어가 버그 발견과 보안을 동시에 달성하게 합니다.

코드 예제

use core::ptr;

// 가드 바이트를 사용한 오버플로우 탐지
const GUARD_PATTERN: u8 = 0xFD; // 할당 가드
const FREE_PATTERN: u8 = 0xDD;  // 해제 마커
const GUARD_SIZE: usize = 16;   // 가드 영역 크기

pub struct GuardedAllocator {
    inner: Mutex<LinkedListAllocator>,
}

impl GuardedAllocator {
    unsafe fn alloc_with_guards(&self, layout: Layout) -> *mut u8 {
        // 실제 필요한 크기: 앞 가드 + 데이터 + 뒤 가드
        let total_size = GUARD_SIZE + layout.size() + GUARD_SIZE;
        let extended_layout = Layout::from_size_align_unchecked(
            total_size,
            layout.align(),
        );

        let mut allocator = self.inner.lock();
        let ptr = allocator.alloc(extended_layout);

        if ptr.is_null() {
            return ptr;
        }

        // 앞 가드 영역 초기화
        ptr::write_bytes(ptr, GUARD_PATTERN, GUARD_SIZE);

        // 뒤 가드 영역 초기화
        let rear_guard = ptr.add(GUARD_SIZE + layout.size());
        ptr::write_bytes(rear_guard, GUARD_PATTERN, GUARD_SIZE);

        // 데이터 영역은 0xCC로 초기화 (초기화 안된 읽기 탐지)
        let data_ptr = ptr.add(GUARD_SIZE);
        ptr::write_bytes(data_ptr, 0xCC, layout.size());

        data_ptr // 가드를 건너뛴 포인터 반환
    }

    unsafe fn dealloc_with_guards(&self, ptr: *mut u8, layout: Layout) {
        // 가드 검증
        let front_guard = ptr.sub(GUARD_SIZE);
        let rear_guard = ptr.add(layout.size());

        // 앞 가드 확인
        for i in 0..GUARD_SIZE {
            let byte = *front_guard.add(i);
            if byte != GUARD_PATTERN {
                panic!(
                    "힙 언더플로우 탐지!\n\
                     주소: {:p}\n\
                     위치: 앞 가드 +{} 바이트\n\
                     예상: 0x{:02X}, 실제: 0x{:02X}",
                    ptr, i, GUARD_PATTERN, byte
                );
            }
        }

        // 뒤 가드 확인
        for i in 0..GUARD_SIZE {
            let byte = *rear_guard.add(i);
            if byte != GUARD_PATTERN {
                panic!(
                    "힙 오버플로우 탐지!\n\
                     주소: {:p}\n\
                     크기: {} 바이트\n\
                     위치: 뒤 가드 +{} 바이트\n\
                     예상: 0x{:02X}, 실제: 0x{:02X}",
                    ptr, layout.size(), i, GUARD_PATTERN, byte
                );
            }
        }

        // 해제된 메모리를 FREE_PATTERN으로 채움
        ptr::write_bytes(front_guard, FREE_PATTERN,
            GUARD_SIZE + layout.size() + GUARD_SIZE);

        let mut allocator = self.inner.lock();
        allocator.dealloc(front_guard,
            Layout::from_size_align_unchecked(
                GUARD_SIZE + layout.size() + GUARD_SIZE,
                layout.align()
            ));
    }
}

// 더블 프리 탐지
pub struct F

#Rust#HeapAllocator#MemoryManagement#OperatingSystem#SystemProgramming#시스템프로그래밍

댓글 (0)

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