이미지 로딩 중...
AI Generated
2025. 11. 14. · 3 Views
Rust로 만드는 나만의 OS 부트로더 선택 가이드
Rust 운영체제 개발에서 bootloader crate를 사용하는 방법을 알아봅니다. 부트로더의 역할과 설정 방법, 그리고 실제 OS 개발에 필요한 핵심 개념들을 다룹니다.
목차
- bootloader_crate_소개 - 왜 직접 만들지 않나요?
- 커널_엔트리_포인트_작성 - OS의 시작점 만들기
- 타겟_스펙_설정 - 베어메탈을 위한 컴파일
- 부트_이미지_빌드 - 실행 가능한 OS 만들기
- VGA_텍스트_모드 - 첫_화면_출력
- 전역_Writer_와_매크로 - println_구현하기
- 메모리_매핑과_부트_정보 - 커널이_받는_선물
- 페이지_테이블_접근 - 가상_메모리_이해하기
- 프레임_할당자 - 물리_메모리_관리
- 힙_할당 - Box와_Vec_사용하기
1. bootloader_crate_소개 - 왜 직접 만들지 않나요?
시작하며
여러분이 Rust로 운영체제를 만들려고 할 때 가장 먼저 마주하는 벽이 무엇인지 아시나요? 바로 부트로더입니다.
컴퓨터가 켜지면 BIOS나 UEFI가 디스크에서 부트로더를 찾아 실행하고, 이 부트로더가 여러분의 OS 커널을 메모리에 올려주는 역할을 합니다. 문제는 부트로더를 직접 작성하는 것이 엄청나게 복잡하다는 점입니다.
16비트 리얼 모드에서 시작해서 32비트 보호 모드를 거쳐 64비트 롱 모드로 전환해야 하고, 페이징 테이블을 설정하고, 커널을 메모리에 로드하는 등 수많은 저수준 작업이 필요합니다. 바로 이럴 때 필요한 것이 bootloader crate입니다.
이 크레이트는 이 모든 복잡한 과정을 자동으로 처리해주어, 여러분이 실제 OS 커널 개발에만 집중할 수 있게 해줍니다.
개요
간단히 말해서, bootloader crate는 Rust로 작성된 OS 커널을 부팅하기 위한 표준 부트로더 라이브러리입니다. 이 크레이트가 필요한 이유는 명확합니다.
부트로더는 OS 개발에서 반드시 필요하지만, 그 자체로는 여러분이 만들고 싶은 OS의 핵심 기능이 아니기 때문입니다. 예를 들어, 프로세스 스케줄링이나 파일 시스템 같은 흥미로운 기능을 개발하고 싶은데, 부트로더 개발에 몇 주를 쓰는 것은 비효율적입니다.
기존에는 GRUB 같은 C 기반 부트로더를 사용했다면, 이제는 Rust 생태계 내에서 타입 안전하고 간편하게 부트로더를 설정할 수 있습니다. 이 크레이트의 핵심 특징은 첫째, 완전히 Rust로 작성되어 있어 안전하고, 둘째, ELF 형식의 커널을 자동으로 로드하며, 셋째, 페이징과 64비트 롱 모드 설정을 자동으로 처리한다는 점입니다.
이러한 특징들이 OS 개발의 진입 장벽을 크게 낮춰줍니다.
코드 예제
# Cargo.toml에 bootloader 의존성 추가
[dependencies]
bootloader = "0.9.23"
[profile.dev]
panic = "abort"
[profile.release]
panic = "abort"
# 부트 이미지 생성을 위한 build-std 설정
[unstable]
build-std = ["core", "compiler_builtins"]
build-std-features = ["compiler-builtins-mem"]
설명
이것이 하는 일: Cargo.toml에 bootloader 크레이트를 의존성으로 추가하고, 부트 이미지 생성에 필요한 설정을 구성합니다. 첫 번째로, dependencies 섹션에서 bootloader 버전을 지정합니다.
0.9 버전대는 안정적이고 널리 사용되는 버전으로, 대부분의 Rust OS 튜토리얼에서 권장합니다. 이 크레이트가 여러분의 프로젝트에 포함되면, 빌드 시스템이 자동으로 부트로더를 컴파일하고 커널과 결합합니다.
그 다음으로, panic 설정을 "abort"로 지정합니다. OS 커널은 표준 라이브러리 없이 실행되는 freestanding 환경이므로, 패닉이 발생했을 때 스택을 풀어내는(unwinding) 기능을 사용할 수 없습니다.
대신 즉시 중단하도록 설정하여 더 간단하고 예측 가능한 동작을 보장합니다. 마지막으로, build-std 옵션을 통해 표준 라이브러리의 core와 compiler_builtins를 타겟에 맞춰 재컴파일하도록 지정합니다.
이는 여러분의 커널이 실행될 특정 하드웨어 아키텍처(예: x86_64)에 최적화된 코드를 생성하기 위해 필요합니다. 여러분이 이 설정을 완료하면 cargo build 명령으로 부트 가능한 디스크 이미지를 자동으로 생성할 수 있습니다.
별도로 부트로더를 컴파일하거나 링킹 스크립트를 작성할 필요가 없으며, 모든 과정이 Cargo의 빌드 시스템 내에서 통합되어 처리됩니다.
실전 팁
💡 bootloader 버전을 선택할 때는 0.9.x 버전이 가장 안정적입니다. 0.10+ 버전은 API가 많이 변경되었으니 튜토리얼을 따를 때 버전을 확인하세요.
💡 panic = "abort" 설정을 빼먹으면 링킹 에러가 발생할 수 있습니다. freestanding 환경에서는 반드시 필요한 설정입니다.
💡 build-std를 사용하려면 rustup component add rust-src 명령으로 Rust 소스코드를 먼저 설치해야 합니다.
💡 nightly Rust 컴파일러가 필요합니다. rustup override set nightly 명령으로 프로젝트별로 nightly를 설정하세요.
💡 .cargo/config.toml 파일을 만들어 타겟 설정을 영구적으로 저장하면 매번 명령줄에 옵션을 입력할 필요가 없습니다.
2. 커널_엔트리_포인트_작성 - OS의 시작점 만들기
시작하며
여러분의 OS 커널이 메모리에 로드되었을 때, 부트로더는 어디서부터 코드 실행을 시작해야 할까요? 일반 프로그램이라면 main 함수가 시작점이지만, OS 커널은 다릅니다.
일반 프로그램의 main 함수는 실제로는 운영체제가 제공하는 런타임이 호출해주는 것입니다. 하지만 우리가 지금 만드는 것이 바로 그 운영체제이기 때문에, 그런 런타임이 존재하지 않습니다.
따라서 완전히 새로운 엔트리 포인트를 정의해야 합니다. 바로 이럴 때 필요한 것이 #![no_std]와 #![no_main] 속성, 그리고 커스텀 엔트리 포인트입니다.
이들을 통해 표준 라이브러리와 런타임 없이 실행되는 독립적인 커널을 만들 수 있습니다.
개요
간단히 말해서, 커널 엔트리 포인트는 부트로더가 제어권을 넘겨주는 커널의 첫 번째 함수입니다. 이 개념이 필요한 이유는 OS 커널이 일반 애플리케이션과 완전히 다른 환경에서 실행되기 때문입니다.
표준 라이브러리가 없고, 운영체제의 서비스를 받을 수 없으며, 심지어 힙 메모리 할당도 직접 구현해야 합니다. 예를 들어, println!
매크로를 사용하려면 VGA 텍스트 버퍼나 시리얼 포트를 직접 제어해야 하는 상황입니다. 기존 일반 Rust 프로그램에서는 fn main()으로 시작했다면, 이제는 #[no_mangle]과 extern "C"를 사용한 특별한 함수를 엔트리 포인트로 지정해야 합니다.
이 방식의 핵심 특징은 첫째, 이름 맹글링을 비활성화하여 부트로더가 정확한 함수 이름으로 호출할 수 있게 하고, 둘째, C 호출 규약을 사용하여 어셈블리 코드와의 호환성을 보장하며, 셋째, 절대 반환하지 않는(diverging) 함수로 정의하여 OS가 종료되지 않음을 표현한다는 점입니다. 이러한 특징들이 저수준 시스템 프로그래밍의 요구사항을 정확히 반영합니다.
코드 예제
#![no_std] // 표준 라이브러리를 사용하지 않음
#![no_main] // 일반적인 main 진입점을 사용하지 않음
use core::panic::PanicInfo;
// 패닉 핸들러 정의 (필수)
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {} // 패닉 시 무한 루프
}
// 커널 엔트리 포인트
#[no_mangle] // 이름 맹글링 방지
pub extern "C" fn _start() -> ! {
// VGA 텍스트 버퍼의 시작 주소
let vga_buffer = 0xb8000 as *mut u8;
// "OK"를 화면에 출력
unsafe {
*vga_buffer.offset(0) = b'O';
*vga_buffer.offset(1) = 0x0f; // 흰색 글자
*vga_buffer.offset(2) = b'K';
*vga_buffer.offset(3) = 0x0f;
}
loop {} // 무한 루프로 CPU를 대기 상태로
}
설명
이것이 하는 일: 표준 라이브러리 없이 실행되는 freestanding 바이너리를 만들고, VGA 텍스트 버퍼에 직접 접근하여 화면에 텍스트를 출력합니다. 첫 번째로, #![no_std]와 #![no_main] 속성으로 표준 라이브러리와 기본 진입점을 비활성화합니다.
이렇게 하면 Rust 컴파일러가 운영체제의 존재를 가정하지 않고 컴파일합니다. 그리고 #[panic_handler]를 반드시 정의해야 하는데, 이는 패닉 발생 시 어떻게 처리할지를 지정하는 것입니다.
OS 커널이므로 표준 패닉 메시지를 출력할 방법이 없어 단순히 무한 루프로 처리합니다. 그 다음으로, _start 함수가 실행됩니다.
#[no_mangle] 속성은 Rust 컴파일러가 함수 이름을 변경하지 못하게 막아, 링커가 정확히 "_start"라는 이름으로 이 함수를 찾을 수 있게 합니다. extern "C"는 C 언어의 호출 규약을 따르도록 지정하는데, 이는 어셈블리로 작성된 부트로더 코드와 호환되기 위함입니다.
함수 내부에서는 VGA 텍스트 버퍼의 물리 주소인 0xb8000에 직접 접근합니다. 이 메모리 영역은 BIOS가 텍스트 모드로 설정한 화면 버퍼로, 2바이트씩 쌍을 이루어 문자와 색상을 표현합니다.
"OK"를 출력하기 위해 각 문자의 ASCII 코드와 색상 속성(0x0f = 검은 배경에 흰색 글자)을 순차적으로 씁니다. 마지막으로, loop {} 무한 루프로 함수를 종료합니다.
! 타입은 이 함수가 절대 반환하지 않음을 의미하는데, 이는 OS 커널의 본질입니다.
커널은 시작되면 계속 실행되어야 하며, 종료되어서는 안 됩니다. 여러분이 이 코드를 빌드하고 실행하면 화면 왼쪽 상단에 "OK"라는 흰색 글자가 나타납니다.
이것이 여러분의 OS가 성공적으로 부팅되었다는 첫 번째 신호입니다. 이후 인터럽트 처리, 메모리 관리, 멀티태스킹 등의 기능을 이 기반 위에 추가할 수 있습니다.
실전 팁
💡 _start 함수 이름은 관례상 사용되지만, 링커 스크립트에서 다른 이름으로 변경할 수도 있습니다. 중요한 것은 #[no_mangle]로 보호하는 것입니다.
💡 VGA 버퍼 접근은 unsafe 블록이 필요합니다. 하지만 실제 프로젝트에서는 이를 안전한 추상화로 감싸서 사용하는 것이 좋습니다.
💡 loop {} 대신 hlt 명령어를 사용하면 CPU를 저전력 모드로 전환할 수 있어 전력 소비를 줄입니다. x86_64 crate의 instructions::hlt()를 사용하세요.
💡 panic_handler에서 패닉 정보를 화면에 출력하면 디버깅이 훨씬 쉬워집니다. VGA 버퍼 출력 함수를 만들어 활용하세요.
💡 QEMU로 테스트할 때 -serial mon:stdio 옵션을 사용하면 시리얼 포트 출력을 터미널에서 볼 수 있어 디버깅에 유용합니다.
3. 타겟_스펙_설정 - 베어메탈을 위한 컴파일
시작하며
여러분이 Rust 코드를 컴파일할 때 평소에는 신경 쓰지 않는 것이 있습니다. 바로 "타겟"입니다.
일반적으로는 현재 작업 중인 OS와 CPU에 맞춰 자동으로 설정되죠. 문제는 OS 커널을 컴파일할 때입니다.
우리는 운영체제 "위에서" 실행되는 프로그램이 아니라, 운영체제 "자체"를 만들고 있습니다. 따라서 리눅스나 윈도우 같은 호스트 OS의 기능을 전혀 사용할 수 없는 베어메탈 환경을 타겟으로 해야 합니다.
바로 이럴 때 필요한 것이 커스텀 타겟 스펙입니다. JSON 파일로 정의하는 이 스펙은 컴파일러에게 "이 코드는 아무런 OS 없이 하드웨어에서 직접 실행된다"고 알려줍니다.
개요
간단히 말해서, 타겟 스펙은 컴파일러가 생성할 코드의 실행 환경을 정의하는 설정 파일입니다. 이것이 필요한 이유는 기본 타겟들(x86_64-unknown-linux-gnu 등)이 모두 운영체제의 존재를 가정하기 때문입니다.
표준 라이브러리의 스레드, 파일 시스템, 네트워킹 기능을 사용하고, C 런타임의 초기화 코드를 기대하며, 특정 메모리 레이아웃을 요구합니다. 예를 들어, 동적 링킹을 가정하거나 스택 언와인딩(unwinding) 기능을 포함하는 식입니다.
기존에는 미리 정의된 타겟 중에서 선택했다면, 이제는 우리 OS의 요구사항에 정확히 맞는 커스텀 타겟을 직접 만들 수 있습니다. 커스텀 타겟의 핵심 특징은 첫째, 베어메탈 실행 환경을 정확히 명시할 수 있고, 둘째, 불필요한 기능(Red Zone, SIMD 등)을 비활성화할 수 있으며, 셋째, 특정 CPU 기능을 활성화하거나 비활성화할 수 있다는 점입니다.
이러한 제어를 통해 예측 가능하고 안전한 OS 커널을 만들 수 있습니다.
코드 예제
{
"llvm-target": "x86_64-unknown-none",
"data-layout": "e-m:e-i64:64-f80:128-n8:16:32:64-S128",
"arch": "x86_64",
"target-endian": "little",
"target-pointer-width": "64",
"target-c-int-width": "32",
"os": "none",
"executables": true,
"linker-flavor": "ld.lld",
"linker": "rust-lld",
"panic-strategy": "abort",
"disable-redzone": true,
"features": "-mmx,-sse,+soft-float"
}
설명
이것이 하는 일: x86_64 아키텍처에서 운영체제 없이 실행되는 커널 바이너리를 생성하기 위한 컴파일러 설정을 지정합니다. 첫 번째 핵심 설정은 "os": "none"입니다.
이는 운영체제가 없는 환경을 의미하며, 컴파일러가 OS 관련 기능을 전혀 포함하지 않도록 합니다. "llvm-target"과 "arch"는 x86_64 프로세서를 타겟으로 지정하지만, unknown-none으로 OS가 없음을 명시합니다.
이렇게 하면 libc나 pthread 같은 시스템 라이브러리에 대한 링킹이 발생하지 않습니다. 그 다음으로 중요한 설정이 "disable-redzone": true입니다.
Red Zone은 x86_64 ABI에서 함수가 스택 포인터 아래 128바이트를 스크래치 공간으로 사용할 수 있게 하는 최적화입니다. 하지만 인터럽트 핸들러에서 문제를 일으킬 수 있습니다.
인터럽트가 발생하면 현재 스택을 사용하는데, Red Zone의 데이터를 덮어쓸 수 있기 때문입니다. 따라서 OS 커널에서는 반드시 비활성화해야 합니다.
"features": "-mmx,-sse,+soft-float" 설정은 SIMD 명령어를 비활성화합니다. MMX와 SSE는 성능 향상에 도움이 되지만, 컨텍스트 스위칭 시 추가적인 레지스터 상태를 저장해야 합니다.
커널 초기화 단계에서는 이런 복잡성을 피하기 위해 소프트웨어 부동소수점 연산을 사용합니다. 나중에 FPU 상태 관리를 구현한 후에 활성화할 수 있습니다.
"panic-strategy": "abort"는 패닉 시 스택 언와인딩 대신 즉시 중단하도록 지정합니다. 언와인딩은 복잡한 런타임 지원이 필요하며, 커널 환경에서는 불필요하고 위험할 수 있습니다.
linker 설정으로 rust-lld를 지정하여 Rust에 내장된 LLD 링커를 사용합니다. 이는 크로스 컴파일 환경에서 일관된 결과를 보장합니다.
여러분이 이 타겟 스펙을 x86_64-my_os.json으로 저장하고 cargo build --target x86_64-my_os.json으로 빌드하면, 완전히 독립적인 커널 바이너리가 생성됩니다. 이 바이너리는 어떤 OS 기능도 의존하지 않으며, 부트로더가 메모리에 로드하여 직접 실행할 수 있습니다.
실전 팁
💡 타겟 스펙 파일명은 관례적으로 {arch}-{vendor}-{os}.json 형식을 따릅니다. 예: x86_64-blog_os-none.json
💡 "data-layout" 문자열은 LLVM 문서에서 정확한 의미를 확인할 수 있습니다. 잘못된 값은 미묘한 버그를 일으킬 수 있으니 검증된 값을 사용하세요.
💡 .cargo/config.toml에 [build] target = "x86_64-my_os.json"을 추가하면 매번 --target 옵션을 입력하지 않아도 됩니다.
💡 실제 부팅 전에 cargo objdump를 사용해 생성된 바이너리의 섹션과 심볼을 확인하면 문제를 미리 발견할 수 있습니다.
💡 나중에 유저 공간 프로그램을 지원하려면 SSE를 다시 활성화해야 합니다. 그 전에 FXSAVE/FXRSTOR로 FPU 상태를 저장/복원하는 코드를 작성하세요.
4. 부트_이미지_빌드 - 실행 가능한 OS 만들기
시작하며
여러분이 커널 바이너리를 성공적으로 컴파일했다고 해서 끝이 아닙니다. 이 바이너리를 실제로 부팅할 수 있는 디스크 이미지로 만들어야 합니다.
문제는 부트로더와 커널을 결합하는 과정이 복잡하다는 점입니다. 부트로더를 컴파일하고, 커널을 특정 주소에 배치하고, 부트 섹터에 시그니처를 추가하고, 파일 시스템 이미지를 만드는 등 수많은 단계가 필요합니다.
직접 하려면 grub-mkrescue 같은 도구를 사용하고 복잡한 설정 파일을 작성해야 합니다. 바로 이럴 때 필요한 것이 bootimage 도구입니다.
단 한 줄의 명령으로 부트 가능한 디스크 이미지를 생성할 수 있게 해줍니다.
개요
간단히 말해서, bootimage는 Rust 커널과 bootloader crate를 결합하여 부팅 가능한 디스크 이미지를 자동으로 생성하는 도구입니다. 이 도구가 필요한 이유는 빌드 과정의 자동화입니다.
커널 개발 중에는 코드를 수정하고 테스트하는 사이클을 수백 번 반복하게 됩니다. 매번 부트로더를 다시 컴파일하고 이미지를 만드는 과정을 수동으로 한다면 엄청난 시간 낭비입니다.
예를 들어, VGA 출력 함수를 수정할 때마다 몇 분씩 이미지 생성에 쓰는 것은 비효율적입니다. 기존에는 Makefile이나 쉘 스크립트로 빌드 과정을 자동화했다면, 이제는 Cargo의 빌드 시스템과 통합된 bootimage 도구로 간단히 처리할 수 있습니다.
이 도구의 핵심 특징은 첫째, 의존성 추적이 자동화되어 커널이나 부트로더가 변경되면 필요한 부분만 재빌드하고, 둘째, QEMU 실행까지 통합되어 있어 빌드와 테스트를 한 번에 수행할 수 있으며, 셋째, bootloader crate의 설정을 Cargo.toml에서 직접 제어할 수 있다는 점입니다. 이러한 통합이 개발 생산성을 크게 향상시킵니다.
코드 예제
# bootimage 설치
cargo install bootimage
# 부트 이미지 빌드
cargo bootimage
# 빌드 결과는 다음 위치에 생성됨:
# target/x86_64-my_os/debug/bootimage-my_os.bin
# QEMU로 바로 실행
cargo run
# .cargo/config.toml에 runner 설정 추가
[target.'cfg(target_os = "none")']
runner = "bootimage runner"
설명
이것이 하는 일: bootimage 도구를 설치하고, 이를 사용하여 커널 바이너리와 부트로더를 결합한 부팅 가능한 이미지를 생성합니다. 첫 번째로, cargo install bootimage로 도구를 설치합니다.
이 도구는 Cargo의 서브커맨드로 작동하며, 빌드 과정을 확장합니다. 설치 후에는 cargo bootimage 명령을 사용할 수 있게 됩니다.
내부적으로 이 도구는 먼저 커널을 빌드하고, 그 다음 bootloader crate를 컴파일하며, 마지막으로 두 바이너리를 결합하여 부팅 가능한 이미지를 만듭니다. 그 다음으로, 생성된 이미지 파일은 target 디렉토리의 타겟별 하위 디렉토리에 저장됩니다.
파일명은 bootimage-{프로젝트명}.bin 형식이며, 이 파일을 USB 드라이브에 직접 쓰거나(dd 명령 사용) 가상 머신에서 부팅할 수 있습니다. 이미지 크기는 보통 수 MB로, 부트로더와 커널 코드만 포함되어 있습니다.
cargo run 명령으로 빌드와 실행을 한 번에 할 수 있습니다. 이를 위해서는 .cargo/config.toml에 runner를 설정해야 합니다.
"bootimage runner"는 이미지를 빌드한 후 QEMU로 자동 실행하는 래퍼입니다. 여기에 QEMU 옵션을 추가할 수도 있습니다.
예를 들어, -serial mon:stdio를 추가하면 시리얼 출력을 터미널에서 볼 수 있습니다. bootloader crate의 버전이나 설정을 변경하면 bootimage가 자동으로 재빌드합니다.
Cargo.toml의 [package.metadata.bootimage] 섹션에서 부트로더의 동작을 커스터마이징할 수 있습니다. 예를 들어, 물리 메모리 매핑 방식이나 프레임 버퍼 설정을 조정할 수 있습니다.
여러분이 코드를 수정하고 cargo run을 실행하면, 변경된 부분만 재컴파일되고 몇 초 안에 QEMU 창이 뜨면서 여러분의 OS가 실행됩니다. 이 빠른 피드백 루프가 OS 개발을 훨씬 즐겁고 생산적으로 만들어줍니다.
실전 팁
💡 bootimage는 llvm-tools-preview 컴포넌트를 필요로 합니다. rustup component add llvm-tools-preview로 설치하세요.
💡 실제 하드웨어에서 테스트하려면 dd if=bootimage.bin of=/dev/sdX bs=4M으로 USB에 직접 쓸 수 있습니다. 단, sdX는 올바른 디바이스인지 꼭 확인하세요!
💡 QEMU 옵션을 자주 바꾼다면 .cargo/config.toml에 [env] 섹션을 추가하고 QEMU_ARGS 환경변수로 관리하면 편리합니다.
💡 CI/CD 파이프라인에서는 cargo bootimage --release로 최적화된 빌드를 사용하세요. 디버그 빌드보다 훨씬 빠르게 실행됩니다.
💡 이미지 크기가 너무 크면 cargo bloat으로 어떤 코드가 공간을 차지하는지 분석할 수 있습니다.
5. VGA_텍스트_모드 - 첫_화면_출력
시작하며
여러분의 OS가 부팅되었을 때 가장 먼저 하고 싶은 일이 무엇인가요? 아마도 "Hello, World!"를 화면에 출력하는 것일 겁니다.
하지만 println! 매크로는 사용할 수 없습니다.
일반 프로그램에서 println!은 운영체제의 시스템 콜을 사용하여 표준 출력에 씁니다. 하지만 우리가 만드는 것이 바로 그 운영체제이기 때문에, 하드웨어에 직접 접근해야 합니다.
다행히 VGA 텍스트 모드는 비교적 간단한 인터페이스를 제공합니다. 바로 이럴 때 필요한 것이 VGA 텍스트 버퍼입니다.
0xb8000 주소에 매핑된 이 버퍼에 쓰면 즉시 화면에 나타나는 메모리 매핑 I/O 방식입니다.
개요
간단히 말해서, VGA 텍스트 모드는 25행 80열의 문자를 화면에 표시할 수 있는 간단한 텍스트 출력 인터페이스입니다. 이 방식이 필요한 이유는 그래픽 모드보다 훨씬 간단하기 때문입니다.
픽셀 단위로 그리려면 프레임버퍼 설정, 해상도 협상, 픽셀 포맷 변환 등 복잡한 과정이 필요합니다. 하지만 텍스트 모드는 BIOS가 이미 설정해 놓았기 때문에, 메모리에 쓰기만 하면 됩니다.
예를 들어, 부트 로그나 커널 패닉 메시지를 출력하는 데 완벽합니다. 기존에는 BIOS 인터럽트(int 0x10)를 호출해서 문자를 출력했다면, 이제는 보호 모드와 64비트 환경에서 메모리 매핑 I/O를 직접 사용합니다.
VGA 텍스트 버퍼의 핵심 특징은 첫째, 각 문자가 2바이트(ASCII 코드 + 색상 속성)로 표현되고, 둘째, 2000바이트의 연속된 메모리 영역(25×80×2)이며, 셋째, 쓰기 즉시 화면에 반영되는 실시간 출력이라는 점입니다. 이러한 단순함이 초기 커널 개발을 쉽게 만들어줍니다.
코드 예제
// VGA 텍스트 버퍼 구조체 정의
#[repr(C)]
struct VgaChar {
ascii_char: u8, // ASCII 문자 코드
color_code: u8, // 전경색(4비트) + 배경색(4비트)
}
const VGA_BUFFER_ADDR: usize = 0xb8000;
const BUFFER_HEIGHT: usize = 25;
const BUFFER_WIDTH: usize = 80;
// VGA 버퍼에 문자열 출력
pub fn print_string(s: &str) {
let vga_buffer = VGA_BUFFER_ADDR as *mut VgaChar;
for (i, byte) in s.bytes().enumerate() {
unsafe {
let char_pos = vga_buffer.offset(i as isize);
*char_pos = VgaChar {
ascii_char: byte,
color_code: 0x0f, // 흰색 글자, 검은 배경
};
}
}
}
설명
이것이 하는 일: VGA 텍스트 모드의 메모리 매핑 버퍼에 직접 접근하여 문자열을 화면에 출력합니다. 첫 번째로, VgaChar 구조체를 정의합니다.
#[repr(C)] 속성은 Rust 컴파일러가 필드 순서를 바꾸거나 패딩을 추가하지 못하게 합니다. 이는 하드웨어 인터페이스와 정확히 일치해야 하기 때문입니다.
ascii_char는 실제 출력할 문자이고, color_code는 상위 4비트가 배경색, 하위 4비트가 전경색을 나타냅니다. 0x0f는 검은 배경(0)에 밝은 흰색(15) 글자를 의미합니다.
그 다음으로, 상수들을 정의합니다. VGA_BUFFER_ADDR은 BIOS가 표준으로 지정한 텍스트 버퍼의 물리 주소입니다.
이 주소는 x86 아키텍처에서 거의 항상 동일합니다. BUFFER_HEIGHT와 WIDTH는 표준 텍스트 모드의 크기로, 총 2000개의 문자 셀을 제공합니다.
print_string 함수에서는 먼저 버퍼 주소를 VgaChar 포인터로 캐스팅합니다. 그런 다음 문자열의 각 바이트를 순회하면서 해당 위치의 VGA 셀에 씁니다.
offset 메서드로 포인터 산술을 수행하여 올바른 위치를 계산합니다. 모든 메모리 접근은 unsafe 블록 안에 있어야 하는데, 이는 컴파일러가 이 주소의 안전성을 보장할 수 없기 때문입니다.
이 코드의 한계는 스크롤링이나 커서 위치 관리가 없다는 점입니다. 문자열이 80자를 넘으면 다음 줄로 자동으로 넘어가지 않고 버퍼 범위를 벗어날 수 있습니다.
실제 사용하려면 현재 위치를 추적하고, 줄바꿈 문자를 처리하며, 화면이 가득 차면 위로 스크롤하는 로직이 필요합니다. 여러분이 이 함수를 _start에서 호출하면 화면에 텍스트가 나타납니다.
이것이 여러분 OS의 첫 번째 출력이며, 이후 더 정교한 콘솔 드라이버를 만드는 기반이 됩니다. 색상 코드를 바꿔가며 실험해보면 다양한 색상의 텍스트를 표현할 수 있습니다.
실전 팁
💡 색상 코드는 4비트 CGA 팔레트를 사용합니다. 0=검정, 1=파랑, 2=초록, ... 15=흰색. 비트 3은 밝기를 조절합니다.
💡 volatile 읽기/쓰기를 사용하세요. 컴파일러 최적화가 메모리 매핑 I/O 접근을 제거할 수 있습니다. volatile crate를 사용하면 편리합니다.
💡 스크롤링을 구현할 때는 ptr::copy를 사용해 메모리 블록을 한 줄씩 위로 이동시키는 것이 효율적입니다.
💡 커서 위치는 VGA 컨트롤러의 포트(0x3D4, 0x3D5)를 통해 제어할 수 있습니다. x86_64 crate의 Port 타입을 활용하세요.
💡 Writer 구조체를 만들고 fmt::Write 트레이트를 구현하면 write! 매크로를 사용할 수 있어 형식화된 출력이 가능합니다.
6. 전역_Writer_와_매크로 - println_구현하기
시작하며
여러분이 VGA 버퍼에 출력하는 함수를 만들었지만, 매번 함수를 호출하고 위치를 수동으로 관리하는 것은 불편합니다. 일반 Rust 프로그램처럼 println!("값: {}", x) 같은 편리한 매크로를 사용하고 싶지 않나요?
문제는 전역 상태 관리입니다. 현재 커서 위치, 색상 설정 등을 여러 곳에서 공유해야 하는데, Rust는 안전성을 위해 전역 가변 상태를 제한합니다.
또한 멀티코어 환경에서는 동시 접근 문제도 고려해야 합니다. 바로 이럴 때 필요한 것이 lazy_static과 스핀락입니다.
이들을 조합하면 안전하게 공유되는 전역 Writer를 만들고, 그 위에 println! 매크로를 구현할 수 있습니다.
개요
간단히 말해서, 전역 Writer는 lazy_static으로 초기화 지연되고 스핀락으로 보호되는 VGA 버퍼 래퍼입니다. 이것이 필요한 이유는 커널 전역에서 일관된 콘솔 출력을 위해서입니다.
인터럽트 핸들러, 드라이버, 시스템 콜 핸들러 등 여러 곳에서 로그를 출력해야 하는데, 각각 별도의 Writer를 만들면 커서 위치가 엉망이 됩니다. 예를 들어, 타이머 인터럽트에서 출력하는 동안 다른 코드가 같은 위치에 쓰면 글자가 섞입니다.
기존에는 C에서 전역 변수를 무분별하게 사용했다면, 이제는 Rust의 타입 시스템을 활용하여 안전한 전역 상태를 만듭니다. 이 패턴의 핵심 특징은 첫째, lazy_static이 컴파일 타임에 초기화할 수 없는 복잡한 전역 변수를 런타임에 한 번만 초기화하고, 둘째, Mutex(스핀락)가 동시 접근을 직렬화하여 데이터 레이스를 방지하며, 셋째, fmt::Write 트레이트 구현으로 표준 포맷팅 기능을 활용할 수 있다는 점입니다.
이러한 구조가 안전하고 편리한 커널 로깅을 가능하게 합니다.
코드 예제
use lazy_static::lazy_static;
use spin::Mutex;
use core::fmt;
// VGA Writer 구조체
pub struct Writer {
column_position: usize,
color_code: u8,
buffer: &'static mut [VgaChar; BUFFER_SIZE],
}
// 전역 Writer 인스턴스
lazy_static! {
pub static ref WRITER: Mutex<Writer> = Mutex::new(Writer {
column_position: 0,
color_code: 0x0f,
buffer: unsafe { &mut *(VGA_BUFFER_ADDR as *mut _) },
});
}
// fmt::Write 트레이트 구현
impl fmt::Write for Writer {
fn write_str(&mut self, s: &str) -> fmt::Result {
for byte in s.bytes() {
self.write_byte(byte);
}
Ok(())
}
}
// println! 매크로 정의
#[macro_export]
macro_rules! println {
() => ($crate::print!("\n"));
($($arg:tt)*) => ($crate::print!("{}\n", format_args!($($arg)*)));
}
설명
이것이 하는 일: 커널 어디서나 사용할 수 있는 안전한 전역 출력 인터페이스를 만들고, 표준 라이브러리와 유사한 println! 매크로를 제공합니다.
첫 번째로, Writer 구조체는 VGA 버퍼의 상태를 캡슐화합니다. column_position은 다음 문자를 쓸 위치를 추적하고, color_code는 현재 색상 설정을 저장하며, buffer는 실제 VGA 메모리를 가리킵니다.
'static 라이프타임은 이 참조가 프로그램 전체 수명 동안 유효함을 보장합니다. 이는 커널이 종료되지 않기 때문에 안전합니다.
그 다음으로, lazy_static! 매크로가 WRITER를 정의합니다.
이 매크로는 첫 번째 접근 시에만 초기화 코드를 실행하는 전역 변수를 만듭니다. Mutex::new로 Writer를 감싸는데, 여기서 사용하는 Mutex는 표준 라이브러리가 아닌 spin crate의 것입니다.
스핀락은 OS의 스케줄러 없이도 작동하는 간단한 락으로, 락을 얻을 때까지 CPU를 반복해서 체크합니다. 커널 초기에는 스레드 개념이 없으므로 이 방식이 적합합니다.
fmt::Write 트레이트를 구현하면 Rust의 포맷팅 인프라를 활용할 수 있습니다. write_str 메서드만 구현하면 되고, write!
매크로가 자동으로 작동합니다. 내부적으로 각 바이트를 write_byte로 처리하는데, 이 메서드는 줄바꿈 문자를 감지하고 필요시 스크롤을 수행하는 로직을 포함해야 합니다.
println! 매크로는 표준 라이브러리의 것을 모방합니다.
format_args!로 인자를 포맷팅하고, WRITER를 락한 후 write! 매크로로 출력합니다.
#[macro_export] 속성으로 다른 모듈에서도 사용할 수 있게 합니다. 매크로 내부에서 $crate::를 사용하여 현재 크레이트를 참조하므로, 외부에서 사용할 때도 올바른 경로를 찾습니다.
여러분이 이제 println!("부팅 완료. CPU: {}", cpu_name);처럼 코드를 작성하면, 마치 일반 Rust 프로그램처럼 작동합니다.
락 획득, 포맷팅, 버퍼 쓰기, 락 해제가 모두 자동으로 처리되며, 안전성도 보장됩니다. 이것이 Rust의 제로 코스트 추상화의 좋은 예입니다.
실전 팁
💡 스핀락은 짧은 크리티컬 섹션에만 사용하세요. 긴 작업에서는 CPU를 낭비합니다. 나중에 슬립 가능한 뮤텍스로 교체하세요.
💡 인터럽트 핸들러에서 println!을 사용할 때는 데드락 주의하세요. 인터럽트가 락을 획득한 코드를 중단시키면 영원히 대기할 수 있습니다.
💡 print!와 println!을 별도로 정의하세요. print!는 줄바꿈 없이 출력하므로 프로그레스 바 같은 UI에 유용합니다.
💡 디버그 빌드에서는 _print 함수에 #[inline(never)]를 추가하면 스택 트레이스가 더 읽기 쉬워집니다.
💡 나중에 시리얼 포트 출력도 추가하려면 Writer를 트레이트로 추상화하고 여러 구현을 만드는 것이 좋습니다.
7. 메모리_매핑과_부트_정보 - 커널이_받는_선물
시작하며
여러분의 커널이 실행을 시작할 때, 메모리가 어떻게 구성되어 있는지 알아야 합니다. 어디에 커널 코드가 로드되어 있고, 어디가 사용 가능한 RAM이며, 어디가 하드웨어 예약 영역인지 파악해야 메모리 관리자를 만들 수 있습니다.
문제는 이 정보를 얻는 것이 쉽지 않다는 점입니다. BIOS는 E820 메모리 맵을 제공하지만, 이를 쿼리하려면 16비트 리얼 모드에서 인터럽트를 호출해야 합니다.
우리 커널은 이미 64비트 롱 모드에서 실행되고 있어 직접 접근할 수 없습니다. 바로 이럴 때 필요한 것이 부트로더가 제공하는 부트 정보입니다.
bootloader crate는 메모리 맵, 프레임버퍼 정보, RSDP 주소 등 유용한 데이터를 구조체로 전달해줍니다.
개요
간단히 말해서, 부트 정보는 부트로더가 커널에게 전달하는 하드웨어 및 메모리 구성 데이터입니다. 이것이 필요한 이유는 동적 메모리 할당과 페이지 테이블 관리를 위해서입니다.
메모리 맵 없이는 어느 물리 메모리를 안전하게 사용할 수 있는지 알 수 없습니다. 예를 들어, 비디오 카드의 MMIO 영역에 데이터를 쓰려고 하면 시스템이 크래시됩니다.
또한 ACPI 테이블을 찾으려면 RSDP 주소가 필요합니다. 기존에는 GRUB의 Multiboot 정보 구조체를 파싱했다면, 이제는 Rust의 타입 안전한 구조체로 깔끔하게 접근할 수 있습니다.
부트 정보의 핵심 특징은 첫째, 모든 물리 메모리 영역의 타입(사용 가능, 예약됨, ACPI 등)을 제공하고, 둘째, 커널이 로드된 가상 주소와 물리 주소를 알려주며, 셋째, 페이징이 이미 활성화되어 있고 재귀 매핑이나 오프셋 매핑이 설정되어 있다는 점입니다. 이러한 정보가 메모리 관리자 구현의 기초가 됩니다.
코드 예제
use bootloader::BootInfo;
use bootloader::bootinfo::{MemoryMap, MemoryRegionType};
// 커널 엔트리 포인트가 부트 정보를 받음
#[no_mangle]
pub extern "C" fn _start(boot_info: &'static BootInfo) -> ! {
println!("부팅 정보:");
// 메모리 맵 출력
let memory_map = &boot_info.memory_map;
println!("메모리 영역 {}개:", memory_map.len());
for region in memory_map.iter() {
println!(
" {:016x}-{:016x} ({:?})",
region.range.start_addr(),
region.range.end_addr(),
region.region_type
);
}
// 사용 가능한 메모리 총량 계산
let usable_memory: u64 = memory_map.iter()
.filter(|r| r.region_type == MemoryRegionType::Usable)
.map(|r| r.range.end_addr() - r.range.start_addr())
.sum();
println!("사용 가능한 메모리: {} MB", usable_memory / 1024 / 1024);
loop {}
}
설명
이것이 하는 일: 부트로더가 수집한 시스템 정보를 받아서 메모리 레이아웃을 분석하고, 사용 가능한 물리 메모리를 계산합니다. 첫 번째로, _start 함수의 시그니처가 변경되었습니다.
이제 BootInfo 참조를 인자로 받습니다. bootloader crate는 특정 레지스터(RDI)를 통해 이 구조체의 주소를 전달합니다.
'static 라이프타임은 이 데이터가 부트로더가 설정한 메모리 영역에 영구적으로 존재함을 의미합니다. 커널은 이 구조체를 복사하거나 참조를 저장하여 나중에 사용할 수 있습니다.
그 다음으로, memory_map 필드를 순회합니다. 이는 MemoryRegion의 슬라이스로, 각 영역은 시작/끝 주소와 타입을 포함합니다.
MemoryRegionType은 Usable(사용 가능), Reserved(예약됨), AcpiReclaimable(ACPI 데이터), AcpiNvs(ACPI 비휘발성 저장소) 등의 값을 가질 수 있습니다. Usable 영역만 커널의 힙이나 페이지 프레임 할당자에서 사용할 수 있습니다.
메모리 맵을 출력하면 일반적으로 이런 패턴을 볼 수 있습니다: 낮은 주소(0x0-0x100000)는 BIOS와 부트로더가 사용하고, 그 위에 커널 코드가 로드되며, 그 위로 큰 Usable 영역이 있고, 상위 주소에 하드웨어 MMIO 영역이 있습니다. 이 정보를 바탕으로 물리 메모리 프레임 할당자를 초기화할 수 있습니다.
filter와 map, sum을 연결한 함수형 스타일 코드로 사용 가능한 메모리를 계산합니다. 이터레이터 체인은 컴파일 시간에 최적화되어 효율적인 코드를 생성합니다.
일반적으로 최신 컴퓨터에서는 수 GB의 usable 메모리가 보고됩니다. 여러분이 이 정보를 바탕으로 프레임 할당자를 만들면, 동적 메모리 할당이 가능해집니다.
또한 boot_info.physical_memory_offset을 사용하면 모든 물리 메모리에 가상 주소로 접근할 수 있습니다. 이는 페이지 테이블 조작에 필수적입니다.
실전 팁
💡 BootInfo를 전역 변수에 저장하려면 Option<&'static BootInfo>를 사용하고, _start에서 초기화하세요.
💡 메모리 맵은 정렬되어 있지 않을 수 있습니다. 프레임 할당자를 만들 때 영역을 정렬하고 병합하면 효율성이 높아집니다.
💡 AcpiReclaimable 영역은 ACPI 테이블을 읽은 후 재사용할 수 있습니다. 메모리를 아끼고 싶다면 활용하세요.
💡 QEMU에서는 -m 옵션으로 메모리 크기를 조정할 수 있습니다. 여러 시나리오를 테스트해보세요.
💡 framebuffer 필드를 사용하면 VGA 텍스트 모드 대신 그래픽 모드로 출력할 수 있습니다. 픽셀 단위 제어가 가능합니다.
8. 페이지_테이블_접근 - 가상_메모리_이해하기
시작하며
여러분의 커널은 이미 가상 메모리 환경에서 실행되고 있습니다. 부트로더가 페이징을 활성화했기 때문에, 코드에서 사용하는 모든 주소는 가상 주소이며 페이지 테이블을 통해 물리 주소로 변환됩니다.
문제는 이 페이지 테이블에 어떻게 접근하느냐입니다. 페이지 테이블 자체도 물리 메모리에 있는데, 가상 주소만 사용할 수 있는 상황에서 어떻게 읽고 수정할까요?
재귀 매핑이나 물리 메모리 오프셋 매핑 같은 트릭이 필요합니다. 바로 이럴 때 필요한 것이 x86_64 crate의 페이지 테이블 추상화입니다.
이 크레이트는 4단계 페이징의 복잡성을 숨기고 안전한 API를 제공합니다.
개요
간단히 말해서, 페이지 테이블 접근은 가상 주소를 물리 주소로 변환하는 하드웨어 구조를 소프트웨어에서 조작하는 것입니다. 이것이 필요한 이유는 메모리 격리와 관리를 위해서입니다.
각 프로세스에게 독립적인 주소 공간을 제공하려면 페이지 테이블을 동적으로 생성하고 수정해야 합니다. 예를 들어, 새 프로세스를 생성할 때마다 새로운 페이지 테이블을 만들고, 프로그램을 로드할 때 가상 주소를 물리 프레임에 매핑해야 합니다.
기존에는 어셈블리로 CR3 레지스터를 읽고 물리 주소를 직접 계산했다면, 이제는 타입 안전한 Rust API로 추상화할 수 있습니다. 페이지 테이블의 핵심 특징은 첫째, x86_64에서는 4단계 계층 구조(PML4, PDPT, PD, PT)를 사용하고, 둘째, 각 테이블은 512개의 엔트리를 가지며, 셋째, 각 엔트리는 물리 주소와 플래그(Present, Writable, User 등)를 포함한다는 점입니다.
이러한 구조가 효율적이면서도 유연한 메모리 관리를 가능하게 합니다.
코드 예제
use x86_64::structures::paging::{PageTable, OffsetPageTable};
use x86_64::{VirtAddr, PhysAddr};
// 활성 페이지 테이블에 대한 참조 얻기
pub unsafe fn active_level_4_table(
physical_memory_offset: VirtAddr
) -> &'static mut PageTable {
use x86_64::registers::control::Cr3;
// CR3 레지스터에서 PML4 테이블의 물리 주소 읽기
let (level_4_table_frame, _) = Cr3::read();
let phys = level_4_table_frame.start_address();
// 물리 메모리 오프셋을 더해 가상 주소로 변환
let virt = physical_memory_offset + phys.as_u64();
let page_table_ptr: *mut PageTable = virt.as_mut_ptr();
&mut *page_table_ptr // 테이블에 대한 가변 참조 반환
}
// 가상 주소를 물리 주소로 변환
pub fn translate_addr(addr: VirtAddr, mapper: &OffsetPageTable) -> Option<PhysAddr> {
use x86_64::structures::paging::mapper::Translate;
mapper.translate_addr(addr)
}
설명
이것이 하는 일: CR3 레지스터에서 활성 페이지 테이블의 물리 주소를 읽고, 부트로더가 설정한 물리 메모리 매핑을 사용하여 안전하게 접근합니다. 첫 번째로, Cr3::read()로 현재 활성 페이지 테이블의 루트를 찾습니다.
CR3는 CPU의 제어 레지스터로, PML4 테이블(4단계 페이징의 최상위)의 물리 주소를 담고 있습니다. 이 레지스터를 읽는 것은 특권 명령이지만, 우리 커널은 ring 0에서 실행되므로 가능합니다.
반환값은 PhysFrame과 플래그의 튜플이며, 우리는 프레임의 시작 주소만 사용합니다. 그 다음으로, 물리 주소를 가상 주소로 변환합니다.
physical_memory_offset은 부트로더가 설정한 값으로, 모든 물리 메모리가 이 오프셋에서 시작하는 가상 주소에 매핑되어 있습니다. 예를 들어, 오프셋이 0xFFFF_8000_0000_0000이고 물리 주소가 0x1000이라면, 가상 주소 0xFFFF_8000_0000_1000으로 접근할 수 있습니다.
이렇게 하면 모든 물리 메모리를 간접 참조 없이 직접 읽고 쓸 수 있습니다. PageTable은 512개의 PageTableEntry를 담는 배열입니다.
각 엔트리는 64비트로, 하위 12비트는 플래그이고 나머지는 다음 단계 테이블의 물리 주소입니다. PageTable 타입은 이 구조를 추상화하여 인덱싱과 플래그 조작을 안전하게 만듭니다.
가변 참조를 반환하므로 엔트리를 수정할 수 있습니다. translate_addr 함수는 OffsetPageTable을 사용하여 주소 변환을 수행합니다.
이는 4단계를 모두 순회하며, 각 단계에서 인덱스를 추출하고 다음 테이블을 찾습니다. Present 플래그가 꺼져 있으면 None을 반환합니다.
이 함수는 디버깅에 유용하며, 특정 가상 주소가 어느 물리 주소에 매핑되어 있는지 확인할 수 있습니다. 여러분이 이 코드를 사용하면 페이지 테이블을 직접 조작할 수 있습니다.
새로운 매핑을 만들거나, 권한을 변경하거나, 매핑을 제거하는 모든 작업이 가능해집니다. 이는 프로세스 격리, 메모리 보호, on-demand 페이징 등의 고급 기능을 구현하는 기반이 됩니다.
실전 팁
💡 페이지 테이블 수정 후에는 TLB(Translation Lookaside Buffer)를 무효화해야 합니다. x86_64::instructions::tlb::flush를 사용하세요.
💡 Huge Pages(2MB 또는 1GB)를 사용하면 TLB 미스를 줄여 성능을 향상시킬 수 있습니다. PageTableFlags::HUGE_PAGE로 활성화합니다.
💡 User 플래그를 설정하지 않으면 유저 모드에서 접근할 수 없습니다. 프로세스 메모리를 매핑할 때 잊지 마세요.
💡 Copy-on-Write를 구현하려면 Writable 플래그를 끄고 페이지 폴트 핸들러에서 복사를 수행하세요.
💡 재귀 매핑 대신 물리 메모리 오프셋 매핑을 사용하면 구현이 간단하지만, 가상 주소 공간을 많이 소비합니다. 트레이드오프를 고려하세요.
9. 프레임_할당자 - 물리_메모리_관리
시작하며
여러분이 새로운 페이지 테이블을 만들거나 힙을 확장하려면 물리 메모리 프레임(4KB 블록)이 필요합니다. 하지만 어떤 프레임이 사용 중이고 어떤 프레임이 비어 있는지 어떻게 추적할까요?
문제는 물리 메모리가 연속적이지 않다는 점입니다. 메모리 맵을 보면 여러 개의 사용 가능한 영역이 흩어져 있고, 그 사이에 예약된 영역이 끼어 있습니다.
또한 할당된 프레임을 효율적으로 추적하고, 해제 시 조각화를 방지해야 합니다. 바로 이럴 때 필요한 것이 프레임 할당자입니다.
비트맵이나 스택 방식으로 사용 가능한 프레임을 관리하고, 할당/해제 인터페이스를 제공합니다.
개요
간단히 말해서, 프레임 할당자는 물리 메모리를 4KB 단위로 추적하고 할당/해제하는 메모리 관리 컴포넌트입니다. 이것이 필요한 이유는 모든 동적 메모리 작업의 기초이기 때문입니다.
페이지 테이블 생성, 힙 할당, DMA 버퍼 할당 등 모든 것이 프레임 할당자에 의존합니다. 예를 들어, 프로세스가 메모리를 요청하면 가상 주소를 할당하고, 프레임 할당자로부터 물리 프레임을 얻어 매핑합니다.
기존에는 C에서 비트맵이나 연결 리스트로 직접 구현했다면, 이제는 Rust의 안전성과 x86_64 crate의 FrameAllocator 트레이트를 활용합니다. 프레임 할당자의 핵심 특징은 첫째, 메모리 맵의 Usable 영역만 관리하여 안전성을 보장하고, 둘째, O(1) 시간에 할당/해제가 가능하도록 최적화하며, 셋째, 멀티코어 환경에서 동시성을 고려한 락 전략을 사용한다는 점입니다.
이러한 설계가 효율적이고 안전한 메모리 관리를 가능하게 합니다.
코드 예제
use bootloader::bootinfo::{MemoryMap, MemoryRegionType};
use x86_64::structures::paging::{PhysFrame, Size4KiB, FrameAllocator};
use x86_64::PhysAddr;
// 단순한 프레임 할당자 구현
pub struct BootInfoFrameAllocator {
memory_map: &'static MemoryMap,
next: usize, // 다음 확인할 프레임 인덱스
}
impl BootInfoFrameAllocator {
pub unsafe fn init(memory_map: &'static MemoryMap) -> Self {
BootInfoFrameAllocator {
memory_map,
next: 0,
}
}
// Usable 영역의 프레임을 이터레이터로 반환
fn usable_frames(&self) -> impl Iterator<Item = PhysFrame> {
let regions = self.memory_map.iter();
let usable_regions = regions
.filter(|r| r.region_type == MemoryRegionType::Usable);
let addr_ranges = usable_regions
.map(|r| r.range.start_addr()..r.range.end_addr());
let frame_addresses = addr_ranges.flat_map(|r| r.step_by(4096));
frame_addresses.map(|addr| {
PhysFrame::containing_address(PhysAddr::new(addr))
})
}
}
unsafe impl FrameAllocator<Size4KiB> for BootInfoFrameAllocator {
fn allocate_frame(&mut self) -> Option<PhysFrame> {
let frame = self.usable_frames().nth(self.next);
self.next += 1;
frame
}
}
설명
이것이 하는 일: 부트로더가 제공한 메모리 맵을 기반으로 사용 가능한 물리 프레임을 순차적으로 할당하는 단순한 할당자를 만듭니다. 첫 번째로, BootInfoFrameAllocator 구조체는 메모리 맵의 참조와 다음 프레임 인덱스를 저장합니다.
init 함수는 unsafe인데, 호출자가 메모리 맵이 정확하고 프레임이 중복 할당되지 않음을 보장해야 하기 때문입니다. 'static 라이프타임은 부트 정보가 커널 수명 동안 유효함을 나타냅니다.
그 다음으로, usable_frames 메서드가 핵심입니다. 먼저 메모리 맵을 순회하여 Usable 타입인 영역만 필터링합니다.
그런 다음 각 영역의 주소 범위를 추출하고, flat_map으로 모든 범위를 단일 이터레이터로 평탄화합니다. step_by(4096)로 4KB씩 건너뛰며 각 프레임의 시작 주소를 생성합니다.
마지막으로 주소를 PhysFrame 객체로 변환합니다. FrameAllocator 트레이트 구현은 매우 간단합니다.
allocate_frame은 usable_frames 이터레이터의 nth 메서드로 현재 인덱스에 해당하는 프레임을 가져오고, 인덱스를 증가시킵니다. 이 방식의 단점은 해제된 프레임을 재사용하지 못한다는 점입니다.
실제 시스템에서는 비트맵이나 버디 시스템 같은 더 정교한 방법이 필요합니다. 이 구현은 매번 이터레이터를 처음부터 다시 생성하므로 비효율적입니다.
nth(n)는 O(n) 시간이 걸리기 때문에, n번째 프레임을 할당하는 데 O(n^2) 시간이 소요됩니다. 최적화하려면 이터레이터를 구조체에 저장하거나, 사용 가능한 프레임을 벡터로 미리 수집하거나, 비트맵을 사용해야 합니다.
여러분이 이 할당자를 초기화하고 페이지 테이블 매퍼에 제공하면, 동적으로 페이지를 매핑할 수 있습니다. 예를 들어, mapper.map_to(page, frame, flags, &mut frame_allocator)를 호출하면 내부적으로 필요한 페이지 테이블을 위한 프레임을 자동으로 할당합니다.
실전 팁
💡 실제 프로젝트에서는 비트맵 방식이 효율적입니다. 각 프레임을 1비트로 표현하면 4GB 메모리를 128KB 비트맵으로 추적할 수 있습니다.
💡 버디 할당자는 조각화를 줄이고 큰 연속 블록 할당에 유리합니다. Linux 커널도 사용하는 검증된 알고리즘입니다.
💡 프레임 해제를 구현할 때는 중복 해제(double free)를 방지해야 합니다. PhysFrame을 소유 타입으로 감싸는 RAII 패턴이 도움이 됩니다.
💡 NUMA 시스템에서는 프로세서와 가까운 메모리를 우선 할당하는 정책이 성능을 크게 향상시킵니다.
💡 DMA를 위한 프레임은 특정 물리 주소 범위(예: 하위 16MB)에 있어야 할 수 있습니다. 별도의 영역 할당자를 만드는 것이 좋습니다.
10. 힙_할당 - Box와_Vec_사용하기
시작하며
여러분이 지금까지 작성한 커널 코드는 모두 스택 메모리만 사용했습니다. 하지만 동적 크기의 자료구조가 필요하다면 어떻게 할까요?
Vec, String, HashMap 같은 표준 컬렉션은 힙 할당을 요구합니다. 문제는 #![no_std] 환경에서는 기본 글로벌 할당자가 없다는 점입니다.
alloc 크레이트의 타입들은 사용할 수 있지만, 실제로 메모리를 할당하는 백엔드가 필요합니다. 또한 멀티스레드 환경에서 안전하게 작동해야 합니다.
바로 이럴 때 필요한 것이 커스텀 글로벌 할당자입니다. GlobalAlloc 트레이트를 구현하고 #[global_allocator]로 등록하면 표준 할당 API를 사용할 수 있습니다.
개요
간단히 말해서, 힙 할당은 런타임에 크기가 결정되는 데이터를 동적으로 관리하는 메모리 할당 방식입니다. 이것이 필요한 이유는 복잡한 자료구조와 동적 프로그램 로딩을 위해서입니다.
프로세스 테이블, 파일 시스템 캐시, 네트워크 버퍼 등 모두 동적 크기이며 힙 할당이 필요합니다. 예를 들어, 실행 중인 프로세스 목록은 프로세스가 생성/종료될 때마다 변경되므로 Vec<Process>로 관리하는 것이 자연스럽습니다.
기존에는 malloc/free를 직접 구현했다면, 이제는 Rust의 GlobalAlloc 트레이트와 alloc 크레이트를 활용할 수 있습니다. 힙 할당의 핵심 특징은 첫째, 할당된 메모리의 수명이 할당 함수의 스코프를 넘어선다는 점, 둘째, 할당 크기가 컴파일 타임에 알려지지 않아도 된다는 점, 셋째, 할당자 알고리즘(first-fit, best-fit, buddy 등)에 따라 성능과 조각화 특성이 달라진다는 점입니다.
이러한 유연성이 복잡한 시스템 소프트웨어를 가능하게 합니다.
코드 예제
use alloc::alloc::{GlobalAlloc, Layout};
use linked_list_allocator::LockedHeap;
use x86_64::structures::paging::{Mapper, Page, Size4KiB};
use x86_64::VirtAddr;
// 글로벌 힙 할당자 선언
#[global_allocator]
static ALLOCATOR: LockedHeap = LockedHeap::empty();
// 힙 초기화
pub fn init_heap(
mapper: &mut impl Mapper<Size4KiB>,
frame_allocator: &mut impl FrameAllocator<Size4KiB>,
) -> Result<(), MapToError<Size4KiB>> {
const HEAP_START: usize = 0x_4444_4444_0000;
const HEAP_SIZE: usize = 100 * 1024; // 100 KB
// 힙을 위한 가상 페이지 범위 생성
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);
// 각 페이지를 물리 프레임에 매핑
for page in Page::range_inclusive(heap_start_page, heap_end_page) {
let frame = frame_allocator.allocate_frame()
.ok_or(MapToError::FrameAllocationFailed)?;
let flags = PageTableFlags::PRESENT | PageTableFlags::WRITABLE;
mapper.map_to(page, frame, flags, frame_allocator)?.flush();
}
// 할당자에 힙 영역 등록
unsafe {
ALLOCATOR.lock().init(HEAP_START, HEAP_SIZE);
}
Ok(())
}
설명
이것이 하는 일: 커널의 가상 주소 공간에 힙 영역을 예약하고, 페이지 테이블에 매핑한 후 할당자를 초기화하여 Box, Vec 등을 사용 가능하게 합니다. 첫 번째로, 글로벌 할당자를 선언합니다.
linked_list_allocator 크레이트의 LockedHeap은 간단하지만 효과적인 프리리스트 기반 할당자로, 내부적으로 스핀락으로 보호되어 멀티스레드에 안전합니다. #[global_allocator] 속성은 이 할당자를 alloc::alloc::alloc과 dealloc의 백엔드로 등록합니다.
이후 Box::new나 Vec::new가 이 할당자를 자동으로 사용합니다. 그 다음으로, init_heap에서 메모리를 준비합니다.
HEAP_START는 임의로 선택한 가상 주소로, 커널 주소 공간의 상위 절반에 위치합니다. 중요한 것은 다른 영역(스택, 코드, 데이터)과 겹치지 않아야 한다는 점입니다.
HEAP_SIZE는 초기 힙 크기로, 나중에 확장할 수 있습니다. 100KB는 초기 테스트에 충분하며, 실제 시스템에서는 수 MB로 시작합니다.
Page::range_inclusive로 힙을 커버하는 모든 페이지를 순회합니다. 각 페이지에 대해 프레임 할당자에서 물리 프레임을 얻고, map_to로 매핑을 생성합니다.
PRESENT 플래그는 페이지가 메모리에 있음을 나타내고, WRITABLE은 쓰기 허용을 의미합니다. flush()는 TLB를 무효화하여 매핑이 즉시 효과를 발휘하게 합니다.
이 과정이 실패하면 프레임 부족을 의미하므로 에러를 반환합니다. 마지막으로, ALLOCATOR.lock().init()으로 할당자에게 힙의 시작 주소와 크기를 알려줍니다.
이제 할당자는 이 영역을 프리리스트로 관리하며, alloc 호출 시 여기서 메모리를 잘라 반환합니다. linked_list_allocator는 해제된 블록을 연결 리스트로 추적하여 재사용합니다.
여러분이 init_heap을 커널 초기화 중에 호출한 후, let v = vec![1, 2, 3];처럼 코드를 작성하면 정상적으로 작동합니다. Vec는 내부적으로 GlobalAlloc::alloc을 호출하고, 할당자가 힙에서 메모리를 제공합니다.
Drop 시에는 자동으로 dealloc이 호출되어 메모리가 반환됩니다.
실전 팁
💡 linked_list_allocator는 간단하지만 조각화에 취약합니다. 실제 OS에서는 slab 할당자나 버디 시스템을 고려하세요.
💡 alloc_error_handler를 정의하여 할당 실패 시 동작을 커스터마이즈할 수 있습니다. 패닉 대신 OOM 킬러를 실행할 수 있습니다.
💡 힙 크기는 동적으로 확장할 수 있습니다. 할당 실패 시 새 페이지를 매핑하고 힙을 확장하는 로직을 추가하세요.
💡 유저 공간 프로세스는 각자 독립적인 힙을 가져야 합니다. 프로세스별 페이지 테이블에서 같은 가상 주소를 다른 물리 프레임에 매핑하면 됩니다.
💡 힙 메모리 사용량을 추적하려면 GlobalAlloc 래퍼를 만들어 할당 통계를 수집하세요. 메모리 누수 디버깅에 유용합니다.