이미지 로딩 중...
AI Generated
2025. 11. 14. · 5 Views
Rust로 만드는 나만의 OS - BIOS vs UEFI 부팅 완벽 가이드
운영체제 개발의 시작점인 부팅 과정을 깊이 있게 다룹니다. BIOS와 UEFI의 차이점, 부트로더 작성 방법, 그리고 Rust로 실제 부팅 가능한 OS 커널을 만드는 방법을 실습 중심으로 학습합니다.
목차
- BIOS 부팅 방식의 이해
- UEFI 부팅 방식의 이해
- Rust OS 프로젝트 초기 설정
- VGA 텍스트 버퍼 출력
- 부트로더의 메모리 레이아웃 이해
- CPU 예외 처리와 IDT 설정
- BIOS 부트로더 작성 실습
- UEFI 부트로더 작성 실습
- 부팅 디버깅 기법
- BIOS와 UEFI 멀티 부팅 지원
1. BIOS 부팅 방식의 이해
시작하며
여러분이 컴퓨터를 켤 때마다 일어나는 마법 같은 과정, 궁금하지 않으셨나요? 전원 버튼을 누르는 순간부터 운영체제가 로딩되기까지, 그 짧은 몇 초 동안 수많은 일들이 일어납니다.
전통적인 BIOS(Basic Input/Output System) 부팅은 1980년대부터 사용되어 온 방식입니다. 16비트 리얼 모드에서 시작하여, 부트섹터의 512바이트 코드를 실행하는 단순하면서도 강력한 메커니즘이죠.
하지만 이 단순함 속에 많은 제약과 도전 과제가 숨어 있습니다. OS 개발을 시작한다면, BIOS 부팅 방식을 이해하는 것이 필수입니다.
이것은 컴퓨터 하드웨어와 소프트웨어가 만나는 가장 근본적인 지점이기 때문입니다.
개요
간단히 말해서, BIOS 부팅은 컴퓨터가 켜질 때 펌웨어가 하드디스크의 첫 번째 섹터(MBR)를 읽어 실행하는 과정입니다. BIOS는 POST(Power-On Self-Test)를 수행한 후, 부팅 가능한 디스크를 찾습니다.
디스크의 첫 512바이트를 메모리 주소 0x7C00에 로드하고, 마지막 2바이트가 0x55AA인지 확인합니다. 이 매직 넘버가 있으면 유효한 부트섹터로 간주하여 실행을 시작합니다.
전통적인 방법과 비교하면, BIOS는 매우 제한적입니다. 단 512바이트 안에 부트로더를 작성해야 하고, 16비트 리얼 모드에서 시작하며, 1MB 미만의 메모리만 직접 접근할 수 있습니다.
핵심 특징은 다음과 같습니다: (1) 레거시 호환성이 뛰어나 거의 모든 x86 시스템에서 동작하고, (2) 단순한 구조로 이해하기 쉬우며, (3) 디버깅이 상대적으로 간단합니다. 하지만 이러한 단순함은 현대적인 대용량 디스크나 고급 보안 기능을 다루기 어렵게 만듭니다.
코드 예제
; BIOS 부트섹터 예제 (NASM 문법)
[org 0x7c00] ; BIOS가 코드를 로드하는 위치
[bits 16] ; 16비트 리얼 모드
start:
mov ax, 0x0003 ; 텍스트 모드 설정
int 0x10 ; BIOS 비디오 인터럽트
mov si, msg ; 메시지 포인터를 SI 레지스터에
call print ; 출력 함수 호출
jmp $ ; 무한 루프
print: ; 문자열 출력 함수
lodsb ; SI가 가리키는 바이트를 AL로 로드
or al, al ; AL이 0인지 확인 (문자열 끝)
jz done
mov ah, 0x0e ; BIOS teletype 출력
int 0x10 ; 문자 출력
jmp print
done:
ret
msg db 'Booting OS...', 0
times 510-($-$$) db 0 ; 510바이트까지 0으로 채움
dw 0xaa55 ; 부트 시그니처
설명
이것이 하는 일: BIOS 부트섹터는 컴퓨터가 켜졌을 때 가장 먼저 실행되는 코드로, 운영체제 커널을 메모리에 로드하는 역할을 합니다. 첫 번째로, [org 0x7c00]과 [bits 16] 지시어는 어셈블러에게 코드가 메모리 주소 0x7C00에 로드될 것이며 16비트 명령어를 생성하라고 알려줍니다.
BIOS는 항상 이 주소에 부트섹터를 로드하기 때문에, 메모리 참조가 정확히 동작하려면 이 설정이 필수입니다. 그 다음으로, BIOS 인터럽트를 사용하여 화면에 메시지를 출력합니다.
int 0x10은 비디오 서비스를 제공하는 BIOS 인터럽트이며, ah=0x0e는 teletype 모드로 문자를 출력합니다. lodsb 명령어는 SI 레지스터가 가리키는 메모리에서 한 바이트를 읽어 AL에 저장하고 SI를 증가시킵니다.
마지막으로, times 510-($-$$) db 0는 코드를 510바이트까지 0으로 패딩하고, dw 0xaa55는 부트 시그니처를 추가합니다. BIOS는 이 시그니처가 없으면 부팅 가능한 디스크로 인식하지 않습니다.
$는 현재 위치, $$는 섹션 시작 위치를 나타내므로, $-$$는 지금까지 작성된 코드의 크기입니다. 여러분이 이 코드를 사용하면 BIOS 환경에서 실행되는 최소한의 부트로더를 만들 수 있습니다.
실무에서는 이 코드를 확장하여 디스크에서 더 큰 커널 이미지를 읽어오고, 보호 모드나 롱 모드로 전환하여 현대적인 운영체제를 부팅할 수 있습니다.
실전 팁
💡 BIOS 인터럽트는 리얼 모드에서만 사용할 수 있으므로, 보호 모드로 전환하기 전에 필요한 모든 하드웨어 정보를 수집해야 합니다
💡 512바이트 제약을 극복하려면 2단계 부트로더를 사용하세요. 첫 단계는 512바이트 내에서 더 큰 2단계 로더를 디스크에서 읽어옵니다
💡 QEMU나 Bochs 같은 에뮬레이터를 사용하면 실제 하드웨어 없이도 부트로더를 테스트할 수 있습니다: qemu-system-x86_64 -drive format=raw,file=boot.bin
💡 디버깅할 때는 Bochs의 내장 디버거나 QEMU의 GDB 서버를 활용하면 레지스터와 메모리 상태를 실시간으로 확인할 수 있습니다
💡 A20 라인을 활성화하지 않으면 1MB 이상의 메모리에 접근할 수 없으니, 보호 모드 전환 전에 반드시 활성화하세요
2. UEFI 부팅 방식의 이해
시작하며
여러분이 최근에 구입한 컴퓨터를 켜면, 예전 컴퓨터보다 훨씬 빠르게 부팅되는 것을 느낄 수 있습니다. 또한 화려한 그래픽 부팅 화면과 마우스를 사용할 수 있는 BIOS 설정 화면을 보셨을 겁니다.
이것이 바로 UEFI의 세계입니다. UEFI(Unified Extensible Firmware Interface)는 BIOS의 현대적인 대체재로, 2000년대 중반부터 점진적으로 도입되었습니다.
Intel이 주도하여 개발했으며, 현재는 거의 모든 새로운 컴퓨터에 탑재되고 있습니다. BIOS의 16비트 제약에서 벗어나 32비트/64비트 환경에서 직접 동작하며, 훨씬 더 강력한 기능을 제공합니다.
OS 개발 관점에서 UEFI는 양날의 검입니다. 더 많은 기능과 편의성을 제공하지만, 그만큼 복잡도도 높아졌습니다.
Secure Boot, GPT 파티션, PE/COFF 실행 파일 형식 등 새로운 개념들을 이해해야 합니다.
개요
간단히 말해서, UEFI는 운영체제와 펌웨어 사이의 인터페이스를 정의하는 규격으로, FAT32 파티션에서 EFI 실행 파일을 로드하여 부팅합니다. UEFI는 BIOS와 달리 파일시스템을 이해할 수 있습니다.
ESP(EFI System Partition)라는 특별한 FAT32 파티션에서 /EFI/BOOT/BOOTX64.EFI 같은 실행 파일을 찾아 로드합니다. 이 파일은 PE/COFF 형식으로 컴파일된 64비트 실행 파일이며, 부팅 과정에서 훨씬 더 많은 메모리와 CPU 기능을 활용할 수 있습니다.
기존 BIOS와 비교하면, UEFI는 512바이트 제약이 없고, 처음부터 32비트나 64비트 모드에서 시작하며, 2TB 이상의 대용량 디스크를 지원합니다. 또한 네트워크 부팅, 그래픽 출력, 마우스 입력 등 현대적인 기능을 펌웨어 레벨에서 제공합니다.
핵심 특징은 다음과 같습니다: (1) Boot Services와 Runtime Services로 구분된 풍부한 API 제공, (2) GPT 파티션 테이블을 통한 대용량 디스크 지원, (3) Secure Boot를 통한 부팅 보안 강화. 이러한 특징들은 현대 운영체제의 요구사항을 충족시키지만, 개발자에게는 더 많은 학습 곡선을 요구합니다.
코드 예제
// UEFI 부트로더 예제 (Rust)
#![no_std]
#![no_main]
use uefi::prelude::*;
use uefi::table::boot::MemoryType;
#[entry]
fn main(_handle: Handle, mut system_table: SystemTable<Boot>) -> Status {
// UEFI 출력 프로토콜 사용
uefi_services::init(&mut system_table).unwrap();
// 화면에 메시지 출력
system_table
.stdout()
.output_string(cstr16!("Hello UEFI World!\n"))
.unwrap();
// 메모리 맵 가져오기
let boot_services = system_table.boot_services();
let mmap_size = boot_services.memory_map_size();
// Boot Services 종료 (OS로 제어권 이전)
system_table.exit_boot_services(MemoryType::LOADER_DATA);
// 여기서부터는 커널 코드 실행
loop {}
}
설명
이것이 하는 일: UEFI 부트로더는 펌웨어가 제공하는 Boot Services를 활용하여 운영체제 커널을 준비하고, 최종적으로 제어권을 커널에 넘깁니다. 첫 번째로, #![no_std]와 #![no_main] 속성은 Rust 표준 라이브러리와 일반적인 main 함수를 사용하지 않겠다는 선언입니다.
UEFI 환경은 호스트 운영체제가 없는 베어메탈 환경이므로, 표준 라이브러리의 시스템 호출이나 메모리 할당자를 사용할 수 없습니다. #[entry] 매크로는 UEFI 펌웨어가 호출할 진입점을 정의합니다.
그 다음으로, SystemTable을 통해 UEFI가 제공하는 모든 서비스에 접근합니다. stdout() 메서드로 콘솔 출력 프로토콜을 얻어 화면에 메시지를 출력할 수 있습니다.
cstr16! 매크로는 UTF-16 형식의 문자열을 생성하는데, UEFI는 유니코드를 기본으로 사용하기 때문입니다. boot_services()를 통해 메모리 할당, 파일 읽기, 프로토콜 검색 등 다양한 기능을 사용할 수 있습니다.
마지막으로, exit_boot_services()를 호출하면 UEFI Boot Services가 종료되고 운영체제가 하드웨어를 완전히 제어하게 됩니다. 이 함수를 호출한 후에는 UEFI의 Boot Services API를 더 이상 사용할 수 없고, 오직 Runtime Services만 사용 가능합니다.
메모리 맵을 미리 받아두는 이유는 이 정보가 OS의 메모리 관리자를 초기화하는 데 필수적이기 때문입니다. 여러분이 이 코드를 사용하면 UEFI 환경에서 실행되는 부트로더를 Rust로 작성할 수 있습니다.
실무에서는 이 코드를 확장하여 커널 이미지를 파일시스템에서 읽어오고, 페이지 테이블을 설정하며, 그래픽 프레임버퍼를 초기화하여 현대적인 OS를 부팅할 수 있습니다.
실전 팁
💡 UEFI 애플리케이션은 반드시 PE/COFF 형식이어야 하므로, Rust에서는 x86_64-unknown-uefi 타겟을 사용하세요
💡 exit_boot_services() 호출 전에 필요한 모든 정보(메모리 맵, 그래픽 모드, ACPI 테이블 등)를 미리 수집해야 합니다
💡 Secure Boot 환경에서 테스트하려면 자체 서명된 인증서로 EFI 파일에 서명하거나, UEFI 설정에서 Secure Boot를 임시로 비활성화하세요
💡 OVMF(Open Virtual Machine Firmware)를 사용하면 QEMU에서 UEFI 부팅을 테스트할 수 있습니다: qemu-system-x86_64 -bios OVMF.fd -drive format=raw,file=disk.img
💡 UEFI Shell을 사용하면 EFI 애플리케이션을 대화형으로 실행하고 디버깅할 수 있어, 개발 초기 단계에서 매우 유용합니다
3. Rust OS 프로젝트 초기 설정
시작하며
여러분이 "나만의 운영체제를 만들어보고 싶다"는 생각을 한 적이 있나요? 하지만 막상 시작하려니 어디서부터 손을 대야 할지 막막했을 겁니다.
C로 작성된 복잡한 튜토리얼들, 세그멘테이션 폴트로 가득한 디버깅 세션들... Rust는 OS 개발의 패러다임을 바꾸고 있습니다.
메모리 안전성을 컴파일 타임에 보장하면서도, C와 동등한 성능과 하드웨어 제어 능력을 제공합니다. 소유권 시스템은 데이터 레이스를 원천적으로 방지하고, 타입 시스템은 많은 논리적 오류를 미리 잡아냅니다.
하지만 Rust로 OS를 개발하려면 특별한 설정이 필요합니다. 표준 라이브러리 없이, 힙 할당자 없이, 심지어 운영체제 없이 코드를 실행해야 하니까요.
이것이 바로 베어메탈 프로그래밍입니다.
개요
간단히 말해서, Rust OS 프로젝트는 no_std 환경에서 동작하도록 설정된 특별한 Rust 프로젝트로, 커스텀 타겟과 링커 스크립트를 사용합니다. 일반적인 Rust 프로그램은 표준 라이브러리(std)에 의존하지만, 표준 라이브러리는 운영체제의 시스템 콜을 사용합니다.
OS를 만들 때는 아직 운영체제가 없으므로, #![no_std] 속성을 사용하여 표준 라이브러리를 제외하고 코어 라이브러리(core)만 사용해야 합니다. 또한 panic 핸들러, 메모리 할당자 등을 직접 구현해야 합니다.
전통적인 C 기반 OS 개발과 비교하면, Rust는 더 안전합니다. 버퍼 오버플로우, Use-After-Free, 데이터 레이스 같은 일반적인 시스템 프로그래밍 버그를 컴파일 타임에 방지할 수 있습니다.
하지만 그만큼 컴파일러와의 싸움도 많아집니다. 핵심 요소는 다음과 같습니다: (1) .cargo/config.toml에서 커스텀 타겟 지정, (2) Cargo.toml에 panic = "abort" 설정으로 unwinding 제거, (3) 링커 스크립트로 메모리 레이아웃 정의.
이 설정들이 올바르게 조합되어야 부팅 가능한 바이너리가 생성됩니다.
코드 예제
# Cargo.toml
[package]
name = "my_os"
version = "0.1.0"
edition = "2021"
[dependencies]
bootloader = "0.9" # 부트로더 크레이트
[profile.dev]
panic = "abort" # Panic 시 스택 unwinding 대신 abort
[profile.release]
panic = "abort"
# .cargo/config.toml
[build]
target = "x86_64-my_os.json" # 커스텀 타겟
[target.'cfg(target_os = "none")']
runner = "bootimage runner" # 부팅 이미지 실행기
설명
이것이 하는 일: 이 설정 파일들은 Rust 컴파일러에게 표준 라이브러리 없이 베어메탈 바이너리를 생성하는 방법을 알려줍니다. 첫 번째로, Cargo.toml의 panic = "abort" 설정은 매우 중요합니다.
기본적으로 Rust는 패닉 발생 시 스택을 unwinding하여 메모리를 정리하는데, 이 과정은 운영체제의 지원이 필요합니다. OS 커널 자체에서는 이런 지원이 없으므로, 패닉 시 즉시 중단(abort)하도록 설정합니다.
bootloader 크레이트는 BIOS와 UEFI 양쪽을 지원하는 부트로더를 자동으로 생성해줍니다. 그 다음으로, .cargo/config.toml에서 빌드 타겟을 지정합니다.
x86_64-my_os.json은 커스텀 타겟 스펙 파일로, "os": "none"을 포함하여 OS가 없는 환경임을 명시합니다. 이 파일에는 링커 설정, 최적화 플래그, LLVM 옵션 등이 포함됩니다.
runner 설정은 cargo run을 실행했을 때 QEMU에서 바이너리를 자동으로 부팅하도록 합니다. 마지막으로, bootimage 도구는 커널 바이너리와 부트로더를 결합하여 부팅 가능한 디스크 이미지를 생성합니다.
이 과정에서 부트로더는 커널을 메모리에 로드하고, 필요한 CPU 모드 전환(리얼 모드 → 보호 모드 → 롱 모드)을 수행한 후, 커널의 진입점으로 점프합니다. 여러분이 이 설정을 사용하면 cargo build로 OS 커널을 컴파일하고, cargo run으로 QEMU에서 바로 테스트할 수 있습니다.
실무에서는 이 기본 설정에 테스트 프레임워크, 디버그 심볼 설정, 최적화 프로파일 등을 추가하여 개발 환경을 더욱 개선할 수 있습니다.
실전 팁
💡 커스텀 타겟 JSON 파일에는 "disable-redzone": true를 꼭 포함하세요. Red zone은 인터럽트와 충돌하여 스택 손상을 일으킬 수 있습니다
💡 bootimage 크레이트를 사용하면 부트로더를 수동으로 작성할 필요 없이 cargo bootimage로 부팅 이미지를 생성할 수 있습니다
💡 개발 초기에는 cargo install cargo-watch를 사용하여 파일 변경 시 자동으로 재빌드하고 QEMU를 재시작하세요
💡 .cargo/config.toml에 rustflags를 추가하여 링커에 전달할 플래그를 지정할 수 있습니다. 예: "-C link-arg=-T linker.ld"
💡 rust-analyzer가 no_std 프로젝트를 제대로 인식하지 못하면, .vscode/settings.json에 "rust-analyzer.cargo.target": "x86_64-my_os.json"을 추가하세요
4. VGA 텍스트 버퍼 출력
시작하며
여러분의 OS가 처음으로 "Hello, World!"를 화면에 출력하는 순간을 상상해보세요. 디버거 없이, printf 없이, 오직 여러분의 코드만으로 화면에 글자가 나타나는 그 순간의 감동은 이루 말할 수 없습니다.
VGA 텍스트 모드는 1980년대부터 존재해온 레거시 기술이지만, OS 개발 초기 단계에서는 여전히 가장 간단하고 신뢰할 수 있는 출력 방법입니다. 복잡한 그래픽 드라이버 없이, 메모리 맵 I/O만으로 화면에 글자를 표시할 수 있습니다.
메모리 주소 0xB8000에 바이트를 쓰기만 하면 화면에 즉시 반영됩니다. 각 문자는 2바이트로 표현되며, 첫 바이트는 ASCII 코드, 두 번째 바이트는 색상 정보입니다.
이 간단한 인터페이스를 통해 80x25 크기의 컬러 텍스트를 출력할 수 있습니다.
개요
간단히 말해서, VGA 텍스트 버퍼는 메모리 주소 0xB8000에 매핑된 4000바이트 영역으로, 여기에 쓰는 데이터가 화면에 그대로 표시됩니다. 화면은 80열 25행의 문자 그리드로 구성되며, 각 문자는 2바이트를 차지합니다.
첫 번째 바이트는 표시할 ASCII 문자이고, 두 번째 바이트는 색상 속성입니다. 색상 바이트의 하위 4비트는 전경색, 상위 4비트는 배경색을 나타내며, 16가지 색상을 사용할 수 있습니다.
전통적인 BIOS 인터럽트 방식과 비교하면, 직접 메모리 접근은 훨씬 빠르고 유연합니다. BIOS int 0x10은 한 번에 한 문자씩 출력하고 느리지만, 메모리 맵 방식은 전체 화면을 한 번에 업데이트할 수 있고 보호 모드나 롱 모드에서도 동작합니다.
핵심 특징은 다음과 같습니다: (1) 하드웨어 직접 제어로 빠른 성능, (2) CPU 모드에 관계없이 동작, (3) 간단한 프로그래밍 모델. 다만 80x25 해상도 제약과 제한된 색상이 단점이지만, 디버깅과 초기 부팅 메시지 출력에는 충분합니다.
코드 예제
// VGA 텍스트 버퍼 드라이버 (Rust)
use core::fmt;
const BUFFER_HEIGHT: usize = 25;
const BUFFER_WIDTH: usize = 80;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum Color {
Black = 0,
Blue = 1,
Green = 2,
Cyan = 3,
Red = 4,
Magenta = 5,
Brown = 6,
LightGray = 7,
DarkGray = 8,
LightBlue = 9,
LightGreen = 10,
LightCyan = 11,
LightRed = 12,
Pink = 13,
Yellow = 14,
White = 15,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(transparent)]
struct ColorCode(u8);
impl ColorCode {
fn new(foreground: Color, background: Color) -> ColorCode {
ColorCode((background as u8) << 4 | (foreground as u8))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(C)]
struct ScreenChar {
ascii_character: u8,
color_code: ColorCode,
}
#[repr(transparent)]
struct Buffer {
chars: [[ScreenChar; BUFFER_WIDTH]; BUFFER_HEIGHT],
}
pub struct Writer {
column_position: usize,
color_code: ColorCode,
buffer: &'static mut Buffer,
}
impl Writer {
pub fn write_byte(&mut self, byte: u8) {
match byte {
b'\n' => self.new_line(),
byte => {
if self.column_position >= BUFFER_WIDTH {
self.new_line();
}
let row = BUFFER_HEIGHT - 1;
let col = self.column_position;
let color_code = self.color_code;
self.buffer.chars[row][col] = ScreenChar {
ascii_character: byte,
color_code,
};
self.column_position += 1;
}
}
}
pub fn write_string(&mut self, s: &str) {
for byte in s.bytes() {
match byte {
0x20..=0x7e | b'\n' => self.write_byte(byte),
_ => self.write_byte(0xfe), // 출력 불가능한 문자는 ■로 표시
}
}
}
fn new_line(&mut self) {
// 한 줄 위로 스크롤
for row in 1..BUFFER_HEIGHT {
for col in 0..BUFFER_WIDTH {
let character = self.buffer.chars[row][col];
self.buffer.chars[row - 1][col] = character;
}
}
self.clear_row(BUFFER_HEIGHT - 1);
self.column_position = 0;
}
fn clear_row(&mut self, row: usize) {
let blank = ScreenChar {
ascii_character: b' ',
color_code: self.color_code,
};
for col in 0..BUFFER_WIDTH {
self.buffer.chars[row][col] = blank;
}
}
}
impl fmt::Write for Writer {
fn write_str(&mut self, s: &str) -> fmt::Result {
self.write_string(s);
Ok(())
}
}
설명
이것이 하는 일: VGA 텍스트 버퍼 드라이버는 메모리 맵 I/O를 통해 화면에 컬러 텍스트를 출력하고, 자동 스크롤과 개행 처리를 구현합니다. 첫 번째로, #[repr(u8)]과 #[repr(C)] 속성은 Rust 컴파일러에게 정확한 메모리 레이아웃을 지정합니다.
Color 열거형은 u8로 표현되며, ScreenChar 구조체는 C 언어와 동일한 레이아웃을 사용합니다. 이것이 중요한 이유는 VGA 하드웨어가 정확히 이 형식의 데이터를 기대하기 때문입니다.
ColorCode::new()는 비트 연산으로 배경색과 전경색을 하나의 바이트로 합칩니다. 그 다음으로, Writer 구조체는 현재 커서 위치와 색상을 추적하며, buffer 필드는 'static 라이프타임을 가집니다.
이는 VGA 버퍼가 프로그램 실행 내내 존재하는 전역 리소스이기 때문입니다. write_byte() 메서드는 개행 문자를 특별히 처리하고, 줄 끝에 도달하면 자동으로 다음 줄로 넘어갑니다.
write_string()은 UTF-8 문자열을 받아 ASCII 범위의 문자만 출력합니다. 마지막으로, new_line() 함수는 스크롤 기능을 구현합니다.
모든 줄을 한 칸씩 위로 복사하고, 마지막 줄을 빈 공간으로 채웁니다. fmt::Write 트레이트를 구현함으로써 Rust의 포맷 매크로(write!, writeln!)를 사용할 수 있게 됩니다.
이는 println! 매크로를 OS에서 구현할 수 있는 기반이 됩니다. 여러분이 이 코드를 사용하면 OS에서 컬러 텍스트를 출력할 수 있으며, 스크롤링 콘솔을 구현할 수 있습니다.
실무에서는 여기에 커서 표시, 색상 변경 이스케이프 시퀀스, 더블 버퍼링 등을 추가하여 더 풍부한 TUI(Text User Interface)를 만들 수 있습니다.
실전 팁
💡 VGA 버퍼 접근 시 volatile 읽기/쓰기를 사용해야 컴파일러 최적화로 인한 문제를 방지할 수 있습니다. volatile 크레이트를 사용하세요
💡 전역 Writer 인스턴스를 만들 때는 lazy_static! 매크로와 Mutex를 사용하여 안전한 동시 접근을 보장하세요
💡 core::fmt::Write 트레이트를 구현하면 write! 매크로로 포맷된 출력이 가능합니다: write!(writer, "x = {}", 42)
💡 시리얼 포트 출력을 함께 구현하면 QEMU의 -serial stdio 옵션으로 터미널에서 직접 커널 로그를 볼 수 있어 디버깅이 훨씬 편해집니다
💡 화면 클리어를 빠르게 하려면 ptr::write_bytes()로 전체 버퍼를 한 번에 초기화하는 것이 루프보다 효율적입니다
5. 부트로더의 메모리 레이아웃 이해
시작하며
여러분의 OS가 메모리 어디에 로드되는지, 스택은 어디에 위치하는지, 힙은 어디서 시작하는지 정확히 아시나요? 이런 질문들이 추상적으로 느껴진다면, 아직 메모리 레이아웃에 대한 이해가 부족한 것입니다.
부팅 과정에서 메모리는 여러 단계를 거쳐 구성됩니다. BIOS/UEFI가 부트로더를 로드하고, 부트로더가 커널을 로드하며, 커널이 자체 메모리 관리자를 초기화합니다.
각 단계에서 메모리의 어느 영역을 사용할 수 있고, 어느 영역은 예약되어 있는지 정확히 알아야 합니다. 잘못된 메모리 주소에 접근하면 삼중 오류(Triple Fault)가 발생하여 시스템이 재부팅됩니다.
디버그 메시지도, 스택 트레이스도 없이 그냥 재부팅됩니다. 이런 상황을 피하려면 메모리 맵을 철저히 이해하고, 부트로더가 설정한 환경을 존중해야 합니다.
개요
간단히 말해서, 메모리 레이아웃은 물리 메모리의 각 영역이 어떤 목적으로 사용되는지 정의하는 맵으로, 부트로더가 커널에 전달합니다. x86_64 시스템에서 물리 메모리는 여러 용도로 구분됩니다.
0x00x400은 실제 모드 IVT(Interrupt Vector Table), 0x5000x7BFF는 자유 사용 가능, 0x7C000x7DFF는 BIOS 부트섹터, 0x800000x9FFFF는 확장 BIOS 데이터 영역, 0xA0000~0xFFFFF는 비디오 메모리와 BIOS ROM입니다. 1MB 이상의 영역은 대부분 사용 가능하지만, ACPI 테이블이나 하드웨어 예약 영역이 산재해 있습니다.
전통적인 링커 스크립트 방식과 비교하면, 현대적인 부트로더는 동적으로 메모리 맵을 생성하여 커널에 전달합니다. bootloader 크레이트는 커널을 상위 반(higher half) 가상 메모리에 매핑하고, 물리 메모리 정보를 BootInfo 구조체로 제공합니다.
핵심 개념은 다음과 같습니다: (1) 물리 메모리와 가상 메모리의 구분, (2) 커널 코드/데이터/스택의 메모리 세그먼트 분리, (3) 사용 가능한 메모리 영역과 예약 영역의 식별. 이 정보를 바탕으로 커널은 페이지 프레임 할당자와 힙 할당자를 초기화할 수 있습니다.
코드 예제
// 부트로더로부터 메모리 정보 받기 (Rust)
use bootloader::{BootInfo, entry_point};
entry_point!(kernel_main);
fn kernel_main(boot_info: &'static BootInfo) -> ! {
use x86_64::structures::paging::PageTable;
// 물리 메모리 맵 정보 출력
println!("Memory Map:");
for region in boot_info.memory_map.iter() {
println!(
" Start: 0x{:016x}, End: 0x{:016x}, Type: {:?}",
region.range.start_addr(),
region.range.end_addr(),
region.region_type
);
}
// 커널이 로드된 물리 주소 확인
let phys_mem_offset = boot_info.physical_memory_offset;
println!("Physical memory offset: 0x{:x}", phys_mem_offset);
// 레벨 4 페이지 테이블 접근
let level_4_table_addr = boot_info.physical_memory_offset
+ boot_info.recursive_page_table.as_u64();
let level_4_table_ptr = level_4_table_addr as *mut PageTable;
let level_4_table = unsafe { &mut *level_4_table_ptr };
println!("Level 4 page table at: {:p}", level_4_table);
// 메모리 영역별 크기 계산
let mut usable_memory = 0u64;
for region in boot_info.memory_map.iter() {
if region.region_type == bootloader::bootinfo::MemoryRegionType::Usable {
usable_memory += region.range.end_addr() - region.range.start_addr();
}
}
println!("Total usable memory: {} MB", usable_memory / 1024 / 1024);
loop {}
}
설명
이것이 하는 일: 부트로더가 제공한 메모리 맵을 분석하여 사용 가능한 메모리 영역을 식별하고, 페이지 테이블을 통해 가상 메모리를 설정합니다. 첫 번째로, entry_point! 매크로는 부트로더가 호출할 커널 진입점을 설정합니다.
일반적인 main 함수와 달리, 이 함수는 BootInfo 구조체를 받아 부트로더가 수집한 시스템 정보를 전달받습니다. &'static 라이프타임은 이 정보가 프로그램 전체 수명 동안 유효함을 나타냅니다.
-> ! 반환 타입은 이 함수가 절대 반환하지 않는다는 것을 의미하며, OS 커널은 종료될 수 없기 때문입니다. 그 다음으로, memory_map.iter()를 통해 각 메모리 영역을 순회합니다.
각 영역은 시작 주소, 끝 주소, 타입(Usable, Reserved, ACPI 등)을 포함합니다. physical_memory_offset은 가상 주소 공간에서 모든 물리 메모리가 매핑된 오프셋입니다.
예를 들어, 오프셋이 0xFFFF800000000000이면, 물리 주소 0x1000은 가상 주소 0xFFFF800000001000에 매핑됩니다. 마지막으로, 페이지 테이블에 접근하는 코드는 unsafe 블록이 필요합니다.
원시 포인터를 역참조하는 것은 Rust가 안전성을 보장할 수 없기 때문입니다. 레벨 4 페이지 테이블은 가상 주소 변환의 최상위 단계로, 여기서부터 4단계의 페이지 테이블 워킹을 통해 최종 물리 주소를 얻습니다.
MemoryRegionType::Usable 영역만 합산하여 커널이 사용할 수 있는 총 메모리 크기를 계산합니다. 여러분이 이 코드를 사용하면 시스템의 메모리 구조를 정확히 파악하고, 이를 바탕으로 페이지 프레임 할당자를 구현할 수 있습니다.
실무에서는 이 정보를 바탕으로 비트맵 기반 또는 버디 시스템 기반의 물리 메모리 할당자를 만들고, 가상 메모리 관리자를 초기화합니다.
실전 팁
💡 메모리 맵을 출력하여 저장해두면 디버깅 시 매우 유용합니다. 특히 페이지 폴트가 발생했을 때 어느 영역이 문제인지 즉시 파악할 수 있습니다
💡 physical_memory_offset을 전역 변수로 저장하면, 나중에 물리 주소를 가상 주소로 쉽게 변환할 수 있습니다
💡 ACPI 테이블이나 프레임버퍼 같은 예약 영역은 절대 할당자에 포함시키면 안 됩니다. 시스템 크래시의 주요 원인입니다
💡 페이지 테이블 구조를 시각화하는 디버그 함수를 만들면, 가상 메모리 매핑 문제를 빠르게 진단할 수 있습니다
💡 메모리 맵은 시스템마다 다를 수 있으므로, 다양한 환경(QEMU, 실제 하드웨어, VirtualBox 등)에서 테스트하세요
6. CPU 예외 처리와 IDT 설정
시작하며
여러분의 OS에서 0으로 나누기를 하면 어떻게 될까요? 잘못된 메모리 주소에 접근하면요?
초보 OS 개발자들은 종종 이런 질문에 "시스템이 멈춥니다"라고 대답하지만, 실제로는 CPU가 예외를 발생시킵니다. CPU 예외는 하드웨어 레벨에서 발생하는 오류나 특수한 상황을 나타냅니다.
Page Fault, Double Fault, General Protection Fault 등 다양한 예외가 있으며, 각각은 특정한 문제를 나타냅니다. 이런 예외를 적절히 처리하지 않으면, CPU는 Triple Fault를 발생시키고 시스템을 재부팅합니다.
IDT(Interrupt Descriptor Table)는 각 예외나 인터럽트가 발생했을 때 어떤 핸들러 함수를 호출할지 정의하는 테이블입니다. OS 커널은 부팅 초기에 IDT를 설정하여, 모든 예외를 적절히 처리할 수 있도록 준비해야 합니다.
이것이 안정적인 OS의 첫 단계입니다.
개요
간단히 말해서, IDT는 256개의 엔트리를 가진 테이블로, 각 엔트리는 예외나 인터럽트 발생 시 실행할 핸들러 함수의 주소를 담고 있습니다. CPU 예외는 0번부터 31번까지의 벡터 번호를 가지며, 각각은 특정한 오류를 나타냅니다.
예를 들어, 벡터 0은 Divide Error, 벡터 13은 General Protection Fault, 벡터 14는 Page Fault입니다. 32번부터 255번까지는 하드웨어 인터럽트와 소프트웨어 인터럽트에 사용됩니다.
전통적인 어셈블리 방식과 비교하면, Rust의 x86_64 크레이트는 타입 안전한 IDT 설정을 제공합니다. 잘못된 함수 시그니처나 스택 정렬 문제를 컴파일 타임에 잡아내므로, 런타임 크래시를 크게 줄일 수 있습니다.
핵심 요소는 다음과 같습니다: (1) 각 예외에 대한 핸들러 함수 작성, (2) IDT 엔트리에 핸들러 등록, (3) lidt 명령어로 IDT를 CPU에 로드. 특히 Double Fault 핸들러는 별도의 스택을 사용하여 스택 오버플로우 상황에서도 동작하도록 해야 합니다.
코드 예제
// CPU 예외 처리 (Rust)
use x86_64::structures::idt::{InterruptDescriptorTable, InterruptStackFrame};
lazy_static! {
static ref IDT: InterruptDescriptorTable = {
let mut idt = InterruptDescriptorTable::new();
idt.breakpoint.set_handler_fn(breakpoint_handler);
idt.double_fault.set_handler_fn(double_fault_handler);
idt.page_fault.set_handler_fn(page_fault_handler);
idt.general_protection_fault.set_handler_fn(general_protection_fault_handler);
idt
};
}
pub fn init_idt() {
IDT.load();
}
// Breakpoint 예외 핸들러
extern "x86-interrupt" fn breakpoint_handler(
stack_frame: InterruptStackFrame
) {
println!("EXCEPTION: BREAKPOINT\n{:#?}", stack_frame);
}
// Double Fault 핸들러 (별도 스택 사용)
extern "x86-interrupt" fn double_fault_handler(
stack_frame: InterruptStackFrame,
error_code: u64,
) -> ! {
panic!("EXCEPTION: DOUBLE FAULT\n{:#?}\nError code: {}", stack_frame, error_code);
}
// Page Fault 핸들러
use x86_64::structures::idt::PageFaultErrorCode;
use x86_64::registers::control::Cr2;
extern "x86-interrupt" fn page_fault_handler(
stack_frame: InterruptStackFrame,
error_code: PageFaultErrorCode,
) {
println!("EXCEPTION: PAGE FAULT");
println!("Accessed Address: {:?}", Cr2::read());
println!("Error Code: {:?}", error_code);
println!("{:#?}", stack_frame);
loop {}
}
// General Protection Fault 핸들러
extern "x86-interrupt" fn general_protection_fault_handler(
stack_frame: InterruptStackFrame,
error_code: u64,
) {
panic!("EXCEPTION: GENERAL PROTECTION FAULT\nError code: {}\n{:#?}",
error_code, stack_frame);
}
설명
이것이 하는 일: IDT를 초기화하고 각 CPU 예외에 대한 핸들러를 등록하여, 오류 발생 시 시스템이 적절히 대응하고 디버깅 정보를 출력합니다. 첫 번째로, extern "x86-interrupt" 호출 규약은 Rust의 특별한 기능으로, CPU 인터럽트 핸들러에 필요한 정확한 함수 프롤로그/에필로그를 생성합니다.
일반 함수와 달리, 인터럽트 핸들러는 모든 레지스터를 보존해야 하고, iretq 명령어로 반환해야 하며, 스택 정렬도 달라야 합니다. 이 호출 규약은 이 모든 것을 자동으로 처리합니다.
InterruptStackFrame은 CPU가 자동으로 푸시하는 스택 정보를 나타냅니다. 그 다음으로, lazy_static!을 사용하여 IDT를 전역 변수로 정의합니다.
static이지만 런타임에 초기화해야 하므로, lazy_static! 매크로가 필요합니다. set_handler_fn()은 각 IDT 엔트리에 핸들러 함수를 등록하며, 타입 시스템이 함수 시그니처를 검증합니다.
Double Fault 핸들러는 -> ! 반환 타입을 가지는데, 이는 절대 정상 반환할 수 없기 때문입니다. 마지막으로, Page Fault 핸들러는 CR2 레지스터를 읽어 어느 메모리 주소 접근이 실패했는지 확인합니다.
PageFaultErrorCode는 페이지가 없어서 실패했는지, 권한 위반인지, 쓰기 시도인지 등을 비트 플래그로 알려줍니다. 이 정보는 디버깅에 매우 중요하며, 실제 OS에서는 이를 바탕으로 페이지를 할당하거나 프로세스를 종료합니다.
여러분이 이 코드를 사용하면 OS가 예외 상황을 처리할 수 있게 되어, 무작정 재부팅하는 대신 의미 있는 오류 메시지를 출력할 수 있습니다. 실무에서는 여기에 스택 트레이스, 레지스터 덤프, 커널 로그 저장 등을 추가하여 사후 분석(post-mortem analysis)을 가능하게 합니다.
실전 팁
💡 Double Fault는 종종 스택 오버플로우로 인해 발생하므로, TSS(Task State Segment)에 별도의 스택을 설정해야 안전하게 처리할 수 있습니다
💡 int3 명령어로 Breakpoint 예외를 의도적으로 발생시켜 핸들러가 올바르게 동작하는지 테스트할 수 있습니다
💡 Page Fault 핸들러는 성능에 민감한 핫패스입니다. 실제 OS에서는 여기서 수요 페이징(demand paging)과 copy-on-write를 구현합니다
💡 error_code의 각 비트를 파싱하여 정확한 오류 원인을 출력하면 디버깅이 훨씬 쉬워집니다. 예: "Page-level protection violation during data write"
💡 예외 핸들러 내에서 또 다른 예외가 발생하면 Double Fault가 되므로, 핸들러 코드는 최대한 단순하고 안전하게 작성하세요
7. BIOS 부트로더 작성 실습
시작하며
여러분이 직접 512바이트 안에 마법을 담아본 적이 있나요? BIOS 부트로더는 극한의 최적화를 요구하는 도전입니다.
모든 바이트가 소중하고, 모든 명령어가 신중해야 합니다. 실제로 부트로더를 작성하면서 16비트 리얼 모드의 제약을 경험하고, BIOS 인터럽트를 사용하며, 디스크에서 데이터를 읽는 방법을 배웁니다.
이 과정은 컴퓨터 아키텍처에 대한 깊은 이해를 제공하며, 추상화 레이어 아래에서 무슨 일이 일어나는지 보여줍니다. 더 나아가, 이 부트로더는 보호 모드로 전환하고, A20 라인을 활성화하며, 커널을 메모리에 로드하여 실행하는 완전한 부팅 체인을 구현합니다.
비록 작지만, 이것은 완전히 동작하는 시스템 소프트웨어입니다.
개요
간단히 말해서, BIOS 부트로더는 16비트 리얼 모드에서 시작하여 디스크에서 커널을 읽어오고, 32비트 보호 모드로 전환한 후 커널에 제어권을 넘기는 프로그램입니다. 부트로더의 주요 작업은 다음과 같습니다: (1) BIOS int 0x13을 사용하여 디스크에서 커널 섹터를 메모리로 로드, (2) GDT(Global Descriptor Table) 설정, (3) A20 라인 활성화로 1MB 이상 메모리 접근 가능하게 하기, (4) 보호 모드로 전환, (5) 커널 진입점으로 점프.
이 모든 것을 512바이트 안에 구현해야 합니다. 현대적인 bootloader 크레이트와 비교하면, 수동 부트로더 작성은 훨씬 복잡하고 오류가 발생하기 쉽습니다.
하지만 교육적 가치는 매우 크며, 부팅 과정의 모든 세부 사항을 완전히 제어할 수 있습니다. 핵심 단계는 다음과 같습니다: (1) BIOS 인터럽트로 디스크 읽기, (2) GDT 로드와 보호 모드 전환, (3) 세그먼트 레지스터 재설정.
각 단계는 정확한 순서로 실행되어야 하며, 한 단계라도 잘못되면 시스템이 멈춥니다.
코드 예제
; 완전한 BIOS 부트로더 예제 (NASM)
[org 0x7c00]
[bits 16]
KERNEL_OFFSET equ 0x1000 ; 커널을 로드할 메모리 주소
start:
mov [BOOT_DRIVE], dl ; BIOS가 DL에 부트 드라이브 번호 전달
mov bp, 0x9000 ; 스택 설정
mov sp, bp
mov bx, MSG_REAL_MODE
call print_string
call load_kernel ; 디스크에서 커널 로드
call switch_to_pm ; 보호 모드로 전환
jmp $ ; 여기 도달하면 안됨
; 디스크에서 커널 로드
load_kernel:
mov bx, MSG_LOAD_KERNEL
call print_string
mov bx, KERNEL_OFFSET ; 로드 목적지
mov dh, 15 ; 15 섹터 읽기
mov dl, [BOOT_DRIVE]
call disk_load
ret
; BIOS 인터럽트로 디스크 읽기
disk_load:
push dx
mov ah, 0x02 ; BIOS 읽기 함수
mov al, dh ; 읽을 섹터 수
mov ch, 0x00 ; 실린더 0
mov dh, 0x00 ; 헤드 0
mov cl, 0x02 ; 섹터 2부터 시작 (1은 부트섹터)
int 0x13 ; BIOS 디스크 서비스
jc disk_error ; 에러 발생 시 점프
pop dx
cmp al, dh ; 읽은 섹터 수 확인
jne disk_error
ret
disk_error:
mov bx, MSG_DISK_ERROR
call print_string
jmp $
; 리얼 모드 문자열 출력
print_string:
pusha
mov ah, 0x0e
.loop:
lodsb
cmp al, 0
je .done
int 0x10
jmp .loop
.done:
popa
ret
; GDT 정의
gdt_start:
gdt_null:
dd 0x0, 0x0
gdt_code:
dw 0xffff ; Limit 하위 16비트
dw 0x0 ; Base 하위 16비트
db 0x0 ; Base 중간 8비트
db 10011010b ; Access byte
db 11001111b ; Flags + Limit 상위 4비트
db 0x0 ; Base 상위 8비트
gdt_data:
dw 0xffff
dw 0x0
db 0x0
db 10010010b
db 11001111b
db 0x0
gdt_end:
gdt_descriptor:
dw gdt_end - gdt_start - 1
dd gdt_start
CODE_SEG equ gdt_code - gdt_start
DATA_SEG equ gdt_data - gdt_start
[bits 16]
switch_to_pm:
cli ; 인터럽트 비활성화
lgdt [gdt_descriptor] ; GDT 로드
mov eax, cr0 ; 보호 모드 비트 설정
or eax, 0x1
mov cr0, eax
jmp CODE_SEG:init_pm ; Far jump으로 파이프라인 플러시
[bits 32]
init_pm:
mov ax, DATA_SEG ; 세그먼트 레지스터 업데이트
mov ds, ax
mov ss, ax
mov es, ax
mov fs, ax
mov gs, ax
mov ebp, 0x90000 ; 새 스택 설정
mov esp, ebp
call KERNEL_OFFSET ; 커널 실행!
jmp $
; 메시지 데이터
BOOT_DRIVE db 0
MSG_REAL_MODE db "Started in 16-bit Real Mode", 13, 10, 0
MSG_LOAD_KERNEL db "Loading kernel...", 13, 10, 0
MSG_DISK_ERROR db "Disk read error!", 13, 10, 0
times 510-($-$$) db 0
dw 0xaa55
설명
이것이 하는 일: BIOS 부트로더는 컴퓨터를 16비트 리얼 모드에서 32비트 보호 모드로 전환하고, 디스크에서 OS 커널을 메모리에 로드하여 실행합니다. 첫 번째로, BIOS는 부팅 시 DL 레지스터에 부트 드라이브 번호를 넣어주므로, 이를 저장합니다.
스택은 0x9000에 설정하는데, 이 영역은 BIOS 메모리 맵에서 안전하게 사용할 수 있는 영역입니다. int 0x13의 ah=0x02 함수는 CHS(Cylinder-Head-Sector) 주소 지정 방식으로 디스크를 읽습니다.
섹터 번호는 1부터 시작하고, 섹터 1은 부트섹터 자신이므로 섹터 2부터 커널을 읽습니다. 그 다음으로, GDT는 세 개의 엔트리를 가집니다: null descriptor, code segment, data segment.
각 디스크립터는 8바이트이며, base 주소, limit, access flags를 인코딩합니다. 10011010b는 코드 세그먼트의 플래그로, "Present, Ring 0, Executable, Readable"을 의미합니다.
lgdt 명령어는 GDT의 주소와 크기를 GDTR 레지스터에 로드합니다. 마지막으로, CR0 레지스터의 최하위 비트를 1로 설정하면 보호 모드로 전환됩니다.
하지만 CPU 파이프라인에는 아직 16비트 명령어가 남아있으므로, far jump로 파이프라인을 플러시해야 합니다. 보호 모드에서는 세그먼트 레지스터가 GDT의 인덱스를 가리키므로, 모든 세그먼트 레지스터를 DATA_SEG로 업데이트합니다.
마지막으로 call KERNEL_OFFSET로 커널에 제어권을 넘깁니다. 여러분이 이 코드를 사용하면 BIOS 환경에서 완전히 동작하는 부트로더를 만들 수 있습니다.
실무에서는 여기에 오류 처리, 로딩 진행 표시, ELF 파일 파싱 등을 추가하여 더 견고한 부트로더를 만들 수 있습니다.
실전 팁
💡 QEMU의 -d int 옵션을 사용하면 모든 인터럽트를 로그에 출력하여 BIOS 호출을 디버깅할 수 있습니다
💡 디스크 읽기는 여러 번 시도해야 할 수 있습니다. 실제 하드웨어에서는 간헐적으로 실패하므로, 재시도 로직을 추가하세요
💡 CHS 주소 지정은 8GB까지만 지원하므로, 더 큰 디스크는 LBA(Logical Block Addressing) 확장을 사용해야 합니다: int 0x13, ah=0x42
💡 A20 라인 활성화를 빠뜨리면 1MB 이상의 메모리 접근이 wrap around되어 이상한 버그가 발생합니다. Fast A20 방법이 가장 간단합니다
💡 보호 모드 전환 후 처음 몇 명령어는 매우 중요합니다. 세그먼트 레지스터를 업데이트하기 전에 메모리 접근을 하면 안 됩니다
8. UEFI 부트로더 작성 실습
시작하며
여러분이 Rust로 UEFI 애플리케이션을 작성한다는 것은, 현대적인 타입 안전성을 가지면서도 펌웨어 레벨에서 하드웨어를 직접 제어할 수 있다는 의미입니다. 이것은 시스템 프로그래밍의 새로운 패러다임입니다.
UEFI 부트로더는 BIOS 부트로더보다 훨씬 많은 기능을 사용할 수 있습니다. 파일시스템 접근, 그래픽 출력, 네트워크 통신, 메모리 관리 등 펌웨어가 제공하는 풍부한 API를 활용할 수 있습니다.
512바이트 제약도 없고, 처음부터 64비트 모드에서 실행됩니다. 하지만 편리함과 함께 복잡도도 증가합니다.
UEFI 프로토콜 시스템을 이해해야 하고, Boot Services와 Runtime Services의 차이를 알아야 하며, PE/COFF 실행 파일 형식을 다뤄야 합니다. 이 모든 것을 Rust로 타입 안전하게 구현하는 것이 우리의 목표입니다.
개요
간단히 말해서, UEFI 부트로더는 ESP 파티션에서 실행되는 PE/COFF 형식의 64비트 애플리케이션으로, UEFI Boot Services를 사용하여 커널을 로드하고 실행합니다. UEFI 부트로더의 주요 작업은 다음과 같습니다: (1) Simple File System 프로토콜로 커널 파일 읽기, (2) 그래픽 출력 프로토콜로 로딩 화면 표시, (3) 메모리 맵 수집 및 페이지 테이블 설정, (4) Boot Services 종료, (5) 커널 진입점 호출.
BIOS와 달리 모드 전환이 필요 없고, 이미 64비트 롱 모드에서 시작합니다. BIOS 부트로더와 비교하면, UEFI는 훨씬 더 고수준의 추상화를 제공합니다.
디스크 읽기를 직접 구현하는 대신 파일시스템 API를 사용하고, 수동으로 비디오 모드를 설정하는 대신 그래픽 프로토콜을 사용합니다. 하지만 이런 편의성은 펌웨어 의존성을 증가시킵니다.
핵심 개념은 다음과 같습니다: (1) 프로토콜 기반 아키텍처로 하드웨어 추상화, (2) Handle Database에서 프로토콜 검색, (3) Boot Services 종료 후 Runtime Services만 사용 가능. 이 구조를 이해하면 UEFI의 모든 기능을 활용할 수 있습니다.
코드 예제
// UEFI 부트로더 완전한 예제 (Rust)
#![no_std]
#![no_main]
use uefi::prelude::*;
use uefi::proto::media::file::*;
use uefi::proto::console::gop::GraphicsOutput;
use uefi::table::boot::{AllocateType, MemoryType};
#[entry]
fn main(image: Handle, mut st: SystemTable<Boot>) -> Status {
uefi_services::init(&mut st).unwrap();
st.stdout().reset(false).unwrap();
writeln!(st.stdout(), "UEFI Bootloader Starting...").unwrap();
// 파일 시스템에서 커널 로드
let kernel_data = load_kernel(&mut st);
writeln!(st.stdout(), "Kernel loaded: {} bytes", kernel_data.len()).unwrap();
// 그래픽 모드 설정
setup_graphics(&mut st);
// 메모리 맵 가져오기
let mmap_size = st.boot_services().memory_map_size();
let mmap_buf = st.boot_services()
.allocate_pool(MemoryType::LOADER_DATA, mmap_size.map_size + 10 * mmap_size.entry_size)
.unwrap();
// Boot Services 종료
let (_rt, mmap) = st.exit_boot_services(MemoryType::LOADER_DATA);
// 커널 진입점 호출
let kernel_entry: extern "C" fn() -> ! =
unsafe { core::mem::transmute(kernel_data.as_ptr()) };
kernel_entry();
}
fn load_kernel(st: &mut SystemTable<Boot>) -> &'static [u8] {
// Simple File System 프로토콜 찾기
let bs = st.boot_services();
let fs_handle = bs.get_handle_for_protocol::<uefi::proto::media::fs::SimpleFileSystem>()
.expect("Failed to get filesystem");
let fs = bs.open_protocol_exclusive::<uefi::proto::media::fs::SimpleFileSystem>(fs_handle)
.expect("Failed to open filesystem");
// 루트 디렉토리 열기
let mut root = fs.open_volume().expect("Failed to open root volume");
// 커널 파일 열기
let kernel_path = cstr16!("\\EFI\\BOOT\\kernel.elf");
let kernel_handle = root.open(kernel_path, FileMode::Read, FileAttribute::empty())
.expect("Failed to open kernel file");
let mut kernel_file = match kernel_handle.into_type().unwrap() {
FileType::Regular(f) => f,
_ => panic!("Kernel is not a regular file"),
};
// 파일 크기 확인
let mut info_buf = [0u8; 256];
let info = kernel_file.get_info::<FileInfo>(&mut info_buf)
.expect("Failed to get file info");
let file_size = info.file_size() as usize;
// 메모리 할당
let kernel_addr = bs.allocate_pages(
AllocateType::AnyPages,
MemoryType::LOADER_DATA,
(file_size + 4095) / 4096, // 페이지 단위로 올림
).expect("Failed to allocate memory for kernel");
// 파일 읽기
let kernel_buffer = unsafe {
core::slice::from_raw_parts_mut(kernel_addr as *mut u8, file_size)
};
kernel_file.read(kernel_buffer).expect("Failed to read kernel");
kernel_buffer
}
fn setup_graphics(st: &mut SystemTable<Boot>) {
let gop_handle = st.boot_services()
.get_handle_for_protocol::<GraphicsOutput>()
.expect("Failed to get GOP handle");
let mut gop = st.boot_services()
.open_protocol_exclusive::<GraphicsOutput>(gop_handle)
.expect("Failed to open GOP");
// 사용 가능한 모드 확인
let mode = gop.modes(st.boot_services())
.find(|m| {
let info = m.info();
info.resolution() == (1920, 1080)
})
.expect("Failed to find 1920x1080 mode");
gop.set_mode(&mode).expect("Failed to set graphics mode");
}
설명
이것이 하는 일: UEFI 부트로더는 펌웨어가 제공하는 프로토콜들을 사용하여 커널을 로드하고 실행 환경을 준비한 후, 제어권을 커널에 넘깁니다. 첫 번째로, uefi_services::init()은 전역 시스템 테이블 참조를 설정하고, 패닉 핸들러와 할당자를 초기화합니다.
writeln! 매크로는 core::fmt::Write 트레이트를 통해 UEFI의 stdout에 출력합니다. get_handle_for_protocol()은 특정 프로토콜을 구현하는 핸들을 찾는데, 이는 UEFI의 드라이버 모델의 핵심입니다.
모든 하드웨어 기능은 프로토콜로 추상화되어 있습니다. 그 다음으로, Simple File System 프로토콜을 통해 FAT32 파일시스템을 읽습니다.
open_volume()은 루트 디렉토리를 반환하고, open()으로 특정 파일을 열 수 있습니다. 경로는 UTF-16 형식이며 백슬래시를 구분자로 사용합니다.
get_info::<FileInfo>()로 파일 크기를 얻은 후, allocate_pages()로 필요한 메모리를 할당합니다. 페이지 크기는 4096바이트이므로, 올림 계산을 해야 합니다.
마지막으로, Graphics Output Protocol(GOP)로 화면 해상도를 설정합니다. modes()는 지원되는 모든 비디오 모드를 순회하며, find()로 원하는 해상도를 찾습니다.
exit_boot_services()를 호출하면 UEFI Boot Services가 종료되고, 메모리 맵이 반환됩니다. 이 시점부터 파일 읽기, 메모리 할당 등의 Boot Services를 사용할 수 없으므로, 필요한 모든 정보를 미리 수집해야 합니다.
커널 진입점은 transmute로 함수 포인터로 변환하여 호출합니다. 여러분이 이 코드를 사용하면 UEFI 환경에서 완전히 동작하는 부트로더를 Rust로 작성할 수 있습니다.
실무에서는 여기에 ELF 파일 파싱, 커널 재배치, 페이지 테이블 설정, ACPI 테이블 전달 등을 추가하여 프로덕션급 부트로더를 만들 수 있습니다.
실전 팁
💡 UEFI 애플리케이션을 빌드하려면 cargo build --target x86_64-unknown-uefi를 사용하고, 출력 파일의 확장자를 .efi로 변경하세요
💡 OVMF 펌웨어로 테스트할 때는 FAT32로 포맷된 디스크 이미지에 /EFI/BOOT/BOOTX64.EFI 경로에 부트로더를 배치해야 합니다
💡 exit_boot_services() 호출 후에는 힙 할당을 사용할 수 없으므로, 커널에 전달할 데이터는 미리 할당한 메모리에 저장하세요
💡 GOP의 프레임버퍼 주소를 커널에 전달하면, 커널이 그래픽 출력을 계속할 수 있습니다. gop.frame_buffer()로 얻을 수 있습니다
💡 UEFI는 64비트 호출 규약을 사용하므로, 커널도 64비트로 컴파일되어야 합니다. 32비트 커널을 로드하려면 별도의 작업이 필요합니다
9. 부팅 디버깅 기법
시작하며
여러분의 OS가 부팅 중에 갑자기 멈춘다면 어떻게 하시겠습니까? 화면은 검은색, 로그도 없고, 디버거도 연결되지 않은 상황에서 문제를 찾아야 합니다.
이것이 OS 개발의 가장 어려운 부분 중 하나입니다. 부팅 과정은 매우 낮은 수준에서 일어나기 때문에, 일반적인 디버깅 도구를 사용할 수 없습니다.
printf도 없고, 브레이크포인트도 설정할 수 없으며, 스택 트레이스도 볼 수 없습니다. 하지만 시리얼 포트, QEMU 로그, GDB 원격 디버깅 등의 기법을 사용하면 효과적으로 디버깅할 수 있습니다.
경험 많은 OS 개발자들은 여러 레이어의 디버깅 인프라를 구축합니다. 하드웨어 레벨의 시리얼 출력부터 소프트웨어 레벨의 로깅 프레임워크까지, 각 레벨은 서로 다른 종류의 문제를 진단하는 데 유용합니다.
개요
간단히 말해서, 부팅 디버깅은 시리얼 포트 출력, QEMU/Bochs 디버그 기능, GDB 원격 디버깅을 조합하여 부트로더와 커널의 실행을 추적하고 문제를 찾는 과정입니다. 시리얼 포트(COM1, I/O 포트 0x3F8)는 가장 기본적이면서도 강력한 디버깅 도구입니다.
VGA 버퍼와 달리 언제든지 사용할 수 있고, QEMU의 -serial 옵션으로 터미널에 직접 출력할 수 있으며, 부팅 초기부터 사용 가능합니다. 심지어 실제 하드웨어에서도 USB-to-Serial 어댑터로 연결할 수 있습니다.
전통적인 printf 디버깅과 비교하면, 시리얼 출력은 더 저수준이지만 더 안정적입니다. 화면 출력은 그래픽 모드 전환이나 페이지 폴트로 깨질 수 있지만, 시리얼 포트는 하드웨어 I/O이므로 거의 항상 동작합니다.
또한 로그를 파일로 저장하여 사후 분석이 가능합니다. 핵심 도구는 다음과 같습니다: (1) 시리얼 포트 드라이버로 조기 로깅, (2) QEMU의 -d 옵션으로 CPU 상태 추적, (3) GDB의 원격 디버깅으로 브레이크포인트와 메모리 검사.
이 도구들을 조합하면 대부분의 부팅 문제를 해결할 수 있습니다.
코드 예제
// 시리얼 포트 드라이버 (Rust)
use core::fmt;
use x86_64::instructions::port::Port;
pub struct SerialPort {
data: Port<u8>,
int_enable: Port<u8>,
fifo_ctrl: Port<u8>,
line_ctrl: Port<u8>,
modem_ctrl: Port<u8>,
line_status: Port<u8>,
}
impl SerialPort {
pub const fn new(base: u16) -> SerialPort {
SerialPort {
data: Port::new(base),
int_enable: Port::new(base + 1),
fifo_ctrl: Port::new(base + 2),
line_ctrl: Port::new(base + 3),
modem_ctrl: Port::new(base + 4),
line_status: Port::new(base + 5),
}
}
pub fn init(&mut self) {
unsafe {
self.int_enable.write(0x00); // 인터럽트 비활성화
self.line_ctrl.write(0x80); // DLAB 활성화
self.data.write(0x03); // 38400 baud (lo)
self.int_enable.write(0x00); // 38400 baud (hi)
self.line_ctrl.write(0x03); // 8비트, 패리티 없음, 1 정지 비트
self.fifo_ctrl.write(0xC7); // FIFO 활성화, 14바이트 트리거
self.modem_ctrl.write(0x0B); // IRQ 활성화, RTS/DSR 설정
}
}
fn is_transmit_empty(&mut self) -> bool {
unsafe { self.line_status.read() & 0x20 != 0 }
}
pub fn write_byte(&mut self, byte: u8) {
// 전송 버퍼가 비워질 때까지 대기
while !self.is_transmit_empty() {
core::hint::spin_loop();
}
unsafe { self.data.write(byte); }
}
pub fn write_string(&mut self, s: &str) {
for byte in s.bytes() {
self.write_byte(byte);
}
}
}
impl fmt::Write for SerialPort {
fn write_str(&mut self, s: &str) -> fmt::Result {
self.write_string(s);
Ok(())
}
}
// 전역 시리얼 포트
use spin::Mutex;
use lazy_static::lazy_static;
lazy_static! {
pub static ref SERIAL1: Mutex<SerialPort> = {
let mut port = SerialPort::new(0x3F8); // COM1
port.init();
Mutex::new(port)
};
}
// 매크로로 편리하게 사용
#[macro_export]
macro_rules! serial_print {
($($arg:tt)*) => {
$crate::serial::_print(format_args!($($arg)*));
};
}
#[macro_export]
macro_rules! serial_println {
() => ($crate::serial_print!("\n"));
($fmt:expr) => ($crate::serial_print!(concat!($fmt, "\n")));
($fmt:expr, $($arg:tt)*) => ($crate::serial_print!(
concat!($fmt, "\n"), $($arg)*));
}
#[doc(hidden)]
pub fn _print(args: fmt::Arguments) {
use core::fmt::Write;
use x86_64::instructions::interrupts;
// 인터럽트를 비활성화하여 데드락 방지
interrupts::without_interrupts(|| {
SERIAL1.lock().write_fmt(args).unwrap();
});
}
// 사용 예제
pub fn init() {
serial_println!("[BOOT] Serial port initialized");
serial_println!("[BOOT] Loading GDT...");
// ... 부팅 단계마다 로그 출력
}
설명
이것이 하는 일: 시리얼 포트 드라이버는 하드웨어 I/O 포트를 통해 문자를 전송하여, 부팅 과정의 모든 단계를 외부로 로깅할 수 있게 합니다. 첫 번째로, 시리얼 포트는 여러 개의 I/O 포트로 구성됩니다.
COM1의 기본 주소는 0x3F8이며, 데이터 포트, 인터럽트 활성화, FIFO 제어, 라인 제어 등 여러 레지스터가 연속된 주소에 배치되어 있습니다. init() 함수는 UART 칩을 초기화하는데, DLAB(Divisor Latch Access Bit)를 활성화하여 baud rate를 설정하고, 데이터 형식을 8N1(8비트, 패리티 없음, 1 정지 비트)로 설정합니다.
그 다음으로, write_byte() 함수는 전송 버퍼가 비워질 때까지 스핀 루프로 대기합니다. 라인 상태 레지스터(LSR)의 비트 5는 전송 홀딩 레지스터가 비었는지 나타내며, 이 비트가 1이 될 때까지 기다린 후 데이터를 씁니다.
spin_loop() 힌트는 CPU에게 스핀 대기 중임을 알려 전력을 절약합니다. 마지막으로, fmt::Write 트레이트를 구현하고 매크로를 정의하여 Rust의 포맷 시스템을 활용합니다.
serial_println! 매크로는 println!과 동일하게 사용할 수 있지만, VGA 버퍼 대신 시리얼 포트로 출력합니다. interrupts::without_interrupts()로 인터럽트를 임시로 비활성화하여, 인터럽트 핸들러에서 동일한 시리얼 포트를 사용할 때 발생할 수 있는 데드락을 방지합니다.
여러분이 이 코드를 사용하면 부팅 과정의 모든 단계를 로깅할 수 있으며, QEMU를 qemu-system-x86_64 -serial stdio로 실행하면 터미널에서 직접 로그를 볼 수 있습니다. 실무에서는 여기에 로그 레벨(DEBUG, INFO, WARN, ERROR), 타임스탬프, 컬러 출력 등을 추가하여 프로덕션급 로깅 시스템을 구축할 수 있습니다.
실전 팁
💡 QEMU의 -serial file:serial.log 옵션을 사용하면 모든 시리얼 출력을 파일로 저장하여 사후 분석할 수 있습니다
💡 실제 하드웨어에서 테스트할 때는 USB-to-Serial 어댑터와 minicom 또는 screen 명령어를 사용하여 시리얼 포트를 읽을 수 있습니다
💡 매우 초기 부팅 단계에서는 Rust의 포맷 매크로도 사용할 수 없으므로, 단순히 고정 문자열을 출력하는 함수를 만드세요
💡 QEMU의 -d int,cpu_reset 옵션은 모든 인터럽트와 CPU 리셋을 로그에 기록하여 Triple Fault 원인을 찾는 데 도움이 됩니다
💡 GDB 원격 디버깅을 사용하려면 QEMU를 -s -S 옵션으로 실행하고, 다른 터미널에서 gdb를 실행하여 target remote :1234로 연결하세요
10. BIOS와 UEFI 멀티 부팅 지원
시작하며
여러분의 OS가 오래된 레거시 시스템과 최신 UEFI 시스템 모두에서 부팅되어야 한다면 어떻게 하시겠습니까? 두 개의 완전히 다른 부트로더를 유지 관리하는 것은 악몽입니다.
다행히도 멀티 부팅 표준과 하이브리드 이미지 포맷을 사용하면 하나의 디스크 이미지로 BIOS와 UEFI 모두에서 부팅할 수 있습니다. Multiboot2 표준은 GRUB 같은 부트로더가 OS 커널을 로드하는 방법을 정의하며, El Torito 포맷은 CD/DVD 부팅을 지원합니다.
실제 배포를 위해서는 이런 호환성이 매우 중요합니다. 사용자가 어떤 시스템을 사용하든, 여러분의 OS는 문제없이 부팅되어야 합니다.
bootloader 크레이트는 이런 복잡성을 추상화하여, 하나의 Rust 코드베이스로 양쪽을 지원합니다.
개요
간단히 말해서, 멀티 부팅 지원은 BIOS MBR과 UEFI ESP를 모두 포함하는 하이브리드 디스크 이미지를 생성하여, 펌웨어 타입에 관계없이 부팅할 수 있게 합니다. Multiboot2 헤더를 커널 시작 부분에 포함하면, GRUB 같은 Multiboot 호환 부트로더가 커널을 인식하고 로드할 수 있습니다.
이 헤더는 커널의 진입점, 요구되는 기능(예: 페이지 정렬, 메모리 맵), 선호하는 비디오 모드 등을 명시합니다. 부트로더는 이 정보를 읽고 커널을 적절히 설정하여 실행합니다.
전통적인 단일 부팅 방식과 비교하면, 멀티 부팅은 더 복잡하지만 훨씬 유연합니다. 하나의 커널 바이너리가 여러 부트로더와 호환되며, 부트로더는 메모리 맵, 프레임버퍼 정보, 커맨드라인 인자 등을 구조화된 형식으로 전달합니다.
핵심 구성 요소는 다음과 같습니다: (1) Multiboot2 헤더를 커널에 링크, (2) GPT 파티션 테이블에 BIOS Boot 파티션과 ESP 파티션 포함, (3) bootloader 크레이트로 두 부트로더 자동 생성. 이렇게 하면 하나의 디스크 이미지가 모든 시스템에서 부팅됩니다.
코드 예제
# Multiboot2 헤더를 포함한 Rust 커널
# src/boot.s (어셈블리 부분)
.section .multiboot_header
header_start:
.long 0xe85250d6 # Multiboot2 매직 넘버
.long 0 # 아키텍처 (0 = i386 보호 모드)
.long header_end - header_start # 헤더 길이
.long -(0xe85250d6 + 0 + (header_end - header_start)) # 체크섬
# 프레임버퍼 태그
.align 8
framebuffer_tag_start:
.short 5 # 타입 (프레임버퍼)
.short 0 # 플래그
.long framebuffer_tag_end - framebuffer_tag_start
.long 1024 # 너비
.long 768 # 높이
.long 32 # BPP
framebuffer_tag_end:
# 종료 태그
.align 8
.short 0 # 타입 (종료)
.short 0 # 플래그
.long 8 # 크기
header_end:
.section .text
.global _start
.code32
_start:
# Multiboot2 정보 구조체는 EBX에 전달됨
mov esp, stack_top
push ebx # Multiboot 정보 포인터 저장
call rust_main # Rust 커널 진입점 호출
# 반환되면 안됨
cli
1: hlt
jmp 1b
.section .bss
.align 16
stack_bottom:
.skip 16384 # 16KB 스택
stack_top:
// Rust 커널에서 Multiboot 정보 읽기
#[repr(C)]
pub struct MultibootInfo {
total_size: u32,
reserved: u32,
}
#[repr(C)]
pub struct MultibootTag {
typ: u32,
size: u32,
}
pub fn parse_multiboot_info(addr: usize) {
let info = unsafe { &*(addr as *const MultibootInfo) };
serial_println!("Multiboot info size: {}", info.total_size);
let mut tag_addr = addr + 8; // 헤더 다음부터 시작
loop {
let tag = unsafe { &*(tag_addr as *const MultibootTag) };
match tag.typ {
0 => break, // 종료 태그
4 => { // 메모리 맵 태그
serial_println!("Found memory map");
// 메모리 맵 파싱...
}
6 => { // 메모리 정보 태그
serial_println!("Found memory info");
// 메모리 크기 읽기...
}
8 => { // 프레임버퍼 태그
serial_println!("Found framebuffer info");
// 프레임버퍼 정보 읽기...
}
_ => {}
}
// 다음 태그로 (8바이트 정렬)
tag_addr += ((tag.size + 7) & !7) as usize;
}
}
// Cargo.toml에서 bootloader 크레이트 설정
[dependencies]
bootloader = { version = "0.9", features = ["map_physical_memory"] }
[package.metadata.bootloader]
map-physical-memory = true
// 빌드 후 BIOS와 UEFI 이미지 생성
// cargo bootimage --target x86_64-my_os.json
설명
이것이 하는 일: Multiboot2 헤더는 부트로더에게 커널의 요구사항을 알려주고, 부트로더는 이에 따라 시스템을 설정한 후 커널로 제어권을 넘깁니다. 첫 번째로, Multiboot2 매직 넘버 0xE85250D6는 부트로더가 커널 바이너리를 스캔하여 찾는 고유 식별자입니다.
이 헤더는 커널의 첫 8192바이트 안에, 4바이트 정렬되어 있어야 합니다. 체크섬은 매직 넘버, 아키텍처, 헤더 길이를 더한 값의 2의 보수로, 이 네 필드를 모두 더하면 0이 되어야 합니다.
이는 헤더 무결성을 검증하는 간단한 방법입니다. 그 다음으로, 태그 기반 구조는 Multiboot2의 핵심입니다.
각 태그는 타입과 크기 필드를 가지며, 부트로더는 알지 못하는 타입을 무시할 수 있어 하위 호환성이 보장됩니다. 프레임버퍼 태그(타입 5)는 선호하는 비디오 모드를 요청하고, 부트로더는 가능하면 이를 설정합니다.
종료 태그(타입 0)는 헤더의 끝을 표시합니다. 마지막으로, 부트로더는 커널을 호출할 때 EBX 레지스터에 Multiboot 정보 구조체의 물리 주소를 전달합니다.
이 구조체도 태그 기반이며, 메모리 맵(타입 6), 부트 커맨드라인(타입 1), 프레임버퍼 정보(타입 8) 등을 포함합니다. 각 태그는 8바이트로 정렬되므로, 다음 태그로 이동할 때 (size + 7) & !7로 올림 계산을 해야 합니다.
bootloader 크레이트는 이 모든 복잡성을 처리하고, Rust 친화적인 BootInfo 구조체로 변환하여 전달합니다. 여러분이 이 코드를 사용하면 GRUB으로 부팅하거나, bootloader 크레이트의 자동 생성 부트로더로 부팅할 수 있습니다.
실무에서는 ISO 이미지를 생성하여 CD/DVD/USB에 구워 배포할 수 있으며, BIOS와 UEFI 시스템 모두에서 부팅됩니다.
실전 팁
💡 grub-mkrescue를 사용하면 커널 바이너리에서 직접 부팅 가능한 ISO 이미지를 생성할 수 있습니다: grub-mkrescue -o os.iso isofiles/
💡 Multiboot2 헤더가 제대로 링크되었는지 확인하려면 grub-file --is-x86-multiboot2 kernel.bin 명령어를 사용하세요
💡 bootloader 크레이트의 map_physical_memory 기능을 활성화하면, 모든 물리 메모리가 상위 반(higher half)에 매핑되어 편리하게 접근할 수 있습니다
💡 실제 하드웨어에서 테스트하려면 Ventoy 같은 멀티부팅 USB 도구를 사용하면, ISO를 USB에 그냥 복사하기만 하면 부팅됩니다
💡 UEFI Secure Boot 환경에서 테스트하려면 자체 서명 인증서를 생성하고 sbsign 도구로 EFI 파일에 서명해야 합니다