이미지 로딩 중...

Rust로 만드는 나만의 OS 베어메탈 타겟 설정 - 슬라이드 1/11
A

AI Generated

2025. 11. 13. · 4 Views

Rust로 만드는 나만의 OS 베어메탈 타겟 설정

OS 개발의 첫걸음인 베어메탈 환경 설정을 다룹니다. 하드웨어 위에서 직접 실행되는 Rust 코드를 작성하기 위한 타겟 설정과 크로스 컴파일 방법을 실무 중심으로 설명합니다. 실제 OS 개발에 필요한 핵심 개념과 실전 팁을 함께 제공합니다.


목차

  1. 베어메탈 환경이란 - OS 없이 하드웨어에서 직접 실행하기
  2. no_std 속성 - 표준 라이브러리 없이 개발하기
  3. 커스텀 타겟 스펙 - 하드웨어에 맞는 컴파일 설정
  4. 크로스 컴파일 빌드 - 타겟 지정하여 컴파일하기
  5. 링커 스크립트 작성 - 메모리 레이아웃 제어하기
  6. 부트 섹션 정의 - 멀티부트 헤더 구현
  7. 빌드 자동화 - Makefile과 빌드 스크립트
  8. QEMU 테스트 환경 - 빠른 개발 사이클
  9. Core 라이브러리 활용 - no_std에서 사용 가능한 기능들
  10. Alloc 크레이트 통합 - 베어메탈에서 힙 메모리 사용하기

1. 베어메탈 환경이란 - OS 없이 하드웨어에서 직접 실행하기

시작하며

여러분이 일반적인 Rust 애플리케이션을 개발할 때는 리눅스나 윈도우 같은 운영체제 위에서 코드가 실행됩니다. 메모리 할당, 파일 시스템 접근, 네트워크 통신 등 모든 것이 OS의 도움을 받죠.

하지만 OS 자체를 만들려면 어떻게 해야 할까요? 이런 문제는 시스템 프로그래밍에서 가장 근본적인 도전입니다.

OS가 없는 상태에서는 표준 라이브러리의 대부분을 사용할 수 없고, 하드웨어와 직접 대화해야 합니다. println!

매크로조차 사용할 수 없는 환경이죠. 바로 이럴 때 필요한 것이 베어메탈(Bare-Metal) 환경 설정입니다.

이는 OS 없이 하드웨어 위에서 직접 실행되는 코드를 작성하는 것으로, OS 개발의 시작점입니다.

개요

간단히 말해서, 베어메탈 환경은 운영체제 없이 하드웨어 위에서 직접 실행되는 프로그래밍 환경입니다. 일반적인 애플리케이션은 OS가 제공하는 시스템 콜, 메모리 관리, 스레드 스케줄링 등에 의존합니다.

하지만 OS 개발이나 임베디드 시스템, 펌웨어 개발에서는 이런 기능들이 존재하지 않거나 직접 구현해야 합니다. 예를 들어, 부트로더나 하이퍼바이저 같은 경우에 매우 유용합니다.

기존에는 C나 어셈블리로 이런 작업을 했다면, 이제는 Rust의 메모리 안전성과 현대적인 타입 시스템을 활용할 수 있습니다. 베어메탈 환경의 핵심 특징은 세 가지입니다: no_std 환경(표준 라이브러리 없음), 커스텀 타겟 지정(CPU 아키텍처와 특성 정의), 그리고 직접적인 하드웨어 제어입니다.

이러한 특징들이 OS 레벨의 완전한 제어권을 가능하게 합니다.

코드 예제

// Cargo.toml - 베어메탈 프로젝트 설정
[package]
name = "bare-metal-os"
version = "0.1.0"
edition = "2021"

[dependencies]

[profile.dev]
panic = "abort"  // 패닉 시 언와인딩 대신 중단

[profile.release]
panic = "abort"
lto = true       // 링크 타임 최적화

설명

이것이 하는 일: Cargo.toml 설정으로 베어메탈 환경을 위한 프로젝트를 구성합니다. 첫 번째로, [profile.dev]와 [profile.release] 섹션에서 panic = "abort"를 설정합니다.

일반적인 Rust 프로그램에서는 패닉이 발생하면 스택 언와인딩이 일어나면서 메모리를 정리하고 에러를 전파합니다. 하지만 이 과정은 OS의 지원이 필요하죠.

베어메탈 환경에서는 이런 기능이 없으므로 즉시 중단(abort)하도록 설정합니다. 그 다음으로, lto = true 옵션이 실행되면서 링크 타임 최적화를 활성화합니다.

이는 여러 크레이트를 하나로 합치면서 불필요한 코드를 제거하고 인라이닝을 수행합니다. 베어메탈 환경에서는 바이너리 크기가 매우 중요하므로 이 최적화가 필수적입니다.

마지막으로, dependencies 섹션이 비어있는 것을 확인할 수 있습니다. 대부분의 Rust 라이브러리는 표준 라이브러리에 의존하므로, 베어메탈 환경에서는 no_std를 지원하는 특별한 크레이트만 사용할 수 있습니다.

여러분이 이 설정을 사용하면 OS에 의존하지 않는 순수한 바이너리를 생성할 수 있습니다. 이는 부팅 과정에서 실행되거나, 하드웨어를 직접 제어하거나, 커스텀 런타임 환경을 만드는 데 필수적입니다.

또한 코드 크기가 최소화되어 제한된 메모리 환경에서도 실행 가능합니다.

실전 팁

💡 panic = "abort"를 설정하지 않으면 eh_personality 랭귀지 아이템이 필요하다는 에러가 발생합니다. 이는 언와인딩에 필요한 함수인데 베어메탈에서는 구현할 수 없습니다.

💡 lto = true는 컴파일 시간을 늘리지만 최종 바이너리 크기를 크게 줄입니다. 개발 중에는 빠른 빌드를 위해 dev 프로파일에서는 제외할 수도 있습니다.

💡 opt-level = "z"나 "s" 옵션을 추가하면 크기 최적화를 더욱 강화할 수 있습니다. "z"가 "s"보다 더 적극적으로 크기를 줄입니다.

💡 strip = true 옵션으로 디버그 심볼을 제거하면 바이너리가 더욱 작아집니다. 다만 디버깅이 어려워지므로 릴리즈 빌드에만 사용하세요.


2. no_std 속성 - 표준 라이브러리 없이 개발하기

시작하며

여러분이 Rust 코드를 작성할 때 Vec, String, println! 같은 것들을 당연하게 사용하시나요?

이 모든 것이 표준 라이브러리(std)에서 제공하는 기능입니다. 그런데 만약 이것들을 하나도 사용할 수 없다면 어떻게 될까요?

이런 상황은 OS 개발에서 반드시 직면하게 됩니다. 표준 라이브러리는 파일 시스템, 네트워크, 스레드 같은 OS 기능에 의존하기 때문입니다.

OS를 만들려면 이런 것들을 직접 구현해야 하죠. 바로 이럴 때 필요한 것이 no_std 속성입니다.

이는 표준 라이브러리를 사용하지 않고, 더 작은 core 라이브러리만 사용하여 베어메탈 환경에서 실행 가능한 코드를 작성할 수 있게 합니다.

개요

간단히 말해서, no_std는 Rust의 표준 라이브러리를 비활성화하고 core 라이브러리만 사용하는 설정입니다. 표준 라이브러리(std)는 메모리 할당, 파일 I/O, 네트워킹 등 OS에 의존하는 기능을 제공합니다.

반면 core 라이브러리는 Option, Result, 반복자, 트레이트 같은 OS에 독립적인 기본 기능만 제공하죠. OS 개발이나 임베디드 시스템, UEFI 애플리케이션 같은 경우에 매우 유용합니다.

기존에는 모든 Rust 프로그램이 암묵적으로 표준 라이브러리를 포함했다면, 이제는 #![no_std]로 명시적으로 제거할 수 있습니다. no_std의 핵심 특징은 세 가지입니다: core 라이브러리 접근(기본 타입과 트레이트), alloc 라이브러리 선택적 사용(할당자 제공 시 Vec, Box 등 사용 가능), 그리고 플랫폼 독립성입니다.

이러한 특징들이 최소한의 런타임으로 강력한 코드 작성을 가능하게 합니다.

코드 예제

// src/main.rs - no_std 베어메탈 프로그램
#![no_std]           // 표준 라이브러리 비활성화
#![no_main]          // 일반적인 main 함수 사용 안 함

use core::panic::PanicInfo;

// 패닉 핸들러 - 패닉 시 호출됨
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    loop {}  // 무한 루프로 중단
}

// 엔트리 포인트 - OS가 아닌 부트로더가 호출
#[no_mangle]
pub extern "C" fn _start() -> ! {
    loop {}  // 현재는 아무것도 안 함
}

설명

이것이 하는 일: no_std 환경에서 실행 가능한 최소한의 Rust 프로그램을 정의합니다. 첫 번째로, #![no_std]와 #![no_main] 속성이 파일 최상단에 선언됩니다.

no_std는 표준 라이브러리 링크를 제거하고, no_main은 Rust 컴파일러가 기본적으로 제공하는 main 함수와 런타임 초기화 코드를 비활성화합니다. 이렇게 하는 이유는 OS가 없는 환경에서는 일반적인 프로그램 시작 과정이 작동하지 않기 때문입니다.

그 다음으로, #[panic_handler] 함수가 정의됩니다. 표준 라이브러리는 기본 패닉 핸들러를 제공하지만, no_std 환경에서는 직접 구현해야 합니다.

이 함수는 ! 타입을 반환하는데, 이는 절대 반환하지 않는다는 의미입니다.

패닉이 발생하면 무한 루프에 들어가 CPU를 정지시킵니다. 세 번째로, _start 함수가 프로그램의 진짜 시작점이 됩니다.

#[no_mangle] 속성은 함수 이름이 맹글링되지 않도록 하여 링커가 정확히 "_start"라는 이름으로 찾을 수 있게 합니다. extern "C"는 C 호출 규약을 사용하여 부트로더나 링커 스크립트와 호환되도록 합니다.

마지막으로, ! 반환 타입은 이 함수가 절대 반환하지 않음을 나타냅니다.

OS가 없으므로 프로그램을 종료할 곳이 없죠. 따라서 무한 루프로 CPU를 유지합니다.

여러분이 이 코드를 사용하면 OS 없이 하드웨어에서 직접 실행되는 프로그램을 만들 수 있습니다. 이는 부트로더, 커널, 임베디드 펌웨어의 기본 구조이며, 여기서부터 화면 출력, 인터럽트 처리, 메모리 관리 등을 추가로 구현해 나갑니다.

또한 전체 제어권을 가지므로 성능 최적화와 하드웨어 특화 기능 구현이 자유롭습니다.

실전 팁

💡 no_std를 사용하면 println!, Vec, String 등을 직접 사용할 수 없습니다. 대신 core::fmt::Write 트레이트를 구현하여 커스텀 출력 함수를 만들 수 있습니다.

💡 #[panic_handler]를 구현하지 않으면 컴파일 에러가 발생합니다. 최소한 무한 루프라도 반드시 정의해야 합니다.

💡 _start 함수명은 관습적으로 사용되지만 링커 스크립트에서 다른 이름을 지정할 수도 있습니다. 중요한 것은 #[no_mangle]로 이름을 보존하는 것입니다.

💡 디버깅을 위해 패닉 핸들러에서 PanicInfo를 시리얼 포트로 출력하는 코드를 추가하면 에러 추적이 훨씬 쉬워집니다.

💡 extern "C"를 사용하면 ABI(Application Binary Interface)가 C와 호환되어 부트로더나 다른 언어로 작성된 코드와 쉽게 연동할 수 있습니다.


3. 커스텀 타겟 스펙 - 하드웨어에 맞는 컴파일 설정

시작하며

여러분이 x86_64 리눅스 머신에서 개발하다가 ARM 임베디드 보드에서 실행해야 하는 상황을 만난 적 있나요? 아니면 특정 CPU 기능을 활성화하거나 비활성화해야 하는 경우는요?

일반적인 타겟만으로는 세밀한 제어가 불가능합니다. 이런 문제는 베어메탈 OS 개발에서 더욱 심각합니다.

부팅 과정에서는 특정 CPU 기능(SSE, AVX 등)을 사용할 수 없고, 인터럽트 처리 방식도 달라야 합니다. 표준 타겟은 OS 환경을 가정하므로 맞지 않죠.

바로 이럴 때 필요한 것이 커스텀 타겟 스펙입니다. JSON 파일로 CPU 아키텍처, ABI, 링커 설정, 사용 가능한 기능 등을 세밀하게 지정하여 하드웨어에 완벽히 맞는 바이너리를 생성할 수 있습니다.

개요

간단히 말해서, 커스텀 타겟 스펙은 JSON 형식으로 컴파일러에게 정확히 어떤 환경을 위해 컴파일할지 알려주는 설정 파일입니다. Rust 컴파일러는 x86_64-unknown-linux-gnu 같은 내장 타겟들을 제공합니다.

하지만 OS 개발에서는 운영체제가 없고(unknown), 특정 CPU 기능을 제어해야 하며, 커스텀 링커 스크립트를 사용해야 합니다. 예를 들어, UEFI 부트로더나 x86_64 커널 개발 같은 경우에 매우 유용합니다.

기존에는 미리 정의된 타겟 중 하나를 선택해야 했다면, 이제는 모든 세부 사항을 직접 제어할 수 있습니다. 커스텀 타겟의 핵심 특징은 세 가지입니다: 아키텍처 세부 설정(data-layout, target-pointer-width), 기능 제어(features로 SSE, AVX 등 활성화/비활성화), 그리고 링커 옵션(linker-flavor, pre-link-args)입니다.

이러한 특징들이 하드웨어 레벨의 완벽한 제어를 가능하게 합니다.

코드 예제

// x86_64-bare_metal.json - 커스텀 타겟 스펙
{
  "llvm-target": "x86_64-unknown-none",
  "data-layout": "e-m:e-p270:32:32-p271:32:32-p272:64:64-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",                    // OS 없음
  "executables": true,
  "linker-flavor": "ld.lld",       // LLVM 링커 사용
  "linker": "rust-lld",
  "panic-strategy": "abort",       // 패닉 시 중단
  "disable-redzone": true,         // 레드존 비활성화 (인터럽트 안전)
  "features": "-mmx,-sse,+soft-float"  // 부동소수점 비활성화
}

설명

이것이 하는 일: x86_64 베어메탈 환경을 위한 커스텀 컴파일 타겟을 정의합니다. 첫 번째로, "llvm-target"과 "os": "none"이 핵심 설정입니다.

llvm-target은 LLVM 백엔드에 x86_64 아키텍처를 타겟팅하되 OS가 없음(none)을 알려줍니다. 이렇게 하면 컴파일러가 OS 관련 가정을 하지 않고, 시스템 콜이나 동적 링킹 같은 기능을 생성하지 않습니다.

그 다음으로, "disable-redzone": true가 매우 중요합니다. x86_64 ABI는 기본적으로 스택 포인터 아래 128바이트를 임시 저장소로 사용하는 "레드존"을 허용합니다.

하지만 인터럽트가 발생하면 CPU가 자동으로 스택에 데이터를 푸시하면서 레드존을 덮어쓸 수 있습니다. 베어메탈 환경에서는 인터럽트를 직접 처리하므로 레드존을 반드시 비활성화해야 합니다.

세 번째로, "features": "-mmx,-sse,+soft-float"가 SIMD 명령어를 비활성화합니다. MMX와 SSE는 부동소수점과 벡터 연산을 위한 확장 명령어인데, 사용하려면 특별한 초기화가 필요합니다.

부팅 초기에는 이런 초기화를 할 수 없으므로 소프트웨어 부동소수점(soft-float)을 사용합니다. 이는 느리지만 추가 하드웨어 설정 없이 작동합니다.

마지막으로, "linker-flavor": "ld.lld"와 "linker": "rust-lld"가 LLVM의 링커를 지정합니다. 이 링커는 크로스 컴파일을 잘 지원하고, 커스텀 링커 스크립트와 함께 작동하여 메모리 레이아웃을 정밀하게 제어할 수 있습니다.

여러분이 이 타겟 스펙을 사용하면 베어메탈 환경에서 안전하게 실행되는 코드를 생성할 수 있습니다. 인터럽트가 발생해도 스택이 손상되지 않고, 초기화되지 않은 SIMD 명령어로 인한 예외도 발생하지 않습니다.

또한 불필요한 OS 의존성이 제거되어 바이너리가 더 작고 예측 가능해집니다.

실전 팁

💡 disable-redzone을 설정하지 않으면 인터럽트 핸들러에서 원인을 알 수 없는 메모리 손상이 발생할 수 있습니다. 디버깅이 매우 어려우므로 반드시 설정하세요.

💡 features에서 SIMD를 비활성화하면 성능이 떨어지지만, 나중에 SSE/AVX를 명시적으로 초기화한 후 활성화할 수 있습니다. 부팅 후 필요한 시점에 재활성화하세요.

💡 data-layout 문자열은 LLVM의 타겟 정보와 정확히 일치해야 합니다. 잘못 설정하면 ABI 불일치로 크래시가 발생하므로 공식 문서를 참고하세요.

💡 panic-strategy를 "abort"로 설정하면 Cargo.toml의 설정을 덮어씁니다. 타겟 레벨에서 강제하는 것이 더 안전합니다.

💡 rustc --print target-list 명령으로 내장 타겟을 확인하고, --print target-spec-json으로 기존 타겟의 JSON을 볼 수 있습니다. 이를 참고하여 커스텀 타겟을 작성하세요.


4. 크로스 컴파일 빌드 - 타겟 지정하여 컴파일하기

시작하며

여러분이 맥북에서 개발하면서 라즈베리 파이나 x86 서버용 바이너리를 만들어야 하는 상황을 겪어본 적 있나요? 매번 타겟 머신에서 직접 빌드하기에는 시간도 오래 걸리고 개발 환경 설정도 번거롭습니다.

이런 문제는 OS 개발에서 필수적으로 해결해야 합니다. 개발 머신은 리눅스나 맥OS를 실행하지만, 만들고 있는 OS는 전혀 다른 환경이죠.

심지어 아직 OS가 완성되지 않았으므로 타겟 머신에서 컴파일할 수도 없습니다. 바로 이럴 때 필요한 것이 크로스 컴파일입니다.

Rust는 강력한 크로스 컴파일 지원을 제공하여, 한 플랫폼에서 다른 플랫폼을 위한 바이너리를 빌드할 수 있습니다. 커스텀 타겟과 함께 사용하면 베어메탈 OS를 효율적으로 개발할 수 있죠.

개요

간단히 말해서, 크로스 컴파일은 실행 중인 플랫폼이 아닌 다른 플랫폼을 위한 바이너리를 생성하는 컴파일 방식입니다. 일반적인 컴파일은 네이티브 컴파일로, 현재 시스템을 위한 바이너리를 만듭니다.

하지만 임베디드 개발, OS 개발, 멀티플랫폼 배포에서는 타겟 시스템이 개발 머신과 다릅니다. Rust의 rustup과 cargo는 이를 매우 쉽게 만들어줍니다.

예를 들어, ARM 임베디드 장치나 WASM 웹 애플리케이션, 베어메탈 커널 같은 경우에 매우 유용합니다. 기존에는 복잡한 툴체인 설정과 시스템 라이브러리 크로스 컴파일이 필요했다면, 이제는 cargo build --target 명령 하나로 해결됩니다.

크로스 컴파일의 핵심 특징은 세 가지입니다: 타겟 독립적 빌드(개발 머신에서 모든 타겟 빌드 가능), 툴체인 자동 관리(rustup이 필요한 컴포넌트 자동 설치), 그리고 no_std 지원(표준 라이브러리 없이도 빌드 가능)입니다. 이러한 특징들이 빠르고 효율적인 멀티플랫폼 개발을 가능하게 합니다.

코드 예제

# 커스텀 타겟으로 빌드하기

# 1. 타겟 스펙이 있는 상태에서 빌드
cargo build --target x86_64-bare_metal.json

# 2. .cargo/config.toml에 기본 타겟 설정
# .cargo/config.toml
[build]
target = "x86_64-bare_metal.json"

[target.x86_64-bare_metal]
runner = "qemu-system-x86_64 -drive format=raw,file={}"

# 3. 이제 간단하게 빌드 가능
cargo build

# 4. QEMU로 직접 실행 (runner 설정 사용)
cargo run

설명

이것이 하는 일: 커스텀 타겟 스펙을 사용하여 베어메탈 바이너리를 빌드하고 실행합니다. 첫 번째로, cargo build --target 명령이 타겟 스펙 JSON 파일을 받아 크로스 컴파일을 수행합니다.

상대 경로나 절대 경로로 JSON 파일을 지정할 수 있으며, cargo는 이를 읽어 컴파일러 플래그, 링커 옵션, CPU 기능 등을 설정합니다. 빌드 결과물은 target/x86_64-bare_metal/debug/ 디렉토리에 생성됩니다.

그 다음으로, .cargo/config.toml 파일로 프로젝트별 기본 설정을 정의합니다. [build] 섹션의 target을 설정하면 매번 --target 플래그를 입력할 필요가 없어집니다.

이는 팀 전체가 동일한 타겟으로 빌드할 수 있도록 보장하며, CI/CD 파이프라인에서도 일관성을 유지합니다. 세 번째로, [target.x86_64-bare_metal] 섹션에서 runner를 지정합니다.

runner는 cargo run 명령 시 실행될 프로그램을 정의하는데, 여기서는 QEMU 가상 머신을 사용합니다. {}는 빌드된 바이너리 경로로 치환되며, -drive format=raw,file={}로 디스크 이미지로 부팅합니다.

마지막으로, 모든 설정이 완료되면 cargo build와 cargo run 명령만으로 개발 사이클을 돌릴 수 있습니다. 빌드는 베어메탈 바이너리를 생성하고, run은 자동으로 QEMU를 실행하여 테스트합니다.

수정-빌드-테스트 사이클이 매우 빠릅니다. 여러분이 이 설정을 사용하면 개발 머신에서 편안하게 작업하면서 실제 하드웨어나 가상 머신에서 실행되는 OS를 개발할 수 있습니다.

디버깅 도구, 편집기, 빌드 속도 등 개발 환경의 모든 이점을 누리면서 타겟 시스템을 위한 코드를 작성합니다. 또한 다양한 아키텍처(x86, ARM, RISC-V)를 동일한 방식으로 지원할 수 있어 이식성이 뛰어납니다.

실전 팁

💡 타겟 JSON 파일은 프로젝트 루트나 .cargo 디렉토리에 두는 것이 관습입니다. 버전 관리에 포함시켜 팀원들과 공유하세요.

💡 cargo clean을 실행하면 모든 타겟의 빌드 결과가 삭제됩니다. 디스크 공간이 부족하면 특정 타겟만 삭제할 수도 있습니다: rm -rf target/x86_64-bare_metal

💡 QEMU runner를 사용할 때 -s -S 옵션을 추가하면 GDB 디버깅 포트가 열리고 시작 시 일시정지합니다. gdb로 원격 디버깅이 가능해집니다.

💡 .cargo/config.toml은 계층적으로 작동합니다. 홈 디렉토리의 ~/.cargo/config.toml에 전역 설정을, 프로젝트별로는 로컬 설정을 둘 수 있습니다.

💡 cargo build --target --release로 최적화 빌드를 만들면 크기가 크게 줄어듭니다. 베어메탈 환경에서는 크기가 중요하므로 릴리즈 빌드를 기본으로 사용하세요.


5. 링커 스크립트 작성 - 메모리 레이아웃 제어하기

시작하며

여러분이 프로그램을 빌드할 때 코드, 데이터, 스택이 메모리 어디에 배치될지 고민해본 적 있나요? 일반 애플리케이션에서는 OS가 알아서 처리하지만, OS를 만들 때는 여러분이 직접 정해야 합니다.

이런 문제는 베어메탈 환경에서 매우 중요합니다. 커널은 특정 주소(예: 0x100000)에서 시작해야 하고, BIOS나 하드웨어가 예약한 메모리 영역을 피해야 하며, 스택과 힙의 위치도 신중하게 결정해야 합니다.

잘못 설정하면 부팅조차 되지 않죠. 바로 이럴 때 필요한 것이 링커 스크립트입니다.

이는 링커에게 각 섹션을 메모리 어디에 배치할지 정확히 지시하는 스크립트 파일로, 메모리 레이아웃을 완벽하게 제어할 수 있게 합니다.

개요

간단히 말해서, 링커 스크립트는 실행 파일의 메모리 레이아웃을 정의하는 텍스트 파일로, GNU LD 문법을 사용합니다. 링커는 여러 오브젝트 파일을 하나의 실행 파일로 합치는 도구입니다.

일반적으로는 기본 링커 스크립트를 사용하지만, 베어메탈 환경에서는 커스텀 스크립트가 필수입니다. 커널의 시작 주소, 섹션 정렬, 심볼 주소 등을 정밀하게 제어할 수 있죠.

예를 들어, 멀티부트 헤더를 특정 위치에 배치하거나, 페이지 테이블을 페이지 경계에 정렬하는 경우에 매우 유용합니다. 기존에는 링커가 자동으로 레이아웃을 결정했다면, 이제는 바이트 단위로 정확한 위치를 지정할 수 있습니다.

링커 스크립트의 핵심 특징은 세 가지입니다: MEMORY 정의(물리 메모리 영역 선언), SECTIONS 정의(코드/데이터 섹션 배치), 그리고 심볼 정의(시작/끝 주소를 코드에서 참조 가능)입니다. 이러한 특징들이 하드웨어와 완벽히 호환되는 바이너리 생성을 가능하게 합니다.

코드 예제

/* linker.ld - 베어메탈 x86_64 링커 스크립트 */
ENTRY(_start)  /* 엔트리 포인트 지정 */

MEMORY {
    /* 1MB에서 시작 (BIOS 영역 회피) */
    RAM : ORIGIN = 0x100000, LENGTH = 2M
}

SECTIONS {
    .boot : {
        KEEP(*(.boot))  /* 부트 섹션 먼저 배치 */
    } > RAM

    .text : ALIGN(4K) {  /* 코드 섹션, 4KB 정렬 */
        *(.text .text.*)
    } > RAM

    .rodata : ALIGN(4K) {  /* 읽기 전용 데이터 */
        *(.rodata .rodata.*)
    } > RAM

    .data : ALIGN(4K) {  /* 쓰기 가능 데이터 */
        *(.data .data.*)
    } > RAM

    .bss : ALIGN(4K) {  /* 초기화되지 않은 데이터 */
        __bss_start = .;
        *(.bss .bss.*)
        __bss_end = .;
    } > RAM
}

설명

이것이 하는 일: 커널을 1MB 주소에서 시작하도록 배치하고, 각 섹션을 페이지 경계에 정렬합니다. 첫 번째로, ENTRY(_start)가 실행 파일의 진입점을 지정합니다.

이는 ELF 헤더의 e_entry 필드에 기록되어, 부트로더나 CPU가 어디서부터 실행을 시작할지 알려줍니다. _start는 앞서 정의한 Rust 함수명과 일치해야 합니다.

그 다음으로, MEMORY 블록이 물리 메모리 영역을 정의합니다. ORIGIN = 0x100000은 1MB 주소를 시작점으로 지정하는데, 이는 BIOS가 0-1MB 영역을 사용하므로 충돌을 피하기 위함입니다.

LENGTH = 2M은 2MB 크기를 할당하며, 실제 메모리가 이보다 크더라도 링커는 이 범위 내에서만 심볼을 배치합니다. 세 번째로, SECTIONS 블록이 각 섹션의 순서와 정렬을 정의합니다.

.boot 섹션이 가장 먼저 배치되어 멀티부트 헤더나 부트 코드가 파일 시작 부분에 위치합니다. KEEP()은 최적화 과정에서 제거되지 않도록 보호합니다.

그 다음 .text(코드), .rodata(상수), .data(변수), .bss(미초기화 변수) 순으로 배치됩니다. 네 번째로, ALIGN(4K)가 각 섹션을 4KB(페이지 크기) 경계에 정렬합니다.

이는 나중에 페이징을 활성화할 때 중요합니다. 각 섹션이 페이지 경계에 있으면 페이지 단위로 권한을 설정할 수 있습니다(예: .text는 실행 가능, .rodata는 읽기 전용, .data는 읽기/쓰기).

마지막으로, __bss_start와 __bss_end 심볼이 정의됩니다. 이들은 .bss 섹션의 시작과 끝 주소를 담고 있으며, Rust 코드에서 extern "C"로 참조하여 .bss를 0으로 초기화할 수 있습니다.

.bss 섹션은 실행 파일에 포함되지 않고 로드 시 0으로 채워져야 하므로, 명시적 초기화가 필요합니다. 여러분이 이 링커 스크립트를 사용하면 커널이 정확한 메모리 위치에 로드되고, 각 섹션이 예측 가능한 주소에 배치됩니다.

페이징 활성화 시 섹션별 권한 설정이 쉬워지고, 디버깅 시 심볼 주소가 일관되게 유지됩니다. 또한 BIOS나 하드웨어와의 충돌을 피하여 안정적인 부팅이 보장됩니다.

실전 팁

💡 링커 스크립트를 사용하려면 타겟 JSON의 pre-link-args에 "-T링커스크립트.ld"를 추가해야 합니다. 그렇지 않으면 기본 링커 스크립트가 사용됩니다.

💡 ld --verbose 명령으로 기본 링커 스크립트를 볼 수 있습니다. 이를 기반으로 커스텀 스크립트를 작성하면 실수를 줄일 수 있습니다.

💡 KEEP() 매크로는 가비지 컬렉션으로부터 섹션을 보호합니다. 부트 코드나 인터럽트 벡터처럼 직접 참조되지 않지만 필수적인 코드에 사용하세요.

💡 심볼 주소를 Rust에서 사용할 때 주의하세요: extern "C" { static __bss_start: u8; } let addr = &__bss_start as *const u8 as usize; 처럼 참조의 주소를 가져와야 합니다.

💡 디버깅 시 objdump -h 명령으로 실제 섹션 배치를 확인하고, readelf -l로 프로그램 헤더를 검사하여 링커 스크립트가 의도대로 작동하는지 검증하세요.


6. 부트 섹션 정의 - 멀티부트 헤더 구현

시작하며

여러분이 만든 커널 바이너리가 있다면, 이것을 컴퓨터가 어떻게 인식하고 부팅할 수 있을까요? BIOS나 부트로더는 아무 파일이나 실행하지 않고, 특정 시그니처와 구조를 가진 파일만 부팅 가능한 이미지로 인식합니다.

이런 문제는 OS 개발의 첫 관문입니다. 커널 코드를 작성해도 부트로더가 인식하지 못하면 실행조차 할 수 없습니다.

GRUB 같은 부트로더는 멀티부트(Multiboot) 스펙을 따르는 헤더를 찾아 커널을 로드하죠. 바로 이럴 때 필요한 것이 부트 섹션과 멀티부트 헤더입니다.

이는 바이너리 파일 시작 부분에 특정 매직 넘버와 정보를 담아 부트로더가 커널을 올바르게 로드하고 실행할 수 있게 합니다.

개요

간단히 말해서, 멀티부트 헤더는 커널 바이너리에 포함되는 특수한 구조체로, 부트로더에게 커널 로드 방법을 알려주는 메타데이터입니다. 멀티부트 스펙은 GRUB 같은 부트로더와 OS 커널 간의 표준 인터페이스입니다.

헤더에는 매직 넘버(0x1BADB002), 플래그, 체크섬이 포함되며, 부트로더는 이를 검색하여 커널을 식별합니다. 부트로더는 헤더를 찾으면 커널을 메모리에 로드하고, 보호 모드로 전환한 후, 커널의 엔트리 포인트로 점프합니다.

예를 들어, GRUB으로 부팅하는 모든 커널이나 QEMU 직접 부팅 같은 경우에 매우 유용합니다. 기존에는 어셈블리로 복잡한 부트 코드를 작성했다면, 이제는 Rust의 상수와 repr(C) 구조체로 깔끔하게 정의할 수 있습니다.

멀티부트 헤더의 핵심 특징은 세 가지입니다: 매직 넘버 검증(0x1BADB002로 멀티부트 커널 식별), 플래그 설정(메모리 정렬, 비디오 모드 등 요구사항 지정), 그리고 위치 독립성(바이너리의 처음 8KB 내에 위치)입니다. 이러한 특징들이 범용 부트로더와의 호환성을 보장합니다.

코드 예제

// src/boot.rs - 멀티부트 헤더 정의
use core::arch::asm;

const MULTIBOOT_MAGIC: u32 = 0x1BADB002;
const MULTIBOOT_ALIGN: u32 = 1 << 0;  // 페이지 정렬 요청
const MULTIBOOT_MEMINFO: u32 = 1 << 1;  // 메모리 맵 요청
const MULTIBOOT_FLAGS: u32 = MULTIBOOT_ALIGN | MULTIBOOT_MEMINFO;
const MULTIBOOT_CHECKSUM: u32 = -(MULTIBOOT_MAGIC.wrapping_add(MULTIBOOT_FLAGS) as i32) as u32;

#[repr(C, align(4))]
struct MultibootHeader {
    magic: u32,
    flags: u32,
    checksum: u32,
}

#[link_section = ".boot"]  // .boot 섹션에 배치
#[used]  // 최적화로 제거되지 않도록
static MULTIBOOT_HEADER: MultibootHeader = MultibootHeader {
    magic: MULTIBOOT_MAGIC,
    flags: MULTIBOOT_FLAGS,
    checksum: MULTIBOOT_CHECKSUM,
};

설명

이것이 하는 일: GRUB 같은 멀티부트 호환 부트로더가 인식할 수 있는 헤더를 커널 바이너리에 삽입합니다. 첫 번째로, 매직 넘버 MULTIBOOT_MAGIC = 0x1BADB002가 멀티부트 커널의 식별자입니다.

부트로더는 바이너리 파일을 읽으면서 이 값을 검색합니다. 특이한 값을 사용하는 이유는 우연히 일반 데이터와 일치할 확률을 최소화하기 위함입니다.

이 매직 넘버를 찾으면 부트로더는 "아, 이게 부팅 가능한 커널이구나"라고 인식합니다. 그 다음으로, MULTIBOOT_FLAGS가 부트로더에게 요구사항을 알려줍니다.

MULTIBOOT_ALIGN(비트 0)은 모든 부트 모듈을 페이지 경계에 정렬해달라는 요청이고, MULTIBOOT_MEMINFO(비트 1)는 메모리 맵 정보를 제공해달라는 요청입니다. 메모리 맵은 어떤 메모리 영역이 사용 가능하고 어떤 영역이 예약되어 있는지 알려주므로 메모리 관리에 필수적입니다.

세 번째로, MULTIBOOT_CHECKSUM이 검증값으로 작동합니다. 체크섬은 (magic + flags + checksum) 합이 0이 되도록 계산됩니다.

wrapping_add를 사용하는 이유는 오버플로우를 허용하기 위함이고, 음수 변환은 이진 보수를 얻기 위함입니다. 부트로더는 이 세 값을 더해 0이 나오는지 확인하여 헤더의 무결성을 검증합니다.

네 번째로, #[link_section = ".boot"]와 #[used] 속성이 중요합니다. link_section은 이 정적 변수를 .boot 섹션에 배치하여, 링커 스크립트의 KEEP(*(.boot)) 규칙으로 바이너리 시작 부분에 오도록 합니다.

#[used]는 컴파일러 최적화가 "이 변수는 사용되지 않네요"라고 판단하여 제거하는 것을 방지합니다. 마지막으로, repr(C, align(4))가 C 언어와 호환되는 메모리 레이아웃과 4바이트 정렬을 보장합니다.

멀티부트 스펙은 헤더가 4바이트 정렬되어야 한다고 명시하므로, 이를 지키지 않으면 부트로더가 헤더를 찾지 못할 수 있습니다. 여러분이 이 헤더를 사용하면 GRUB, QEMU 직접 부팅, 기타 멀티부트 호환 부트로더로 커널을 실행할 수 있습니다.

부트로더가 메모리 맵, 커맨드 라인, 초기 RAM 디스크 등의 정보를 전달해주므로 부팅 초기 환경 설정이 간편해집니다. 또한 표준 스펙을 따르므로 다양한 플랫폼에서 일관되게 작동합니다.

실전 팁

💡 멀티부트 헤더는 바이너리의 처음 8KB 이내에 위치해야 합니다. 링커 스크립트에서 .boot 섹션을 맨 앞에 배치하여 이를 보장하세요.

💡 objdump -s -j .boot 명령으로 .boot 섹션의 바이트를 확인하여 헤더가 올바르게 생성되었는지 검증할 수 있습니다. 처음 12바이트가 매직, 플래그, 체크섬이어야 합니다.

💡 멀티부트2 스펙도 있는데, 이는 더 많은 기능(UEFI 지원, 64비트 정보 등)을 제공합니다. 최신 부트로더를 사용한다면 멀티부트2를 고려하세요.

💡 #[used] 대신 #[no_mangle]을 함께 사용할 수도 있지만, 정적 변수에는 #[used]가 더 적합합니다. 함수에는 #[no_mangle]을 사용하세요.

💡 GRUB의 grub-file --is-x86-multiboot 명령으로 바이너리가 유효한 멀티부트 이미지인지 테스트할 수 있습니다. 부팅 문제를 디버깅할 때 유용합니다.


7. 빌드 자동화 - Makefile과 빌드 스크립트

시작하며

여러분이 OS를 개발하다 보면 빌드, 링킹, 이미지 생성, QEMU 실행 등 수많은 명령어를 반복적으로 입력해야 하는 상황을 겪게 됩니다. 매번 긴 cargo 명령어와 플래그를 기억하고 입력하기는 매우 번거롭죠.

이런 문제는 프로젝트가 커질수록 심각해집니다. 부팅 가능한 이미지를 만들려면 바이너리를 특정 형식으로 변환하고, 부트로더와 결합하고, ISO 이미지를 생성해야 합니다.

단계가 많아질수록 실수할 가능성도 높아집니다. 바로 이럴 때 필요한 것이 빌드 자동화입니다.

Makefile이나 빌드 스크립트로 복잡한 빌드 과정을 단순화하여, 간단한 명령 하나로 전체 빌드부터 실행까지 자동으로 처리할 수 있습니다.

개요

간단히 말해서, 빌드 자동화는 반복적인 빌드 작업을 스크립트로 정의하여 일관되고 효율적으로 실행하는 프로세스입니다. Make는 유닉스 계열에서 가장 널리 사용되는 빌드 도구로, 타겟과 의존성 기반으로 작동합니다.

파일이 변경되면 필요한 부분만 재빌드하여 시간을 절약합니다. OS 개발에서는 커널 빌드, 이미지 생성, 에뮬레이터 실행, 정리 작업 등을 자동화할 수 있죠.

예를 들어, make run 한 번으로 빌드부터 QEMU 실행까지 모든 것을 처리하는 경우에 매우 유용합니다. 기존에는 매번 수동으로 명령어를 입력하고 옵션을 기억해야 했다면, 이제는 타겟 이름만 입력하면 됩니다.

빌드 자동화의 핵심 특징은 세 가지입니다: 의존성 관리(변경된 파일만 재빌드), 재사용 가능한 타겟(build, run, clean 등), 그리고 플랫폼 독립성(팀원 모두가 동일한 방식으로 빌드)입니다. 이러한 특징들이 개발 생산성을 크게 향상시킵니다.

코드 예제

# Makefile - 베어메탈 OS 빌드 자동화
TARGET := x86_64-bare_metal
KERNEL := target/$(TARGET)/release/bare-metal-os
ISO := build/os.iso

.PHONY: all build run clean

# 기본 타겟: ISO 이미지 생성
all: $(ISO)

# 커널 빌드
build:
	cargo build --target $(TARGET).json --release

# 부팅 가능한 ISO 이미지 생성
$(ISO): build
	@mkdir -p build/isofiles/boot/grub
	@cp $(KERNEL) build/isofiles/boot/kernel.bin
	@echo 'set timeout=0' > build/isofiles/boot/grub/grub.cfg
	@echo 'set default=0' >> build/isofiles/boot/grub/grub.cfg
	@echo 'menuentry "My OS" {' >> build/isofiles/boot/grub/grub.cfg
	@echo '    multiboot2 /boot/kernel.bin' >> build/isofiles/boot/grub/grub.cfg
	@echo '    boot' >> build/isofiles/boot/grub/grub.cfg
	@echo '}' >> build/isofiles/boot/grub/grub.cfg
	@grub-mkrescue -o $(ISO) build/isofiles 2>/dev/null

# QEMU로 실행
run: $(ISO)
	qemu-system-x86_64 -cdrom $(ISO)

# 빌드 결과물 정리
clean:
	cargo clean
	rm -rf build

설명

이것이 하는 일: 커널 빌드부터 부팅 가능한 ISO 이미지 생성, QEMU 실행까지 전체 워크플로우를 자동화합니다. 첫 번째로, 변수 정의 부분에서 TARGET, KERNEL, ISO 경로를 설정합니다.

이렇게 변수로 분리하면 경로가 변경되어도 한 곳만 수정하면 됩니다. $(변수명) 문법으로 다른 곳에서 재사용할 수 있죠.

.PHONY는 이 타겟들이 실제 파일이 아닌 명령어임을 Make에게 알려줍니다. 그 다음으로, build 타겟이 cargo build를 실행합니다.

--release 플래그로 최적화된 작은 바이너리를 생성합니다. Make는 KERNEL 파일의 타임스탬프를 확인하여, 소스 코드가 변경되지 않았다면 이 단계를 건너뜁니다.

이는 증분 빌드로 시간을 절약합니다. 세 번째로, $(ISO) 타겟이 가장 복잡한 작업을 수행합니다.

build에 의존하므로 커널이 먼저 빌드되고, 그 다음 ISO 디렉토리 구조를 만듭니다. mkdir -p는 중첩 디렉토리를 생성하고, cp로 커널 바이너리를 복사합니다.

@ 접두사는 명령어를 출력하지 않고 조용히 실행합니다. 네 번째로, grub.cfg 파일이 생성됩니다.

이는 GRUB 부트로더 설정으로, 타임아웃 0초에 자동으로 "My OS" 항목을 부팅합니다. multiboot2 명령으로 커널을 로드하고 boot로 실행을 시작하죠.

echo 명령으로 라인을 하나씩 추가하며, >>는 파일에 추가(append)하는 리다이렉션입니다. 다섯 번째로, grub-mkrescue가 ISO 이미지를 생성합니다.

이 도구는 부팅 가능한 ISO를 만들어 CD-ROM이나 가상 머신에서 실행할 수 있게 합니다. 2>/dev/null은 경고 메시지를 억제합니다.

마지막으로, run 타겟이 ISO에 의존하여 자동으로 빌드-이미지 생성 과정을 거친 후 QEMU를 실행합니다. 여러분은 make run만 입력하면 되고, Make가 의존성 그래프를 따라 필요한 모든 단계를 순서대로 실행합니다.

여러분이 이 Makefile을 사용하면 복잡한 빌드 과정을 간단한 명령어로 실행할 수 있습니다. 팀원들이 동일한 방식으로 빌드하므로 "내 환경에서는 되는데요" 같은 문제가 사라집니다.

또한 CI/CD 파이프라인에서도 동일한 Makefile을 사용하여 일관된 빌드를 보장합니다. 의존성 추적으로 불필요한 재빌드를 피하여 개발 속도가 빨라지죠.

실전 팁

💡 Make는 탭 문자에 민감합니다. 명령어 앞에 반드시 탭(Tab)을 사용해야 하며, 스페이스를 사용하면 에러가 발생합니다. 편집기 설정을 확인하세요.

💡 -j 플래그로 병렬 빌드를 활성화하면 빨라집니다: make -j4 run. 하지만 의존성이 잘못 정의되면 레이스 컨디션이 발생할 수 있으니 주의하세요.

💡 @echo "메시지"로 빌드 진행 상황을 표시하면 사용자 경험이 좋아집니다. 예: @echo "Building kernel..." 같은 메시지를 각 단계에 추가하세요.

💡 $(shell 명령어) 문법으로 셸 명령의 출력을 변수에 저장할 수 있습니다. 예: DATE := $(shell date +%Y%m%d)로 빌드 날짜를 포함시킬 수 있습니다.

💡 make -n 또는 make --dry-run으로 실제로 실행하지 않고 어떤 명령이 실행될지 미리 볼 수 있습니다. 디버깅과 확인에 유용합니다.


8. QEMU 테스트 환경 - 빠른 개발 사이클

시작하며

여러분이 OS를 개발할 때마다 실제 하드웨어에 설치하고 재부팅해야 한다면 얼마나 불편할까요? 버그 하나를 고치는 데도 몇 분씩 걸릴 것이고, 하드웨어가 손상될 위험도 있습니다.

이런 문제는 시스템 프로그래밍에서 가장 큰 장벽 중 하나였습니다. 과거에는 실제 머신을 부팅하거나, 최소한 듀얼 부팅 환경을 구축해야 했죠.

디버깅도 어렵고, 실수로 디스크를 날려버리는 사고도 빈번했습니다. 바로 이럴 때 필요한 것이 QEMU입니다.

이는 완전한 하드웨어 에뮬레이터로, 여러분의 개발 머신 위에서 가상의 컴퓨터를 실행하여 OS를 안전하고 빠르게 테스트할 수 있게 합니다.

개요

간단히 말해서, QEMU는 오픈소스 머신 에뮬레이터이자 가상화 프로그램으로, 다양한 CPU 아키텍처를 소프트웨어로 시뮬레이션합니다. QEMU는 전체 시스템 에뮬레이션을 제공하여 CPU, 메모리, 디스크, 네트워크 등을 가상화합니다.

OS 개발에서는 실제 하드웨어 없이 커널을 테스트하고, GDB로 디버깅하며, 스냅샷으로 상태를 저장/복원할 수 있습니다. 예를 들어, x86_64 커널을 ARM 맥북에서 개발하거나, 다양한 하드웨어 구성을 테스트하는 경우에 매우 유용합니다.

기존에는 실제 하드웨어나 느린 VMware/VirtualBox를 사용했다면, 이제는 몇 초 만에 부팅하고 테스트할 수 있습니다. QEMU의 핵심 특징은 세 가지입니다: 다중 아키텍처 지원(x86, ARM, RISC-V 등), GDB 통합(원격 디버깅 지원), 그리고 유연한 장치 에뮬레이션(시리얼 포트, VGA, 네트워크 등)입니다.

이러한 특징들이 빠른 개발-테스트 사이클을 가능하게 합니다.

코드 예제

# QEMU 실행 스크립트 - run.sh
#!/bin/bash

# 기본 QEMU 실행 (ISO 부팅)
qemu-system-x86_64 \
    -cdrom build/os.iso \
    -m 128M \                  # 128MB 메모리
    -serial stdio \            # 시리얼 출력을 터미널로
    -no-reboot \               # 리부트 시 종료
    -display gtk \             # GTK 디스플레이
    -enable-kvm                # KVM 가속 (리눅스)

# GDB 디버깅 모드
# qemu-system-x86_64 \
#     -cdrom build/os.iso \
#     -m 128M \
#     -serial stdio \
#     -s \                     # GDB 서버를 1234 포트에서 실행
#     -S \                     # 시작 시 정지, GDB 연결 대기
#     -no-reboot

설명

이것이 하는 일: QEMU 가상 머신에서 OS를 실행하고, 디버깅과 로깅을 위한 환경을 설정합니다. 첫 번째로, qemu-system-x86_64 명령이 x86_64 아키텍처를 에뮬레이트하는 가상 머신을 시작합니다.

-cdrom build/os.iso는 ISO 이미지를 가상 CD-ROM 드라이브로 연결하여, BIOS가 이를 부팅 가능한 미디어로 인식하고 GRUB를 로드합니다. 실제 CD를 굽지 않고도 ISO를 테스트할 수 있죠.

그 다음으로, -m 128M이 가상 머신에 128MB의 RAM을 할당합니다. OS 개발 초기에는 이 정도면 충분하고, 나중에 필요하면 늘릴 수 있습니다.

메모리를 적게 할당하면 부팅이 빠르고, 메모리 부족 상황을 일부러 테스트할 수도 있습니다. 세 번째로, -serial stdio가 매우 중요합니다.

이는 가상 머신의 시리얼 포트(COM1)를 표준 입출력으로 리다이렉션합니다. 여러분의 커널이 시리얼 포트로 로그를 출력하면, 이것이 터미널에 표시됩니다.

VGA 텍스트 모드보다 시리얼이 디버깅에 훨씬 유용합니다. 로그를 파일로 저장하거나, 파이프로 다른 프로그램에 전달할 수도 있습니다.

네 번째로, -no-reboot 옵션은 커널 패닉이나 트리플 폴트 발생 시 QEMU를 종료합니다. 이 옵션이 없으면 무한 재부팅 루프에 빠질 수 있는데, 종료되면 문제를 빨리 인지할 수 있습니다.

CI 환경에서 특히 유용합니다. 다섯 번째로, -enable-kvm은 리눅스 호스트에서 KVM(Kernel-based Virtual Machine) 가속을 활성화합니다.

이는 CPU 가상화 확장(Intel VT-x, AMD-V)을 사용하여 에뮬레이션 속도를 극적으로 향상시킵니다. 맥OS에서는 hvf(Hypervisor Framework)를, 윈도우에서는 HAXM을 사용할 수 있습니다.

마지막으로, 주석 처리된 디버깅 모드 옵션들입니다. -s는 TCP 1234 포트에서 GDB 서버를 시작하여, 다른 터미널에서 gdb를 연결할 수 있게 합니다.

-S는 부팅 직후 CPU를 정지시켜 GDB가 연결될 때까지 기다립니다. 이렇게 하면 부트 코드의 첫 명령어부터 단계별로 실행하며 디버깅할 수 있습니다.

여러분이 이 QEMU 설정을 사용하면 수정-빌드-테스트 사이클이 몇 초로 단축됩니다. 하드웨어를 손상시킬 걱정 없이 실험할 수 있고, GDB로 커널 내부를 들여다보며 버그를 추적할 수 있습니다.

시리얼 로그로 부팅 과정을 모니터링하고, 스크립트로 자동화된 테스트를 실행하는 것도 가능합니다. 개발 생산성이 비약적으로 향상되죠.

실전 팁

💡 -d int,cpu_reset -D qemu.log 옵션으로 인터럽트와 CPU 리셋을 로그 파일에 기록할 수 있습니다. 트리플 폴트 디버깅에 필수적입니다.

💡 -monitor stdio 대신 -serial stdio를 사용하면 QEMU 모니터가 아닌 커널 시리얼 출력을 봅니다. 둘 다 사용하려면 -monitor telnet::4444,server,nowait로 모니터를 분리하세요.

💡 GDB 연결 시 target remote :1234로 연결하고, symbol-file kernel.elf로 심볼을 로드한 후 continue 또는 stepi로 실행합니다. .gdbinit 파일로 자동화할 수 있습니다.

💡 -drive file=disk.img,format=raw로 가상 디스크를 연결하여 파일 시스템을 테스트할 수 있습니다. dd로 빈 디스크 이미지를 만들고 사용하세요.

💡 Ctrl+A, X 키 조합으로 QEMU를 빠르게 종료할 수 있습니다. 커널이 무한 루프에 빠졌을 때 유용합니다.


9. Core 라이브러리 활용 - no_std에서 사용 가능한 기능들

시작하며

여러분이 no_std 환경으로 전환하면서 Vec, String, println! 같은 익숙한 도구들을 모두 잃어버렸다고 느낄 수 있습니다.

하지만 정말 아무것도 사용할 수 없는 걸까요? 표준 라이브러리의 일부는 OS에 의존하지 않는데요.

이런 문제는 베어메탈 개발을 막막하게 만듭니다. Option, Result 같은 기본 타입이나 Iterator 트레이트를 못 쓴다면 Rust의 장점을 전혀 살릴 수 없겠죠.

다행히 Rust는 이를 고려해서 설계되었습니다. 바로 이럴 때 필요한 것이 core 라이브러리입니다.

이는 표준 라이브러리에서 OS 독립적인 부분만 추출한 것으로, no_std 환경에서도 Rust의 강력한 타입 시스템과 추상화를 사용할 수 있게 합니다.

개요

간단히 말해서, core는 OS에 의존하지 않는 Rust의 핵심 기능만 제공하는 라이브러리로, no_std 환경에서 자동으로 사용됩니다. 표준 라이브러리(std)는 core를 기반으로 구축되며, 파일 시스템, 네트워킹 같은 OS 기능을 추가합니다.

core는 Option, Result, Iterator, 슬라이스, 트레이트, 프리미티브 타입 등 언어의 핵심 기능을 제공하죠. 메모리 할당이 필요 없는 모든 것을 사용할 수 있습니다.

예를 들어, 반복자 체인, 패턴 매칭, 트레이트 바운드 같은 경우에 매우 유용합니다. 기존에는 표준 라이브러리 전체를 사용하거나 아무것도 못 쓰거나 둘 중 하나였다면, 이제는 필요한 부분만 선택적으로 사용합니다.

core의 핵심 특징은 세 가지입니다: 제로 비용 추상화(런타임 오버헤드 없음), 타입 안전성(borrow checker와 타입 시스템 사용 가능), 그리고 할당 불필요(힙 메모리 없이 작동)입니다. 이러한 특징들이 베어메탈 환경에서도 현대적인 프로그래밍을 가능하게 합니다.

코드 예제

// core 라이브러리 사용 예제
#![no_std]

use core::fmt::{self, Write};
use core::slice;
use core::ptr;

// Option과 Result 사용 가능
fn find_first_nonzero(data: &[u8]) -> Option<usize> {
    data.iter()
        .position(|&x| x != 0)  // Iterator 트레이트 사용
}

// 포인터를 안전하게 다루기
unsafe fn read_volatile(addr: usize) -> u32 {
    ptr::read_volatile(addr as *const u32)
}

// 슬라이스 조작
fn split_array(data: &mut [u8], mid: usize) -> (&mut [u8], &mut [u8]) {
    data.split_at_mut(mid)
}

// 제네릭과 트레이트 정상 작동
fn swap<T>(a: &mut T, b: &mut T) {
    core::mem::swap(a, b);
}

설명

이것이 하는 일: no_std 환경에서 core 라이브러리의 다양한 기능을 활용하여 안전하고 표현력 있는 코드를 작성합니다. 첫 번째로, Option과 Iterator를 사용한 find_first_nonzero 함수입니다.

.iter()로 슬라이스를 반복자로 변환하고, .position()으로 조건을 만족하는 첫 번째 요소의 인덱스를 찾습니다. 이 모든 것이 core에 포함되어 있으며, 할당 없이 작동합니다.

Option<usize>로 값이 없을 수도 있음을 타입으로 표현하죠. 그 다음으로, ptr::read_volatile로 메모리 매핑된 I/O를 안전하게 읽습니다.

volatile은 컴파일러에게 "이 메모리 읽기를 최적화로 제거하지 마세요"라고 알려줍니다. MMIO 레지스터는 읽을 때마다 값이 바뀔 수 있으므로, volatile 접근이 필수입니다.

core::ptr 모듈은 로우 레벨 포인터 조작을 위한 다양한 함수를 제공합니다. 세 번째로, split_at_mut로 슬라이스를 두 개의 mutable 슬라이스로 나눕니다.

일반적으로는 하나의 값에 대해 두 개의 mutable 참조를 가질 수 없지만, split_at_mut는 겹치지 않는 영역이므로 안전하게 두 참조를 반환합니다. core는 이런 안전한 저수준 API를 많이 제공합니다.

네 번째로, 제네릭 함수 swap이 core::mem::swap을 사용합니다. 이는 두 값을 임시 변수 없이 교환하는데, 내부적으로 ptr::read와 ptr::write를 사용하여 할당 없이 작동합니다.

제네릭, 트레이트, 타입 추론 등 Rust의 고급 기능이 모두 core에서 사용 가능합니다. 마지막으로, use core::fmt::Write 임포트는 포매팅 트레이트를 가져옵니다.

이를 구현하면 write! 매크로를 사용하여 커스텀 출력 장치(시리얼 포트, VGA 버퍼 등)에 포맷된 텍스트를 쓸 수 있습니다.

println!은 못 쓰지만 동일한 포매팅 시스템을 사용할 수 있죠. 여러분이 core 라이브러리를 활용하면 no_std 환경에서도 생산적인 개발이 가능합니다.

로우 레벨 하드웨어 제어와 고수준 추상화를 동시에 사용하여, 안전하면서도 효율적인 코드를 작성합니다. 반복자, 패턴 매칭, 에러 처리 등 Rust의 강점을 모두 살릴 수 있어 C보다 훨씬 생산적입니다.

실전 팁

💡 std::를 core::로 바꾸면 대부분의 코드가 그대로 작동합니다. Vec, String 같은 할당 타입만 제거하면 됩니다.

💡 core::intrinsics 모듈에는 컴파일러 내장 함수들이 있습니다. unreachable(), size_of(), transmute() 등 유용하지만 unsafe한 함수들을 제공합니다.

💡 core::sync::atomic으로 원자적 연산을 사용할 수 있습니다. 멀티코어 환경에서 락 없는 동기화를 구현할 때 필수적입니다.

💡 core::arch 모듈에는 아키텍처별 SIMD 명령어 인트린식이 있습니다. x86_64::_mm_pause() 같은 저수준 명령을 안전하게 호출할 수 있습니다.

💡 #![feature()] 속성으로 nightly 전용 core 기능을 사용할 수 있습니다. error_in_core 같은 실험적 기능을 시도해보세요.


10. Alloc 크레이트 통합 - 베어메탈에서 힙 메모리 사용하기

시작하며

여러분이 no_std 환경에서 개발하다가 동적 크기의 데이터 구조가 필요한 순간을 맞이했나요? 프로세스 목록, 파일 시스템 캐시, 네트워크 버퍼 등은 컴파일 타임에 크기를 알 수 없어서 Vec이나 Box가 필요합니다.

이런 문제는 OS가 복잡해질수록 피할 수 없게 됩니다. 모든 것을 정적 배열로 처리하기에는 메모리 낭비가 심하고, 유연성도 떨어지죠.

하지만 표준 라이브러리의 Vec은 no_std에서 사용할 수 없습니다. 바로 이럴 때 필요한 것이 alloc 크레이트입니다.

이는 힙 할당에 의존하는 타입들(Vec, Box, String, Rc 등)을 제공하되, OS가 아닌 여러분이 제공하는 커스텀 할당자를 사용하여 작동합니다.

개요

간단히 말해서, alloc은 힙 메모리 할당을 추상화한 크레이트로, GlobalAlloc 트레이트를 구현하면 no_std 환경에서도 동적 메모리를 사용할 수 있게 합니다. alloc은 core와 std 사이에 위치하며, 할당이 필요한 타입들을 제공합니다.

여러분이 힙 메모리를 어디서 가져올지(물리 메모리, 페이지 할당자 등)만 정의하면, Vec, Box, String 등을 일반 Rust처럼 사용할 수 있습니다. 예를 들어, 커널에서 프로세스 관리, 파일 시스템 구현, 동적 자료구조 같은 경우에 매우 유용합니다.

기존에는 힙 메모리를 직접 관리하고 포인터 산술을 해야 했다면, 이제는 안전한 스마트 포인터와 컬렉션을 사용할 수 있습니다. alloc 크레이트의 핵심 특징은 세 가지입니다: OS 독립적 할당(커스텀 할당자 사용), 표준 컬렉션 제공(Vec, BTreeMap, Rc 등), 그리고 안전한 메모리 관리(RAII와 drop으로 자동 해제)입니다.

이러한 특징들이 복잡한 OS 기능을 안전하게 구현할 수 있게 합니다.

코드 예제

// src/allocator.rs - 간단한 범프 할당자
#![feature(alloc_error_handler)]

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

extern crate alloc;
use alloc::vec::Vec;

// 범프 할당자 구조체
struct BumpAllocator {
    heap_start: usize,
    heap_end: usize,
    next: core::cell::UnsafeCell<usize>,
}

unsafe impl GlobalAlloc for BumpAllocator {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        let next = *self.next.get();
        let aligned = (next + layout.align() - 1) & !(layout.align() - 1);
        let new_next = aligned + layout.size();

        if new_next > self.heap_end {
            null_mut()  // 메모리 부족
        } else {
            *self.next.get() = new_next;
            aligned as *mut u8
        }
    }

    unsafe fn dealloc(&self, _ptr: *mut u8, _layout: Layout) {
        // 범프 할당자는 개별 해제 안 함
    }
}

#[global_allocator]
static ALLOCATOR: BumpAllocator = BumpAllocator {
    heap_start: 0x_4444_4444_0000,
    heap_end: 0x_4444_4444_0000 + 100 * 1024,  // 100KB
    next: core::cell::UnsafeCell::new(0x_4444_4444_0000),
};

#[alloc_error_handler]
fn alloc_error(_layout: Layout) -> ! {
    panic!("Out of memory!");
}

설명

이것이 하는 일: 간단한 범프 포인터 할당자를 구현하여 no_std 환경에서 동적 메모리 할당을 가능하게 합니다. 첫 번째로, BumpAllocator 구조체가 힙 메모리 영역을 관리합니다.

heap_start와 heap_end가 사용 가능한 메모리 범위를 정의하고, next는 다음 할당 위치를 추적합니다. UnsafeCell은 내부 가변성을 제공하여 &self 참조로도 next를 수정할 수 있게 합니다.

범프 할당자는 가장 간단한 할당자로, 포인터를 계속 앞으로 이동시키기만 합니다. 그 다음으로, GlobalAlloc 트레이트를 구현합니다.

alloc 메서드는 요청된 Layout(크기와 정렬)에 맞는 메모리를 반환합니다. aligned 계산은 정렬 요구사항을 만족시키기 위해 next를 올림합니다.

예를 들어 8바이트 정렬이 필요하면 주소를 8의 배수로 맞춥니다. new_next가 힙 끝을 넘으면 null_mut()를 반환하여 할당 실패를 알립니다.

세 번째로, dealloc 메서드는 비어있습니다. 범프 할당자는 개별 해제를 지원하지 않고, 전체 힙을 한 번에 리셋하는 방식으로만 메모리를 재사용합니다.

이는 단순하지만 제한적이므로, 실제 OS에서는 더 정교한 할당자(slab, buddy 등)를 구현해야 합니다. 네 번째로, #[global_allocator] 속성이 ALLOCATOR를 전역 할당자로 등록합니다.

이제 코드 어디서든 Vec::new(), Box::new() 같은 함수를 호출하면 자동으로 이 할당자가 사용됩니다. 컴파일러가 __rust_alloc 같은 심볼을 이 할당자의 메서드에 연결하죠.

다섯 번째로, #[alloc_error_handler]는 할당 실패 시 호출되는 함수를 정의합니다. no_std 환경에서는 이를 반드시 구현해야 합니다.

여기서는 단순히 패닉하지만, 실제 OS에서는 프로세스를 종료하거나 메모리를 회수하는 등의 복구 로직을 구현할 수 있습니다. 마지막으로, extern crate alloc으로 alloc 크레이트를 임포트하고 Vec을 사용할 준비를 합니다.

Cargo.toml에 alloc을 명시적으로 추가할 필요는 없으며, rustc가 자동으로 제공합니다. 여러분이 이 할당자를 사용하면 베어메탈 환경에서도 Vec, Box, String, Arc 등을 자유롭게 사용할 수 있습니다.

복잡한 자료구조를 안전하게 관리하고, RAII 패턴으로 메모리 누수를 방지하며, 이터레이터와 클로저 등 고급 기능을 활용할 수 있습니다. OS 개발이 훨씬 생산적이고 안전해지죠.

실전 팁

💡 범프 할당자는 프로토타입에는 좋지만 실전에서는 프래그먼테이션이 심합니다. linked_list_allocator나 buddy_system_allocat


#Rust#Bare-Metal#OS-Development#Cross-Compile#Custom-Target#시스템프로그래밍

댓글 (0)

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