이미지 로딩 중...
AI Generated
2025. 11. 14. · 5 Views
Rust로 만드는 나만의 OS - Multiboot2 헤더 완벽 가이드
OS 개발의 첫 걸음인 Multiboot2 헤더를 Rust로 구현하는 방법을 배웁니다. 부트로더가 우리의 커널을 인식하고 로드할 수 있도록 하는 핵심 메커니즘을 실무 중심으로 다룹니다.
목차
- Multiboot2 헤더의 필요성과 역할
- 종료 태그와 헤더 구조 완성
- 프레임버퍼 요청 태그로 그래픽 모드 설정
- 모듈 정렬 태그로 initrd 로딩 제어
- 링커 스크립트로 헤더 배치 제어
- 진입점 함수와 스택 설정
- Rust 커널 메인 함수 구조
- Cargo 설정과 타겟 트리플
- 빌드 스크립트와 바이너리 생성
- QEMU를 이용한 디버깅과 테스트
1. Multiboot2 헤더의 필요성과 역할
시작하며
여러분이 Rust로 운영체제를 만들려고 할 때 이런 상황을 겪어본 적 있나요? 커널 바이너리를 만들었지만 부트로더가 이를 인식하지 못해서 아무것도 실행되지 않는 상황 말이죠.
이런 문제는 실제 OS 개발 초기 단계에서 거의 모든 개발자가 경험합니다. 부트로더와 커널 사이에는 명확한 통신 규약이 필요한데, 이것이 없으면 부트로더는 여러분의 커널이 유효한 OS 이미지인지조차 판단할 수 없습니다.
바로 이럴 때 필요한 것이 Multiboot2 헤더입니다. 이 헤더는 부트로더에게 "나는 부팅 가능한 OS 커널이며, 이런 방식으로 로드해주세요"라고 알려주는 메타데이터 역할을 합니다.
개요
간단히 말해서, Multiboot2 헤더는 커널 바이너리의 시작 부분에 위치하는 특별한 구조체로, 부트로더가 커널을 어떻게 로드하고 실행할지를 정의합니다. GRUB2 같은 부트로더는 디스크를 스캔하면서 Multiboot2 매직 넘버를 찾습니다.
이 헤더가 없으면 아무리 완벽한 커널 코드를 작성해도 부트로더는 이를 일반 데이터 파일로 취급합니다. 예를 들어, 멀티부팅 환경에서 여러 OS를 관리할 때 각 OS의 Multiboot2 헤더를 통해 적절한 부팅 파라미터를 전달받을 수 있습니다.
전통적인 Legacy BIOS 부팅에서는 단순히 첫 섹터를 로드했다면, 이제는 Multiboot2 스펙을 통해 메모리 맵 요청, 프레임버퍼 설정, 모듈 로딩 등 복잡한 요구사항을 부트로더에 전달할 수 있습니다. 핵심 특징으로는 첫째, 매직 넘버를 통한 유효성 검증, 둘째, 아키텍처 필드를 통한 플랫폼 지정(x86, ARM 등), 셋째, 태그 기반의 확장 가능한 정보 전달 방식이 있습니다.
이러한 특징들은 현대적인 OS 부팅 과정에서 유연성과 호환성을 동시에 제공하기 때문에 중요합니다.
코드 예제
// Multiboot2 헤더의 시작을 알리는 매직 넘버
const MULTIBOOT2_MAGIC: u32 = 0xe85250d6;
// i386 보호 모드를 나타내는 아키�ecture 상수
const MULTIBOOT2_ARCHITECTURE: u32 = 0;
#[repr(C, align(8))]
struct Multiboot2Header {
magic: u32, // 부트로더가 찾는 매직 넘버
architecture: u32, // 타겟 아키텍처 (0 = i386)
header_length: u32, // 전체 헤더 길이
checksum: u32, // 검증용 체크섬
}
// 헤더를 .multiboot2 섹션에 배치
#[link_section = ".multiboot2"]
#[no_mangle]
pub static MULTIBOOT2_HDR: Multiboot2Header = Multiboot2Header {
magic: MULTIBOOT2_MAGIC,
architecture: MULTIBOOT2_ARCHITECTURE,
header_length: core::mem::size_of::<Multiboot2Header>() as u32,
checksum: 0u32.wrapping_sub(MULTIBOOT2_MAGIC + MULTIBOOT2_ARCHITECTURE
+ core::mem::size_of::<Multiboot2Header>() as u32),
};
설명
이것이 하는 일: Multiboot2 헤더는 커널 바이너리의 가장 앞부분에 배치되어 부트로더에게 "이 파일은 부팅 가능한 OS이며, 나를 이렇게 처리해주세요"라고 알려주는 메타데이터입니다. 첫 번째로, 매직 넘버(0xe85250d6)는 Multiboot2 스펙에 정의된 고정값으로, GRUB2 같은 부트로더가 디스크를 스캔할 때 이 값을 찾습니다.
이는 마치 파일의 확장자처럼 커널의 정체성을 나타내는 식별자 역할을 합니다. 왜 이렇게 하는지 궁금할 수 있는데, 부트로더는 수많은 파일 중에서 실행 가능한 커널만 빠르게 찾아야 하기 때문입니다.
그 다음으로, 아키텍처 필드가 실행되면서 이 커널이 어떤 CPU 아키텍처용인지를 명시합니다. 0은 i386(32비트 x86)을 의미하며, 부트로더는 이 정보를 바탕으로 적절한 보호 모드로 CPU를 설정합니다.
내부적으로 부트로더는 이 값을 확인해서 잘못된 아키텍처의 커널을 로드하는 것을 방지합니다. 마지막으로, 체크섬 계산이 수행되어 헤더의 무결성을 검증합니다.
체크섬은 매직 넘버, 아키텍처, 헤더 길이를 모두 더한 값의 2의 보수로 계산되며, 최종적으로 모든 필드를 더하면 0이 되도록 설계되었습니다. 이를 통해 메모리 손상이나 전송 오류를 감지할 수 있습니다.
여러분이 이 코드를 사용하면 GRUB2가 여러분의 커널을 자동으로 인식하고, 메모리에 로드한 후 제어권을 넘겨주는 완전한 부팅 프로세스를 구현할 수 있습니다. 실무에서의 이점으로는 첫째, 표준화된 부팅 인터페이스 제공, 둘째, 다양한 부트로더와의 호환성, 셋째, 안전한 부팅 프로세스 보장이 있습니다.
실전 팁
💡 헤더는 반드시 커널 바이너리의 처음 8KB 안에 위치해야 합니다. 링커 스크립트에서 .multiboot2 섹션을 가장 앞에 배치하지 않으면 부트로더가 찾지 못합니다.
💡 체크섬 계산 시 wrapping_sub를 사용하는 이유는 오버플로우를 허용하기 위함입니다. 일반 뺄셈을 사용하면 패닉이 발생할 수 있습니다.
💡 #[repr(C, align(8))]은 필수입니다. Multiboot2 스펙은 헤더가 8바이트 정렬되어야 한다고 명시하며, C 레이아웃을 사용해야 부트로더가 올바르게 파싱할 수 있습니다.
💡 QEMU로 테스트할 때 -kernel 옵션을 사용하면 Multiboot2 헤더 없이도 로드되므로, 실제 검증은 GRUB2를 통한 ISO 부팅으로 해야 합니다.
💡 매직 넘버를 잘못 입력하는 것은 가장 흔한 실수입니다. 0xe85250d6를 정확히 사용하고, Multiboot1의 0x1BADB002와 혼동하지 마세요.
2. 종료 태그와 헤더 구조 완성
시작하며
여러분이 Multiboot2 헤더를 작성하다가 이런 오류를 본 적 있나요? "Invalid Multiboot2 header: missing end tag" 같은 메시지 말이죠.
이런 문제는 Multiboot2 스펙의 태그 기반 구조를 제대로 이해하지 못했을 때 발생합니다. 단순히 헤더 구조체만 있다고 끝이 아니라, 부트로더에게 "여기서 헤더가 끝났습니다"라고 명확히 알려줘야 합니다.
바로 이럴 때 필요한 것이 종료 태그입니다. 이 태그는 헤더의 끝을 표시하고, 부트로더가 불필요한 메모리 영역을 읽지 않도록 합니다.
개요
간단히 말해서, 종료 태그는 Multiboot2 헤더의 마지막에 위치하는 특수 태그로, 타입 0과 크기 8을 가지며 헤더 파싱의 종료점을 나타냅니다. Multiboot2는 확장 가능한 태그 시스템을 사용하는데, 부트로더는 태그를 순차적으로 읽다가 타입 0을 만나면 파싱을 중단합니다.
예를 들어, 프레임버퍼 요청 태그, 메모리 맵 요청 태그 등 다양한 태그를 헤더와 종료 태그 사이에 추가할 수 있으며, 종료 태그가 이들의 경계를 명확히 합니다. 전통적인 고정 크기 헤더 방식에서는 구조체 크기만 알면 됐다면, 이제는 가변 길이 태그 시스템을 통해 필요한 기능만 선택적으로 요청할 수 있습니다.
핵심 특징으로는 첫째, 타입 0이라는 예약된 값 사용, 둘째, 8바이트 고정 크기, 셋째, 반드시 8바이트 정렬이 있습니다. 이러한 특징들은 부트로더의 파서가 효율적으로 동작하고 메모리 오버런을 방지하기 때문에 중요합니다.
코드 예제
// 종료 태그 구조체 정의
#[repr(C, align(8))]
struct Multiboot2EndTag {
tag_type: u16, // 0 = 종료 태그
flags: u16, // 플래그 (보통 0)
size: u32, // 태그 크기 (8바이트)
}
// 완전한 Multiboot2 헤더 + 종료 태그
#[repr(C, align(8))]
struct CompleteMultiboot2Header {
magic: u32,
architecture: u32,
header_length: u32,
checksum: u32,
// 여기에 다른 태그들이 올 수 있음
end_tag: Multiboot2EndTag,
}
#[link_section = ".multiboot2"]
#[no_mangle]
pub static MULTIBOOT2_HDR: CompleteMultiboot2Header = {
const HEADER_LEN: u32 = core::mem::size_of::<CompleteMultiboot2Header>() as u32;
CompleteMultiboot2Header {
magic: 0xe85250d6,
architecture: 0,
header_length: HEADER_LEN,
checksum: 0u32.wrapping_sub(0xe85250d6 + 0 + HEADER_LEN),
end_tag: Multiboot2EndTag {
tag_type: 0,
flags: 0,
size: 8,
},
}
};
설명
이것이 하는 일: 종료 태그는 Multiboot2 헤더의 태그 체인에서 마지막 요소로 작동하며, 부트로더에게 "더 이상 읽을 태그가 없습니다"라고 알려주는 센티널 값입니다. 첫 번째로, tag_type 필드를 0으로 설정하는 것은 Multiboot2 스펙에서 종료를 나타내는 예약된 값입니다.
부트로더는 태그를 순회하면서 타입을 확인하는데, 0을 만나는 순간 파싱 루프를 빠져나옵니다. 왜 이렇게 하는지는 가변 길이 데이터 구조에서 종료 조건을 명확히 하기 위함입니다.
그 다음으로, size 필드가 8로 설정되면서 이 태그 자체의 크기를 나타냅니다. 부트로더는 각 태그의 크기를 읽어서 다음 태그로 점프하는데, 종료 태그는 다음이 없으므로 자신의 크기만 정확히 표시하면 됩니다.
내부적으로 부트로더는 이 크기 정보로 전체 헤더 영역을 계산하고 메모리를 관리합니다. 마지막으로, 8바이트 정렬이 보장되어 모든 태그가 정렬된 메모리 주소에 위치하게 됩니다.
이는 CPU의 메모리 접근 효율성을 높이고, 일부 아키텍처에서 발생할 수 있는 정렬되지 않은 접근 예외를 방지합니다. 여러분이 이 코드를 사용하면 부트로더가 헤더를 완전히 파싱하고, 정확히 필요한 만큼만 메모리를 읽어서 부팅 과정을 안전하게 진행할 수 있습니다.
실무에서의 이점으로는 첫째, 메모리 오버런 방지, 둘째, 확장 가능한 태그 시스템 지원, 셋째, 부트로더와의 명확한 계약이 있습니다.
실전 팁
💡 종료 태그를 빠뜨리면 부트로더가 무한 루프에 빠지거나 잘못된 메모리를 읽어서 크래시할 수 있습니다. 항상 마지막에 종료 태그를 추가하세요.
💡 여러 태그를 추가할 때는 각 태그 사이의 패딩을 신경 써야 합니다. Multiboot2는 모든 태그가 8바이트 정렬되어야 한다고 요구합니다.
💡 header_length 계산 시 전체 구조체 크기를 포함해야 합니다. 종료 태그를 포함하지 않으면 체크섬이 틀려집니다.
💡 Rust의 const fn을 활용하면 컴파일 타임에 체크섬을 계산할 수 있어 런타임 오버헤드가 없습니다.
💡 디버깅 시 hexdump로 바이너리를 확인하면 헤더가 올바른 위치에 있는지, 매직 넘버가 정확한지 바로 알 수 있습니다.
3. 프레임버퍼 요청 태그로 그래픽 모드 설정
시작하며
여러분이 OS를 부팅했을 때 단순한 텍스트 모드가 아닌 멋진 그래픽 화면을 보고 싶었던 적 있나요? 콘솔에 픽셀 단위로 직접 그림을 그리거나 GUI를 만들고 싶었던 경험 말이죠.
이런 욕구는 모든 OS 개발자가 느끼는 자연스러운 것입니다. 하지만 부트로더는 기본적으로 VGA 텍스트 모드로 설정하기 때문에, 그래픽 모드를 원한다면 명시적으로 요청해야 합니다.
바로 이럴 때 필요한 것이 프레임버퍼 요청 태그입니다. 이 태그를 통해 부트로더에게 원하는 해상도, 색상 깊이를 알려주고 프레임버퍼 주소를 받을 수 있습니다.
개요
간단히 말해서, 프레임버퍼 요청 태그는 Multiboot2 헤더에 추가하는 선택적 태그로, 부트로더에게 그래픽 모드 설정을 요청하고 프레임버퍼 정보를 받아옵니다. 부트로더는 이 태그를 읽고 BIOS/UEFI를 통해 그래픽 모드로 전환한 뒤, 프레임버퍼의 물리 주소와 메타데이터를 커널에 전달합니다.
예를 들어, 1024x768 해상도에 32비트 색상을 원한다면 이 태그에 해당 값을 명시하고, 부트로더가 가능하면 정확히 그 모드로 설정하고 그렇지 않으면 가장 가까운 모드를 선택합니다. 전통적인 VGA 텍스트 모드(80x25 문자)에서는 제한적인 출력만 가능했다면, 이제는 프레임버퍼를 통해 픽셀 단위로 완전한 제어가 가능합니다.
핵심 특징으로는 첫째, 선호하는 해상도와 색상 깊이 지정 가능, 둘째, 부트로더가 하드웨어와 협상하여 최적 모드 선택, 셋째, 커널에 프레임버퍼 물리 주소 전달이 있습니다. 이러한 특징들은 현대적인 GUI OS를 만들기 위한 기초를 제공하기 때문에 중요합니다.
코드 예제
// 프레임버퍼 요청 태그 구조체
#[repr(C, align(8))]
struct Multiboot2FramebufferTag {
tag_type: u16, // 5 = 프레임버퍼 요청
flags: u16, // 0 = 선택적 요청
size: u32, // 태그 크기 (20바이트)
width: u32, // 선호 너비 (0 = 상관없음)
height: u32, // 선호 높이 (0 = 상관없음)
depth: u32, // 선호 색상 깊이 (0 = 상관없음)
}
#[repr(C, align(8))]
struct Multiboot2HeaderWithFramebuffer {
magic: u32,
architecture: u32,
header_length: u32,
checksum: u32,
// 프레임버퍼 요청 태그
framebuffer_tag: Multiboot2FramebufferTag,
// 종료 태그
end_tag: Multiboot2EndTag,
}
#[link_section = ".multiboot2"]
#[no_mangle]
pub static MULTIBOOT2_HDR: Multiboot2HeaderWithFramebuffer = {
const HEADER_LEN: u32 = core::mem::size_of::<Multiboot2HeaderWithFramebuffer>() as u32;
Multiboot2HeaderWithFramebuffer {
magic: 0xe85250d6,
architecture: 0,
header_length: HEADER_LEN,
checksum: 0u32.wrapping_sub(0xe85250d6 + 0 + HEADER_LEN),
framebuffer_tag: Multiboot2FramebufferTag {
tag_type: 5,
flags: 0,
size: 20,
width: 1024, // 1024x768 해상도 선호
height: 768,
depth: 32, // 32비트 색상 (RGBA)
},
end_tag: Multiboot2EndTag {
tag_type: 0,
flags: 0,
size: 8,
},
}
};
설명
이것이 하는 일: 프레임버퍼 요청 태그는 부트로더에게 "텍스트 모드 말고 이런 그래픽 모드로 설정해주세요"라고 요청하고, 부트로더는 하드웨어를 설정한 후 프레임버퍼 정보를 커널에 전달합니다. 첫 번째로, tag_type을 5로 설정하는 것은 Multiboot2 스펙에서 프레임버퍼 요청을 나타내는 정의된 값입니다.
부트로더는 이 타입을 보고 그래픽 모드 설정 로직을 실행합니다. 왜 이렇게 하는지는 태그 기반 시스템에서 각 기능을 식별하기 위한 표준 방법이기 때문입니다.
그 다음으로, width, height, depth 필드가 설정되면서 선호하는 비디오 모드를 지정합니다. 1024x768x32는 많은 하드웨어에서 지원하는 안정적인 모드이며, 부트로더는 VESA BIOS Extensions나 UEFI Graphics Output Protocol을 통해 이 모드로 전환을 시도합니다.
내부적으로 부트로더는 사용 가능한 모드 리스트를 쿼리하고 가장 가까운 것을 선택합니다. 마지막으로, flags 필드를 0으로 설정하여 이 요청이 선택적임을 나타냅니다.
즉, 만약 그래픽 모드 설정에 실패해도 부팅은 계속되며, 커널은 부트로더가 전달한 정보를 확인해서 텍스트 모드인지 그래픽 모드인지 판단해야 합니다. 여러분이 이 코드를 사용하면 OS 부팅 시 자동으로 그래픽 모드로 전환되고, 커널은 프레임버퍼에 직접 픽셀을 쓸 수 있어 GUI 개발의 기초를 마련할 수 있습니다.
실무에서의 이점으로는 첫째, 픽셀 단위 완전 제어, 둘째, 다양한 해상도 지원, 셋째, 부트로더가 하드웨어 호환성 처리를 대신해준다는 점이 있습니다.
실전 팁
💡 width, height, depth를 모두 0으로 설정하면 부트로더가 기본값을 선택합니다. 특정 해상도가 필요 없다면 0을 사용하세요.
💡 flags를 1로 설정하면 필수 요청이 되어, 실패 시 부팅이 중단됩니다. 일반적으로 0(선택적)을 사용하는 것이 안전합니다.
💡 실제 프레임버퍼 주소는 부트로더가 부팅 정보 구조체(Multiboot2 Info)를 통해 커널에 전달합니다. 커널 초기화 시 이 정보를 파싱해야 합니다.
💡 QEMU에서는 -vga std 옵션을 사용하면 VESA 호환 그래픽 카드를 에뮬레이션하여 프레임버퍼 모드를 테스트할 수 있습니다.
💡 깊이 32비트는 RGBA 형식으로 각 픽셀이 4바이트이며, 24비트는 RGB만 있고 알파 채널이 없습니다. GUI에서는 보통 32비트를 사용합니다.
4. 모듈 정렬 태그로 initrd 로딩 제어
시작하며
여러분이 OS에 초기 램디스크(initrd)나 추가 드라이버를 로드하려고 할 때 이런 문제를 겪어본 적 있나요? 부트로더가 모듈을 임의의 메모리 위치에 배치해서 페이지 경계를 넘어가는 상황 말이죠.
이런 문제는 메모리 관리를 시작하는 초기 단계에서 특히 골치 아픕니다. 페이지 정렬되지 않은 모듈은 페이지 테이블 매핑이 복잡해지고, 경우에 따라서는 추가 메모리 복사가 필요할 수 있습니다.
바로 이럴 때 필요한 것이 모듈 정렬 태그입니다. 이 태그를 통해 부트로더에게 "모든 모듈을 페이지 경계에 정렬해서 로드해주세요"라고 요청할 수 있습니다.
개요
간단히 말해서, 모듈 정렬 태그는 부트로더가 로드하는 모든 모듈(initrd, 드라이버 등)을 특정 바이트 경계에 정렬하도록 요청하는 태그입니다. 대부분의 OS는 4KB 페이지를 사용하므로, 모듈이 4KB 경계에 정렬되면 페이지 테이블 항목 하나로 깔끔하게 매핑할 수 있습니다.
예를 들어, initrd가 0x100001 주소에 로드되면 두 개의 페이지에 걸쳐지지만, 0x101000에 로드되면 하나의 연속된 페이지 범위로 처리할 수 있습니다. 전통적인 방법에서는 부트로더가 모듈을 임의 위치에 배치했다면, 이제는 명시적으로 정렬을 요청하여 메모리 관리를 단순화할 수 있습니다.
핵심 특징으로는 첫째, 페이지 경계 정렬로 메모리 매핑 단순화, 둘째, DMA 등 하드웨어 요구사항 충족, 셋째, 메모리 낭비 최소화를 위한 유연한 정렬 크기 설정이 있습니다. 이러한 특징들은 효율적인 메모리 관리와 성능 최적화에 직접적으로 기여하기 때문에 중요합니다.
코드 예제
// 모듈 정렬 태그 구조체
#[repr(C, align(8))]
struct Multiboot2ModuleAlignTag {
tag_type: u16, // 6 = 모듈 정렬 요청
flags: u16, // 0 = 선택적
size: u32, // 태그 크기 (8바이트)
}
#[repr(C, align(8))]
struct Multiboot2HeaderComplete {
magic: u32,
architecture: u32,
header_length: u32,
checksum: u32,
// 프레임버퍼 요청
framebuffer_tag: Multiboot2FramebufferTag,
// 모듈 정렬 요청
module_align_tag: Multiboot2ModuleAlignTag,
// 종료 태그
end_tag: Multiboot2EndTag,
}
#[link_section = ".multiboot2"]
#[no_mangle]
pub static MULTIBOOT2_HDR: Multiboot2HeaderComplete = {
const HEADER_LEN: u32 = core::mem::size_of::<Multiboot2HeaderComplete>() as u32;
Multiboot2HeaderComplete {
magic: 0xe85250d6,
architecture: 0,
header_length: HEADER_LEN,
checksum: 0u32.wrapping_sub(0xe85250d6 + 0 + HEADER_LEN),
framebuffer_tag: Multiboot2FramebufferTag {
tag_type: 5, flags: 0, size: 20,
width: 1024, height: 768, depth: 32,
},
module_align_tag: Multiboot2ModuleAlignTag {
tag_type: 6, // 모듈 정렬 요청
flags: 0, // 선택적
size: 8, // 태그 크기
},
end_tag: Multiboot2EndTag {
tag_type: 0, flags: 0, size: 8,
},
}
};
설명
이것이 하는 일: 모듈 정렬 태그는 부트로더에게 "initrd나 다른 모듈들을 로드할 때 페이지 경계(보통 4KB)에 맞춰서 배치해주세요"라고 요청하는 간단하지만 중요한 태그입니다. 첫 번째로, tag_type을 6으로 설정하는 것은 Multiboot2 스펙에서 모듈 정렬 요청을 나타냅니다.
부트로더는 이 태그를 발견하면 모듈을 로드할 때 자동으로 페이지 경계에 맞춰 배치합니다. 왜 이렇게 하는지는 커널의 메모리 관리자가 정렬된 주소를 훨씬 효율적으로 처리할 수 있기 때문입니다.
그 다음으로, 부트로더가 이 요청을 처리하면서 각 모듈의 시작 주소를 4KB의 배수로 만듭니다. 예를 들어 첫 번째 모듈이 0x100000에서 0x105FFF까지 차지한다면, 다음 모듈은 0x105FFF 다음이 아니라 0x106000(다음 4KB 경계)에 배치됩니다.
내부적으로 이는 약간의 메모리 낭비를 유발할 수 있지만, 메모리 관리의 복잡성 감소라는 이점이 훨씬 큽니다. 마지막으로, 커널은 부트로더가 전달한 모듈 정보를 파싱할 때 정렬된 주소를 신뢰하고 직접 페이지 테이블에 매핑할 수 있습니다.
정렬되지 않은 경우 추가 메모리 복사나 복잡한 매핑 로직이 필요했을 것입니다. 여러분이 이 코드를 사용하면 initrd나 추가 커널 모듈을 로드할 때 메모리 관리가 단순해지고, 페이지 테이블 설정이 직관적이 되며, 잠재적인 정렬 오류를 방지할 수 있습니다.
실무에서의 이점으로는 첫째, 페이지 매핑 단순화, 둘째, DMA 버퍼 등 하드웨어 요구사항 충족, 셋째, 메모리 관리 코드의 복잡도 감소가 있습니다.
실전 팁
💡 모듈 정렬 태그는 매개변수가 없으며, 정렬 크기는 항상 4KB입니다. 다른 정렬이 필요하면 커널에서 수동으로 처리해야 합니다.
💡 GRUB2에서 모듈을 로드하려면 grub.cfg에 "module2 /path/to/initrd" 같은 명령을 추가해야 하며, 이 태그가 있으면 자동으로 정렬됩니다.
💡 모듈 정렬로 인한 메모리 낭비는 보통 무시할 수준입니다. 각 모듈 사이에 최대 4KB의 갭이 생길 수 있지만, 메모리 관리 편의성이 더 중요합니다.
💡 부트로더 정보 구조체의 모듈 태그를 파싱하면 각 모듈의 시작 주소, 끝 주소, 커맨드 라인을 얻을 수 있습니다. 이 주소들이 4KB 정렬되어 있을 것입니다.
💡 ELF 섹션 헤더 태그와 함께 사용하면 더욱 정교한 모듈 로딩 전략을 구현할 수 있습니다.
5. 링커 스크립트로 헤더 배치 제어
시작하며
여러분이 완벽한 Multiboot2 헤더를 작성했는데도 부트로더가 인식하지 못하는 상황을 겪어본 적 있나요? 코드는 맞는데 바이너리에서 헤더가 엉뚱한 위치에 있는 경우 말이죠.
이런 문제는 Rust 컴파일러와 링커가 기본적으로 섹션 배치를 최적화하면서 .multiboot2 섹션을 뒤로 밀어버릴 때 발생합니다. Multiboot2 스펙은 헤더가 처음 8KB 안에 있어야 한다고 명시하므로, 이를 보장하지 않으면 부팅에 실패합니다.
바로 이럴 때 필요한 것이 커스텀 링커 스크립트입니다. 링커 스크립트를 통해 .multiboot2 섹션을 바이너리의 맨 앞에 강제로 배치할 수 있습니다.
개요
간단히 말해서, 링커 스크립트는 링커에게 각 섹션을 메모리의 어디에 배치할지 정확히 지시하는 설정 파일로, OS 개발에서 메모리 레이아웃을 완전히 제어하기 위해 필수적입니다. 일반 애플리케이션 개발에서는 링커의 기본 동작에 맡기면 되지만, OS 커널은 특정 주소에 로드되고 특정 순서로 섹션이 배치되어야 합니다.
예를 들어, Multiboot2 헤더는 반드시 맨 앞에, 코드 섹션은 실행 가능한 영역에, 데이터 섹션은 쓰기 가능한 영역에 배치되어야 합니다. 전통적인 링커 동작에서는 컴파일러가 결정한 순서대로 섹션이 배치됐다면, 이제는 명시적인 링커 스크립트를 통해 완벽한 제어가 가능합니다.
핵심 특징으로는 첫째, 섹션의 정확한 메모리 주소 지정, 둘째, 섹션 간의 순서 제어, 셋째, 정렬 요구사항 강제가 있습니다. 이러한 특징들은 부트로더가 커널을 올바르게 인식하고 로드하는 데 절대적으로 중요합니다.
코드 예제
/* linker.ld - Multiboot2 커널용 링커 스크립트 */
OUTPUT_FORMAT(elf32-i386) /* 32비트 ELF 형식 */
OUTPUT_ARCH(i386) /* x86 아키텍처 */
ENTRY(_start) /* 진입점 심볼 */
SECTIONS
{
/* 커널을 1MB 위치에 로드 (일반적인 관례) */
. = 1M;
/* Multiboot2 헤더를 가장 먼저 배치 */
.multiboot2 : ALIGN(8)
{
KEEP(*(.multiboot2)) /* 최적화로 제거되지 않도록 보호 */
}
/* 코드 섹션 */
.text : ALIGN(4K)
{
*(.text .text.*)
}
/* 읽기 전용 데이터 */
.rodata : ALIGN(4K)
{
*(.rodata .rodata.*)
}
/* 초기화된 데이터 */
.data : ALIGN(4K)
{
*(.data .data.*)
}
/* 초기화되지 않은 데이터 */
.bss : ALIGN(4K)
{
*(COMMON)
*(.bss .bss.*)
}
}
설명
이것이 하는 일: 링커 스크립트는 컴파일된 오브젝트 파일들을 최종 실행 파일로 합칠 때 각 섹션의 정확한 위치와 순서를 지시하는 청사진입니다. 첫 번째로, `.
= 1M` 구문은 현재 위치 카운터를 1MB(0x100000)로 설정합니다. 이는 대부분의 부트로더가 커널을 1MB 주소에 로드하는 관례를 따르는 것이며, 낮은 메모리 영역(0-1MB)은 BIOS와 하드웨어가 사용하기 때문입니다.
왜 이렇게 하는지는 메모리 맵의 안전한 영역을 사용하기 위함입니다. 그 다음으로, .multiboot2 : ALIGN(8) 섹션 정의가 실행되면서 이 섹션을 8바이트 경계에 정렬하고 맨 먼저 배치합니다.
KEEP() 명령은 링커의 데드 코드 제거 최적화에서 이 섹션을 보호하는데, 이는 코드에서 직접 참조되지 않더라도 부트로더가 필요로 하기 때문입니다. 내부적으로 링커는 이 지시에 따라 바이너리 오프셋 0에 해당 섹션을 배치합니다.
마지막으로, 나머지 섹션들(.text, .rodata, .data, .bss)이 4KB 정렬로 순차적으로 배치됩니다. 각 섹션의 ALIGN(4K)는 페이지 테이블 설정을 단순화하며, 코드는 실행 권한, 데이터는 쓰기 권한을 부여할 수 있도록 섹션을 분리합니다.
여러분이 이 스크립트를 사용하면 Multiboot2 헤더가 항상 바이너리의 처음에 위치하고, 부트로더가 100% 확률로 찾을 수 있으며, 메모리 레이아웃을 완전히 제어할 수 있습니다. 실무에서의 이점으로는 첫째, 예측 가능한 메모리 레이아웃, 둘째, 페이지 단위 권한 설정 용이, 셋째, 부팅 성공률 향상이 있습니다.
실전 팁
💡 링커 스크립트를 사용하려면 .cargo/config.toml에 rustflags = ["-C", "link-arg=-T/path/to/linker.ld"]를 추가해야 합니다.
💡 KEEP() 명령을 빠뜨리면 LTO(Link Time Optimization) 활성화 시 .multiboot2 섹션이 제거될 수 있습니다. 항상 KEEP()을 사용하세요.
💡 1MB 주소는 관례일 뿐이며, 필요하면 다른 주소를 사용할 수 있습니다. 하지만 낮은 메모리(0-1MB)는 피하세요.
💡 objdump -h your_kernel.elf 명령으로 각 섹션의 실제 주소와 오프셋을 확인할 수 있습니다. .multiboot2가 맨 앞에 있는지 확인하세요.
💡 ELF 파일을 바이너리로 변환할 때(objcopy -O binary) 섹션 순서가 그대로 유지되므로, 링커 스크립트가 더욱 중요합니다.
6. 진입점 함수와 스택 설정
시작하며
여러분이 Multiboot2 헤더를 완벽하게 만들었는데 부트로더가 커널을 로드한 후 크래시하는 상황을 겪어본 적 있나요? 부트로더가 제어권을 넘겼지만 커널이 제대로 실행되지 않는 경우 말이죠.
이런 문제는 진입점 함수가 없거나 스택이 설정되지 않았을 때 발생합니다. 부트로더는 커널의 ELF 진입점으로 점프하는데, 그곳에 유효한 코드가 없거나 스택이 없으면 즉시 예외가 발생합니다.
바로 이럴 때 필요한 것이 적절한 진입점 함수와 부트스트랩 스택입니다. 어셈블리나 Rust로 최소한의 초기화를 수행하고 안전하게 커널 메인 함수로 진입해야 합니다.
개요
간단히 말해서, 진입점 함수는 부트로더가 제어권을 넘기는 첫 번째 함수로, 스택을 설정하고 CPU 상태를 확인한 후 Rust 커널 코드를 호출합니다. 부트로더가 커널로 점프하면 CPU는 32비트 보호 모드 상태이지만 스택 포인터는 미정의 상태입니다.
스택 없이는 함수 호출이나 지역 변수 사용이 불가능하므로, 첫 번째 할 일은 스택 포인터를 유효한 메모리 영역으로 설정하는 것입니다. 예를 들어, 16KB 스택 공간을 BSS 섹션에 예약하고 ESP 레지스터를 그 끝으로 설정하면 안전하게 C/Rust 코드를 실행할 수 있습니다.
전통적인 C 프로그램에서는 OS가 스택을 설정해줬다면, 이제는 우리가 OS이므로 직접 모든 것을 설정해야 합니다. 핵심 특징으로는 첫째, 최소한의 어셈블리로 스택 설정, 둘째, Multiboot2 정보 구조체 주소 보존, 셋째, Rust 런타임 초기화 준비가 있습니다.
이러한 특징들은 안전한 커널 실행 환경을 구축하는 기초이기 때문에 중요합니다.
코드 예제
# boot.s - 진입점 어셈블리 코드
.section .bss
.align 16
stack_bottom:
.skip 16384 # 16KB 스택 공간 예약
stack_top:
.section .text
.global _start
.type _start, @function
_start:
# 스택 포인터를 설정 (스택은 아래로 자라므로 top을 가리킴)
mov $stack_top, %esp
# EBX 레지스터에 부트로더가 전달한 Multiboot2 정보 주소가 있음
# 이를 첫 번째 인자로 Rust 함수에 전달
push %ebx
# 프레임 포인터 초기화 (디버깅 용이)
mov $0, %ebp
# Rust 커널 메인 함수 호출
call kernel_main
# kernel_main에서 리턴하면 안 되지만, 만약 리턴하면 halt
cli # 인터럽트 비활성화
.hang:
hlt # CPU 정지
jmp .hang # 무한 루프
설명
이것이 하는 일: 진입점 함수는 하드웨어에서 소프트웨어로의 전환점으로, 부트로더가 남겨놓은 최소한의 환경에서 완전한 Rust 실행 환경을 구축합니다. 첫 번째로, BSS 섹션에 16KB의 스택 공간을 예약하는 것은 초기 커널 코드가 실행될 최소한의 스택을 제공합니다.
.skip 16384 지시어는 링커에게 16KB를 0으로 초기화하도록 지시하며, stack_top 심볼은 이 영역의 끝 주소를 가리킵니다. 왜 이렇게 하는지는 스택이 높은 주소에서 낮은 주소로 자라기 때문에 끝 주소가 초기 스택 포인터가 되기 때문입니다.
그 다음으로, mov $stack_top, %esp 명령이 실행되면서 ESP 레지스터(스택 포인터)가 유효한 메모리를 가리키게 됩니다. 이 순간부터 push, pop, call, ret 같은 스택 기반 명령을 안전하게 사용할 수 있습니다.
내부적으로 함수 호출 시 리턴 주소와 매개변수가 스택에 저장되므로, 스택이 없으면 커널이 즉시 크래시합니다. 마지막으로, EBX 레지스터의 값을 스택에 푸시하고 kernel_main을 호출합니다.
Multiboot2 스펙에 따르면 부트로더는 EBX에 부트 정보 구조체의 물리 주소를 전달하는데, 이 정보에는 메모리 맵, 프레임버퍼 주소, 로드된 모듈 정보 등이 포함됩니다. Rust 함수는 System V ABI에 따라 첫 번째 인자를 스택에서 읽으므로 이를 보존해야 합니다.
여러분이 이 코드를 사용하면 부트로더에서 안전하게 Rust 코드로 전환되고, 메모리 맵과 하드웨어 정보를 받아서 본격적인 커널 초기화를 시작할 수 있습니다. 실무에서의 이점으로는 첫째, 예측 가능한 스택 환경, 둘째, Multiboot2 정보 접근 가능, 셋째, 안전한 Rust 코드 실행이 있습니다.
실전 팁
💡 스택 크기 16KB는 초기 부팅에 충분하지만, 커널이 복잡해지면 나중에 더 큰 스택으로 교체해야 합니다.
💡 EBP를 0으로 설정하는 것은 백트레이스의 끝을 표시하기 위함입니다. 디버거가 스택을 추적할 때 이 값을 보고 멈춥니다.
💡 cli 명령으로 인터럽트를 끄는 이유는 아직 IDT(Interrupt Descriptor Table)를 설정하지 않았기 때문입니다. 인터럽트가 발생하면 트리플 폴트가 일어납니다.
💡 Rust에서 kernel_main 함수는 #[no_mangle] extern "C"로 선언해야 어셈블리에서 호출할 수 있습니다.
💡 멀티코어 환경에서는 각 코어가 자신의 스택이 필요하므로, 부트스트랩 프로세서(BSP)만 이 스택을 사용하고 나중에 각 코어에 스택을 할당해야 합니다.
7. Rust 커널 메인 함수 구조
시작하며
여러분이 어셈블리 진입점에서 Rust 함수를 호출했는데 링커 오류가 발생하거나 실행 시 이상한 동작을 보는 상황을 겪어본 적 있나요? "undefined reference to __rust_eh_personality" 같은 오류 말이죠.
이런 문제는 일반 Rust 프로그램과 달리 베어메탈 환경에서는 표준 라이브러리를 사용할 수 없기 때문에 발생합니다. 패닉 핸들러, 언와인딩, 메모리 할당자 등 모든 런타임 기능을 직접 제공해야 합니다.
바로 이럴 때 필요한 것이 #![no_std]와 필수 런타임 함수들입니다. 표준 라이브러리 없이 실행되는 최소한의 Rust 환경을 구축해야 합니다.
개요
간단히 말해서, 베어메탈 Rust 커널은 #![no_std] 속성으로 표준 라이브러리를 비활성화하고, core 라이브러리만 사용하며, 패닉 핸들러와 같은 필수 함수를 직접 구현해야 합니다. 표준 라이브러리는 운영체제의 시스템 콜에 의존하는데, 우리가 만드는 것이 운영체제 자체이므로 이런 의존성이 있을 수 없습니다.
예를 들어, std::vec::Vec은 힙 할당을 위해 malloc을 호출하지만, 베어메탈에서는 malloc이 없으므로 대신 alloc::vec::Vec과 커스텀 글로벌 할당자를 사용해야 합니다. 전통적인 애플리케이션 개발에서는 std가 모든 것을 제공했다면, 이제는 core와 alloc만 사용하고 나머지는 직접 구현해야 합니다.
핵심 특징으로는 첫째, #![no_std]로 표준 라이브러리 제거, 둘째, #![no_main]으로 기본 main 진입점 비활성화, 셋째, 패닉 핸들러 직접 구현이 있습니다. 이러한 특징들은 베어메탈 환경에서 Rust를 실행하기 위한 필수 조건이기 때문에 중요합니다.
코드 예제
// main.rs - 베어메탈 Rust 커널
#![no_std] // 표준 라이브러리 비활성화
#![no_main] // 기본 main 함수 비활성화
use core::panic::PanicInfo;
// 어셈블리에서 호출할 커널 메인 함수
#[no_mangle] // 이름 맹글링 방지
pub extern "C" fn kernel_main(multiboot_info_addr: usize) -> ! {
// VGA 텍스트 버퍼에 "Hello, OS!" 출력
let vga_buffer = 0xb8000 as *mut u8;
unsafe {
*vga_buffer.offset(0) = b'H';
*vga_buffer.offset(1) = 0x0f; // 흰색 글자
*vga_buffer.offset(2) = b'i';
*vga_buffer.offset(3) = 0x0f;
}
// Multiboot2 정보 파싱은 나중에 구현
// let boot_info = unsafe { multiboot2::load(multiboot_info_addr) };
// 무한 루프 (OS는 종료되지 않음)
loop {
unsafe { asm!("hlt"); } // CPU를 절전 모드로
}
}
// 패닉 발생 시 호출되는 핸들러
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {
unsafe { asm!("hlt"); }
}
}
설명
이것이 하는 일: 베어메탈 Rust 커널 메인 함수는 부트로더에서 제어권을 받아 OS의 첫 번째 Rust 코드를 실행하고, 이후 모든 시스템 초기화를 담당합니다. 첫 번째로, #![no_std] 속성은 컴파일러에게 표준 라이브러리를 링크하지 말라고 지시합니다.
이는 std 크레이트가 사용 불가능해지지만, 플랫폼 독립적인 core 크레이트는 여전히 사용할 수 있습니다. 왜 이렇게 하는지는 std가 파일 시스템, 네트워킹, 스레드 같은 OS 기능에 의존하는데 우리가 바로 그 OS를 만들고 있기 때문입니다.
그 다음으로, #[no_mangle] 속성이 kernel_main 함수에 적용되면서 Rust의 이름 맹글링을 비활성화합니다. Rust는 기본적으로 함수 이름을 _ZN11kernel_main17h... 같은 형태로 변환하는데, 어셈블리에서는 정확한 심볼 이름으로 호출해야 하므로 맹글링을 막아야 합니다.
내부적으로 extern "C"는 C ABI를 사용하도록 지시하여 어셈블리와의 호환성을 보장합니다. 마지막으로, #[panic_handler] 함수가 정의되어 패닉 발생 시 동작을 제어합니다.
표준 라이브러리는 기본 패닉 핸들러를 제공하지만 #![no_std]에서는 직접 구현해야 합니다. 현재는 단순히 CPU를 정지시키지만, 실제 OS에서는 에러 메시지를 출력하고 코어 덤프를 생성할 수 있습니다.
리턴 타입 !는 이 함수가 절대 리턴하지 않음을 나타냅니다. 여러분이 이 코드를 사용하면 부트로더에서 Rust로 안전하게 전환되고, VGA 버퍼를 통해 화면 출력이 가능하며, OS 개발의 모든 기능을 Rust로 구현할 수 있습니다.
실무에서의 이점으로는 첫째, 메모리 안전성, 둘째, 강력한 타입 시스템, 셋째, 제로 비용 추상화가 있습니다.
실전 팁
💡 #![feature(asm)] 같은 불안정 기능을 사용하려면 nightly Rust 컴파일러가 필요합니다. rust-toolchain 파일에 "nightly"를 지정하세요.
💡 VGA 버퍼 주소 0xb8000은 BIOS가 설정한 텍스트 모드 프레임버퍼입니다. 각 문자는 2바이트(ASCII + 색상)를 차지합니다.
💡 리턴 타입 !는 never type으로, 함수가 무한 루프거나 프로세스를 종료함을 나타냅니다. OS 커널은 종료되지 않으므로 항상 !를 사용합니다.
💡 multiboot2 크레이트를 사용하면 부트로더 정보를 안전하게 파싱할 수 있습니다. Cargo.toml에 추가하세요.
💡 hlt 명령은 다음 인터럽트까지 CPU를 절전 모드로 만들어 전력 소비를 줄입니다. 단순 무한 루프보다 효율적입니다.
8. Cargo 설정과 타겟 트리플
시작하며
여러분이 베어메탈 Rust 커널을 빌드하려고 cargo build를 실행했는데 "can't find crate for std" 오류를 본 적 있나요? #![no_std]를 선언했는데도 계속 오류가 나는 경우 말이죠.
이런 문제는 Cargo가 기본적으로 호스트 OS용으로 빌드하려고 시도하기 때문에 발생합니다. 여러분의 개발 환경은 Linux나 Windows일 수 있지만, 커널은 베어메탈 환경에서 실행되어야 합니다.
바로 이럴 때 필요한 것이 커스텀 타겟 트리플입니다. 타겟 트리플을 통해 Cargo에게 "표준 라이브러리 없는 i686 베어메탈 환경"을 위해 빌드하라고 정확히 지시할 수 있습니다.
개요
간단히 말해서, 타겟 트리플은 컴파일러에게 어떤 아키텍처, 벤더, OS, ABI를 위해 컴파일할지 알려주는 문자열이며, 베어메탈 OS를 위해서는 커스텀 JSON 타겟을 정의해야 합니다. 일반적인 타겟 트리플은 "x86_64-unknown-linux-gnu" 같은 형태인데, 이는 x86-64 아키텍처, 알려지지 않은 벤더, Linux OS, GNU ABI를 의미합니다.
하지만 우리 커널은 OS 자체이므로 "i686-unknown-none" 같은 베어메탈 타겟이 필요하며, 세밀한 제어를 위해 JSON 파일로 정의합니다. 예를 들어, 링커, 패닉 전략, 코드 생성 옵션 등을 모두 커스터마이즈할 수 있습니다.
전통적인 크로스 컴파일에서는 rustup에 내장된 타겟을 사용했다면, 이제는 JSON 파일로 완전한 제어가 가능합니다. 핵심 특징으로는 첫째, 베어메탈 환경 지정(OS 없음), 둘째, 링커와 링커 스크립트 지정, 셋째, 패닉 전략과 스택 언와인딩 설정이 있습니다.
이러한 특징들은 호스트 환경과 완전히 다른 타겟 환경을 위한 빌드를 가능하게 하기 때문에 중요합니다.
코드 예제
// i686-unknown-none.json - 커스텀 타겟 정의
{
"llvm-target": "i686-unknown-none",
"data-layout": "e-m:e-p:32:32-f64:32:64-f80:32-n8:16:32-S128",
"arch": "x86",
"target-endian": "little",
"target-pointer-width": "32",
"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",
"pre-link-args": {
"ld.lld": ["-T", "linker.ld"]
}
}
// .cargo/config.toml - Cargo 설정
[build]
target = "i686-unknown-none.json"
[unstable]
build-std = ["core", "compiler_builtins"]
build-std-features = ["compiler-builtins-mem"]
설명
이것이 하는 일: 커스텀 타겟 트리플은 Rust 컴파일러에게 정확히 어떤 환경을 위해 코드를 생성할지 알려주는 설정 파일로, 호스트 환경과 완전히 다른 베어메탈 환경을 정의합니다. 첫 번째로, "os": "none"은 이 타겟이 어떤 OS 위에서도 실행되지 않음을 명시합니다.
이는 컴파일러가 OS 관련 기능(시스템 콜, 동적 링킹 등)을 생성하지 않도록 하며, 표준 라이브러리를 자동으로 링크하지 않습니다. 왜 이렇게 하는지는 우리가 OS 레이어를 직접 구현하기 때문입니다.
그 다음으로, "panic-strategy": "abort"가 설정되면서 패닉 발생 시 스택 언와인딩 대신 즉시 중단하도록 합니다. 언와인딩은 복잡한 런타임 지원이 필요하고 코드 크기를 늘리는데, 베어메탈에서는 어차피 스택 트레이스를 복원할 방법이 없으므로 abort가 효율적입니다.
내부적으로 이는 예외 처리 테이블을 생성하지 않아 바이너리 크기를 줄입니다. 마지막으로, build-std = ["core", "compiler_builtins"]는 Cargo에게 core 크레이트를 소스에서 다시 빌드하라고 지시합니다.
기본적으로 Rust는 미리 컴파일된 core를 사용하지만, 커스텀 타겟은 미리 컴파일된 버전이 없으므로 직접 빌드해야 합니다. compiler_builtins는 정수 나눗셈 같은 저수준 연산을 제공하는 크레이트입니다.
여러분이 이 설정을 사용하면 Cargo가 자동으로 베어메탈 환경을 위해 컴파일하고, 링커 스크립트를 적용하며, 불필요한 런타임 기능을 제거한 최적화된 커널 바이너리를 생성합니다. 실무에서의 이점으로는 첫째, 완전한 빌드 환경 제어, 둘째, 최소 바이너리 크기, 셋째, 예측 가능한 코드 생성이 있습니다.
실전 팁
💡 build-std 기능은 불안정하므로 nightly Rust가 필요합니다. rust-toolchain 파일에 "nightly"를 추가하세요.
💡 "disable-redzone": true는 x86-64에서 중요한데, red zone을 비활성화하지 않으면 인터럽트 핸들러가 스택을 손상시킬 수 있습니다.
💡 "-mmx,-sse,+soft-float"는 SIMD 명령어를 비활성화하여 부동소수점 레지스터 저장/복원을 피합니다. 컨텍스트 스위칭이 단순해집니다.
💡 cargo build 대신 cargo build --release를 사용하면 최적화된 바이너리가 생성되어 크기가 크게 줄어듭니다.
💡 타겟 JSON 파일의 변경사항은 재빌드 시 자동으로 감지되지 않으므로, 수정 후 cargo clean을 실행해야 합니다.
9. 빌드 스크립트와 바이너리 생성
시작하며
여러분이 커널 ELF 파일을 만들었는데 GRUB이 이를 부팅하지 못하는 상황을 겪어본 적 있나요? ELF 형식은 맞는데 실제 하드웨어에서는 raw 바이너리가 필요한 경우 말이죠.
이런 문제는 ELF 파일에 섹션 헤더, 심볼 테이블 등 메타데이터가 포함되어 있어서 발생합니다. 일부 부트로더는 순수한 바이너리 이미지를 기대하거나, ISO 이미지를 만들 때 특정 형식이 필요합니다.
바로 이럴 때 필요한 것이 빌드 스크립트와 objcopy 도구입니다. ELF 파일을 raw 바이너리로 변환하고, 부팅 가능한 ISO 이미지를 생성하는 자동화된 프로세스를 만들 수 있습니다.
개요
간단히 말해서, 빌드 스크립트는 Cargo가 제공하는 빌드 후크로, 컴파일 후 추가 작업을 수행할 수 있으며, objcopy를 통해 ELF를 raw 바이너리로 변환하고 ISO 이미지를 생성합니다. Rust 컴파일러는 기본적으로 ELF나 PE 같은 오브젝트 파일 형식을 생성하는데, 이는 링커와 로더를 위한 메타데이터를 포함합니다.
하지만 베어메탈 부팅에서는 종종 순수한 기계어 코드만 필요합니다. 예를 들어, USB 드라이브에 직접 쓰려면 섹션 헤더 없는 flat 바이너리가 필요하고, GRUB ISO를 만들 때는 특정 디렉토리 구조가 필요합니다.
전통적인 빌드 시스템에서는 Makefile로 이런 작업을 했다면, 이제는 Rust의 build.rs와 xtask 패턴을 사용할 수 있습니다. 핵심 특징으로는 첫째, ELF에서 바이너리로 변환, 둘째, GRUB을 위한 ISO 이미지 생성, 셋째, 빌드 프로세스 자동화가 있습니다.
이러한 특징들은 개발 워크플로우를 단순화하고 실제 하드웨어나 가상 머신에서 테스트를 가능하게 하기 때문에 중요합니다.
코드 예제
# Makefile - 빌드 자동화
KERNEL := target/i686-unknown-none/release/my_os
ISO_DIR := isofiles
GRUB_CFG := $(ISO_DIR)/boot/grub/grub.cfg
.PHONY: all clean iso run
all: iso
# Rust 커널 빌드
build:
cargo build --release
# ISO 이미지 생성
iso: build
mkdir -p $(ISO_DIR)/boot/grub
cp $(KERNEL) $(ISO_DIR)/boot/kernel.bin
# GRUB 설정 파일 생성
echo 'set timeout=0' > $(GRUB_CFG)
echo 'set default=0' >> $(GRUB_CFG)
echo '' >> $(GRUB_CFG)
echo 'menuentry "My OS" {' >> $(GRUB_CFG)
echo ' multiboot2 /boot/kernel.bin' >> $(GRUB_CFG)
echo ' boot' >> $(GRUB_CFG)
echo '}' >> $(GRUB_CFG)
# ISO 이미지 생성
grub-mkrescue -o my_os.iso $(ISO_DIR)
# QEMU에서 실행
run: iso
qemu-system-i386 -cdrom my_os.iso -serial stdio
clean:
cargo clean
rm -rf $(ISO_DIR) my_os.iso
설명
이것이 하는 일: 빌드 스크립트는 Rust 컴파일이 끝난 후 자동으로 실행되어 커널 ELF 파일을 부팅 가능한 형식으로 변환하고, GRUB을 통해 부팅할 수 있는 ISO 이미지를 생성합니다. 첫 번째로, cargo build --release는 최적화된 커널 ELF 파일을 생성합니다.
이 파일은 코드와 데이터를 포함하지만 부팅에 필요한 추가 파일(GRUB 모듈 등)이 없습니다. 왜 release 모드를 사용하는지는 디버그 심볼과 최적화되지 않은 코드가 바이너리 크기를 수십 배 늘리기 때문입니다.
그 다음으로, ISO 디렉토리 구조가 생성되면서 GRUB이 기대하는 레이아웃(/boot/grub/grub.cfg, /boot/kernel.bin)을 만듭니다. GRUB 설정 파일은 부트로더에게 "multiboot2 프로토콜로 /boot/kernel.bin을 로드하라"고 지시합니다.
내부적으로 grub-mkrescue는 이 디렉토리를 스캔하고 부팅 가능한 El Torito ISO 이미지를 생성합니다. 마지막으로, grub-mkrescue 명령이 실행되어 모든 필요한 GRUB 모듈(multiboot2 지원 포함)과 커널을 포함하는 ISO 파일을 만듭니다.
이 ISO는 CD-ROM, USB, 또는 가상 머신에서 직접 부팅할 수 있으며, Multiboot2 헤더를 찾아서 커널을 로드합니다. 여러분이 이 스크립트를 사용하면 단순히 make run만 입력하면 전체 빌드, ISO 생성, QEMU 실행이 자동으로 이뤄지며, 코드 수정 후 즉시 테스트할 수 있습니다.
실무에서의 이점으로는 첫째, 원클릭 빌드/테스트, 둘째, 재현 가능한 빌드 환경, 셋째, 실제 하드웨어 부팅 가능이 있습니다.
실전 팁
💡 grub-mkrescue가 없다면 sudo apt install grub-pc-bin xorriso (Debian/Ubuntu) 또는 brew install grub xorriso (macOS)로 설치하세요.
💡 QEMU 옵션 -serial stdio는 커널의 시리얼 포트 출력을 터미널에 표시합니다. 디버깅 메시지 출력에 유용합니다.
💡 실제 하드웨어에서 테스트하려면 dd if=my_os.iso of=/dev/sdX bs=4M로 USB에 쓸 수 있습니다(sdX는 USB 장치).
💡 build.rs 대신 xtask 패턴(cargo xtask build)을 사용하면 더 복잡한 빌드 로직을 Rust로 작성할 수 있습니다.
💡 ISO 크기를 줄이려면 strip 명령으로 ELF 파일에서 디버그 심볼을 제거하세요: strip target/.../my_os.
10. QEMU를 이용한 디버깅과 테스트
시작하며
여러분이 커널을 개발하면서 매번 실제 하드웨어에 재부팅해야 하는 번거로움을 겪어본 적 있나요? 작은 수정 하나에도 USB를 꽂았다 뺐다 해야 하는 상황 말이죠.
이런 문제는 베어메탈 개발의 가장 큰 고통 중 하나입니다. 실제 하드웨어는 재부팅이 느리고, 디버깅 도구가 제한적이며, 잘못된 코드가 하드웨어를 손상시킬 수도 있습니다.
바로 이럴 때 필요한 것이 QEMU 같은 에뮬레이터입니다. QEMU는 전체 x86 시스템을 소프트웨어로 에뮬레이션하여 빠른 개발/테스트 사이클을 제공하고, GDB 디버깅까지 지원합니다.
개요
간단히 말해서, QEMU는 오픈소스 머신 에뮬레이터로, 실제 하드웨어 없이 OS 커널을 실행하고 디버깅할 수 있으며, GDB와 연동하여 브레이크포인트, 스텝 실행, 레지스터 검사 등을 지원합니다. 물리적 하드웨어는 한 번 설정하면 변경이 어렵지만, QEMU는 CPU 모델, 메모리 크기, 디바이스 구성을 명령줄 옵션으로 즉시 바꿀 수 있습니다.
예를 들어, -m 128M으로 메모리를 제한해서 메모리 부족 시나리오를 테스트하거나, -smp 4로 멀티코어 환경을 시뮬레이션할 수 있습니다. 전통적인 개발 방법에서는 컴파일-부팅-테스트 사이클이 수 분씩 걸렸다면, 이제는 QEMU로 수 초 만에 반복할 수 있습니다.
핵심 특징으로는 첫째, 빠른 부팅과 재시작, 둘째, GDB를 통한 소스 레벨 디버깅, 셋째, 다양한 하드웨어 구성 시뮬레이션이 있습니다. 이러한 특징들은 개발 생산성을 극적으로 향상시키고 안전한 실험 환경을 제공하기 때문에 중요합니다.
코드 예제
# Makefile에 디버깅 타겟 추가
# QEMU로 실행 (일반 모드)
run: iso
qemu-system-i386 \
-cdrom my_os.iso \
-serial stdio \
-display curses \
-m 128M
# GDB 디버깅 모드로 실행
debug: iso
qemu-system-i386 \
-cdrom my_os.iso \
-serial stdio \
-s -S \
-m 128M &
# GDB 자동 실행
gdb -ex "target remote localhost:1234" \
-ex "symbol-file $(KERNEL)" \
-ex "break kernel_main" \
-ex "continue"
# 시리얼 포트 로깅
run-log: iso
qemu-system-i386 \
-cdrom my_os.iso \
-serial file:serial.log \
-nographic
# 멀티코어 테스트
run-smp: iso
qemu-system-i386 \
-cdrom my_os.iso \
-serial stdio \
-smp cores=4
설명
이것이 하는 일: QEMU는 실제 x86 프로세서와 하드웨어를 소프트웨어로 완벽히 재현하여, 여러분의 커널이 실제 하드웨어에서 실행되는 것처럼 동작하지만 훨씬 빠르고 안전하게 테스트할 수 있게 합니다. 첫 번째로, -cdrom my_os.iso 옵션은 QEMU에게 가상 CD-ROM 드라이브에 ISO 이미지를 넣으라고 지시합니다.
QEMU는 BIOS를 에뮬레이션하고 부팅 순서를 확인한 뒤 CD-ROM에서 부팅을 시도하며, GRUB이 로드되고 여러분의 커널이 실행됩니다. 왜 이렇게 하는지는 실제 하드웨어 부팅 프로세스를 정확히 재현하기 위함입니다.
그 다음으로, -s -S 옵션이 설정되면서 QEMU가 GDB 서버를 1234 포트에 열고 시작 시 일시정지합니다. GDB를 연결하면 커널의 첫 명령어부터 스텝 단위로 실행할 수 있고, 레지스터와 메모리를 검사할 수 있습니다.
내부적으로 QEMU는 각 명령어 실행 전 GDB 요청을 확인하여 브레이크포인트나 스텝 명령을 처리합니다. 마지막으로, -serial stdio 옵션은 가상 시리얼 포트(COM1)를 터미널에 연결합니다.
커널에서 시리얼 포트로 출력하면 즉시 터미널에 표시되므로, VGA 버퍼보다 디버깅 메시지 출력이 훨씬 편리합니다. 시리얼 포트는 간단한 I/O 포트 명령(out 0x3F8)으로 사용할 수 있습니다.
여러분이 이 설정을 사용하면 코드 수정 후 즉시 테스트하고, GDB로 버그를 추적하며, 다양한 하드웨어 구성을 실험할 수 있어 개발 속도가 몇 배는 빨라집니다. 실무에서의 이점으로는 첫째, 수 초 내 재부팅, 둘째, 소스 레벨 디버깅, 셋째, 안전한 실험 환경이 있습니다.
실전 팁
💡 -monitor stdio 옵션을 사용하면 QEMU 모니터 콘솔에 접근하여 메모리 덤프, 레지스터 검사, CPU 상태 확인 등을 대화형으로 할 수 있습니다.
💡 GDB에서 layout asm을 입력하면 어셈블리 코드를 TUI로 보면서 디버깅할 수 있습니다. layout src는 소스 코드를 표시합니다.
💡 QEMU의 -d int,cpu_reset 옵션은 모든 인터럽트와 CPU 리셋을 로그로 기록하여 트리플 폴트를 디버깅할 때 유용합니다.
💡 실제 하드웨어로 넘어가기 전 QEMU의 KVM 모드(-enable-kvm)로 거의 네이티브 속도로 실행해 보세요. 단, 호스트가 Linux여야 합니다.
💡 -soundhw all로 사운드 카드를 에뮬레이션하거나 -vga virtio로 다양한 그래픽 카드를 테스트할 수 있습니다.