이미지 로딩 중...

Rust로 만드는 나만의 OS no_std 환경 이해하기 - 슬라이드 1/11
A

AI Generated

2025. 11. 13. · 3 Views

Rust로 만드는 나만의 OS no_std 환경 이해하기

운영체제 개발의 첫걸음, no_std 환경에서 Rust 표준 라이브러리 없이 베어메탈 프로그래밍을 시작하는 방법을 배웁니다. panic handler, 언어 아이템, 링커 스크립트 설정까지 실전 예제로 완벽하게 마스터하세요.


목차

  1. no_std 속성과 표준 라이브러리 비활성화 - OS 개발의 시작점
  2. panic_handler 구현 - 에러 처리의 핵심
  3. eh_personality와 언어 아이템 - 예외 처리 메커니즘
  4. 커스텀 타겟 스펙 생성 - 타겟 트리플 이해하기
  5. 링커 스크립트 작성 - 메모리 레이아웃 제어
  6. VGA 텍스트 모드 출력 - 첫 번째 화면 출력
  7. 전역 할당자 없이 작동하기 - 스택 기반 프로그래밍
  8. 인라인 어셈블리 활용 - 하드웨어 직접 제어
  9. 부트로더 통합 - bootloader 크레이트 활용
  10. no_std 테스트 프레임워크 - 자동화된 테스트

1. no_std 속성과 표준 라이브러리 비활성화 - OS 개발의 시작점

시작하며

여러분이 Rust로 일반 애플리케이션을 개발할 때는 Vec, String, File 같은 편리한 타입들을 자유롭게 사용하셨을 겁니다. 하지만 운영체제를 만들려고 할 때 cargo build를 실행하면 갑자기 수많은 에러가 쏟아지는 상황을 겪어본 적 있나요?

이런 문제는 Rust 표준 라이브러리(std)가 운영체제의 기능(파일 시스템, 메모리 할당자, 스레드 등)에 의존하기 때문에 발생합니다. 아직 운영체제가 없는 베어메탈 환경에서는 이러한 기능들을 사용할 수 없죠.

표준 라이브러리는 약 200KB 이상의 크기를 차지하며, 수많은 OS 시스템 콜에 의존합니다. 바로 이럴 때 필요한 것이 no_std 속성입니다.

이것은 Rust 표준 라이브러리를 비활성화하고 core 라이브러리만 사용하여 운영체제 없이도 실행 가능한 바이너리를 만들 수 있게 해줍니다.

개요

간단히 말해서, no_std는 Rust 표준 라이브러리를 사용하지 않겠다는 선언입니다. 이를 통해 OS 의존성 없이 코드를 컴파일할 수 있습니다.

왜 이 개념이 필요한지 실무 관점에서 설명하자면, 운영체제 커널, 임베디드 펌웨어, 부트로더, 하이퍼바이저 같은 저수준 시스템을 개발할 때는 OS의 도움을 받을 수 없습니다. 예를 들어, 컴퓨터가 부팅될 때 BIOS나 UEFI 이후 실행되는 첫 번째 코드는 아직 운영체제가 없는 상태에서 동작해야 합니다.

기존에는 std 라이브러리를 통해 println!, Vec, HashMap 등을 사용했다면, 이제는 core 라이브러리만 사용하여 Option, Result, 기본 타입, 트레이트 등으로 프로그래밍해야 합니다. no_std의 핵심 특징은 첫째, 메모리 할당자가 없어 힙 할당이 불가능하고, 둘째, 운영체제 기능(파일, 네트워크, 스레드)에 접근할 수 없으며, 셋째, 컴파일 타임에 알 수 있는 고정 크기 데이터만 사용해야 한다는 것입니다.

이러한 특징들이 중요한 이유는 베어메탈 환경에서는 모든 것을 직접 구현해야 하기 때문입니다.

코드 예제

// no_std 속성으로 표준 라이브러리 비활성화
#![no_std]
#![no_main]

// core 라이브러리는 자동으로 사용 가능
use core::panic::PanicInfo;

// 프로그램 진입점 정의 (main이 아님)
#[no_mangle]
pub extern "C" fn _start() -> ! {
    // 베어메탈 환경에서의 초기화 코드
    // 여기서는 무한 루프로 CPU를 정지시킴
    loop {}
}

// panic 발생 시 호출될 함수 (필수)
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    loop {}
}

설명

이것이 하는 일: 이 코드는 운영체제 없이 실행 가능한 가장 기본적인 Rust 프로그램을 정의합니다. 일반적인 Rust 프로그램과 달리 표준 라이브러리를 사용하지 않고, 베어메탈 환경에서 직접 실행될 수 있도록 구성되어 있습니다.

첫 번째로, #![no_std]와 #![no_main] 속성은 컴파일러에게 표준 라이브러리와 표준 main 함수를 사용하지 않겠다고 선언합니다. 이렇게 하는 이유는 표준 라이브러리가 OS의 시스템 콜에 의존하기 때문이며, 우리는 OS 자체를 만들고 있으므로 이러한 의존성을 제거해야 합니다.

그 다음으로, _start 함수가 실행되면서 프로그램의 진입점 역할을 합니다. #[no_mangle]은 Rust의 이름 맹글링을 방지하여 링커가 정확히 "_start"라는 이름으로 이 함수를 찾을 수 있게 하고, extern "C"는 C 호출 규약을 사용하도록 지정합니다.

반환 타입 !는 이 함수가 절대 반환하지 않는다는 것을 의미하며, OS 커널은 종료할 곳이 없기 때문에 무한 루프로 실행됩니다. 마지막으로, #[panic_handler]는 패닉 상황(예: 배열 범위 초과, unwrap 실패)이 발생했을 때 호출될 함수를 정의합니다.

일반 Rust 프로그램에서는 표준 라이브러리가 패닉 핸들러를 제공하지만, no_std 환경에서는 직접 구현해야 합니다. 여기서는 단순히 무한 루프로 CPU를 정지시켜 시스템을 안전한 상태로 유지합니다.

여러분이 이 코드를 사용하면 OS 의존성 없이 컴파일 가능한 바이너리를 생성할 수 있습니다. 이것은 부트로더 개발, 임베디드 시스템 프로그래밍, 커널 모듈 작성의 기초가 되며, 하드웨어를 직접 제어할 수 있는 완전한 자유를 제공합니다.

실전 팁

💡 cargo build 실행 시 --target 옵션으로 타겟 트리플을 지정해야 합니다. 예: cargo build --target x86_64-unknown-none으로 베어메탈 타겟을 사용하세요.

💡 흔한 실수로 core::fmt를 사용한 포맷팅이 안 된다고 생각하는데, core::fmt는 사용 가능하며 직접 write! 매크로를 구현할 수 있습니다.

💡 no_std 환경에서 디버깅할 때는 QEMU의 직렬 포트로 로그를 출력하거나, 특정 메모리 주소에 값을 써서 확인하는 방법을 사용하세요.

💡 core::slice, core::option, core::result 등 core 라이브러리의 대부분 기능은 no_std에서도 사용 가능하니 문서를 꼼꼼히 확인하세요.

💡 성능 최적화를 위해 #![no_std] 환경에서는 release 모드로 빌드하고, LTO(Link Time Optimization)를 활성화하면 바이너리 크기를 크게 줄일 수 있습니다.


2. panic_handler 구현 - 에러 처리의 핵심

시작하며

여러분이 no_std 프로젝트를 처음 만들고 cargo build를 실행했을 때 "language item required, but not found: panic_impl"라는 에러를 만나본 적 있나요? 이것은 no_std 환경에서 가장 먼저 마주치는 필수 요구사항입니다.

이런 문제는 Rust의 안전성 보장 메커니즘과 관련이 있습니다. Rust는 배열 인덱스 범위 초과, Option::unwrap() 실패, 정수 오버플로우(디버그 모드) 등의 상황에서 패닉을 발생시켜 프로그램을 안전하게 중단시킵니다.

일반 프로그램에서는 표준 라이브러리가 패닉 시 스택 트레이스를 출력하고 프로세스를 종료하지만, OS가 없는 환경에서는 이런 처리를 직접 구현해야 합니다. 바로 이럴 때 필요한 것이 panic_handler입니다.

이것은 패닉 상황을 감지하고 적절하게 대응하여 시스템을 안전한 상태로 만드는 역할을 합니다.

개요

간단히 말해서, panic_handler는 Rust 프로그램에서 복구 불가능한 에러가 발생했을 때 호출되는 함수입니다. no_std 환경에서는 반드시 직접 구현해야 하는 언어 아이템(language item)입니다.

왜 이 개념이 필요한지 실무 관점에서 설명하자면, OS 커널이나 임베디드 시스템에서 패닉이 발생하면 그냥 프로그램을 종료할 수 없습니다. 대신 에러 정보를 직렬 포트로 출력하거나, LED를 깜빡이거나, 시스템을 재부팅하는 등의 커스텀 동작을 정의해야 합니다.

예를 들어, 화성 탐사 로버의 펌웨어에서 패닉이 발생하면 안전 모드로 전환하고 지구에 오류 보고를 전송해야 할 것입니다. 기존 표준 라이브러리에서는 패닉 시 자동으로 스택 트레이스를 출력하고 프로세스를 종료했다면, 이제는 시스템의 특성에 맞게 CPU를 정지시키거나, 워치독 타이머를 트리거하거나, 디버그 정보를 특수 메모리 영역에 저장하는 등의 동작을 직접 구현할 수 있습니다.

panic_handler의 핵심 특징은 첫째, 절대 반환하지 않는 diverging function(-> !)이어야 하고, 둘째, &PanicInfo 파라미터를 통해 패닉 메시지와 위치 정보를 받으며, 셋째, 프로젝트에 정확히 하나만 존재해야 한다는 것입니다. 이러한 특징들이 중요한 이유는 컴파일러가 패닉 상황에서 어떤 코드를 실행할지 명확히 알아야 하고, 패닉 후에는 절대 정상 실행으로 돌아갈 수 없기 때문입니다.

코드 예제

use core::panic::PanicInfo;
use core::fmt::Write;

// 직렬 포트 출력을 위한 간단한 래퍼
struct SerialPort;

impl Write for SerialPort {
    fn write_str(&mut self, s: &str) -> core::fmt::Result {
        // 실제로는 0x3F8 포트에 바이트 단위로 출력
        for byte in s.bytes() {
            unsafe {
                core::arch::asm!("out 0x3F8, al", in("al") byte);
            }
        }
        Ok(())
    }
}

// 패닉 핸들러 구현 - 에러 정보를 직렬 포트로 출력
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
    let mut serial = SerialPort;
    let _ = writeln!(serial, "KERNEL PANIC!");

    // 패닉 메시지 출력
    if let Some(message) = info.message() {
        let _ = writeln!(serial, "Message: {}", message);
    }

    // 패닉 발생 위치 출력
    if let Some(location) = info.location() {
        let _ = writeln!(serial, "Location: {}:{}:{}",
            location.file(), location.line(), location.column());
    }

    // CPU 정지 - 무한 루프와 hlt 명령어로 전력 절약
    loop {
        unsafe { core::arch::asm!("hlt"); }
    }
}

설명

이것이 하는 일: 이 코드는 Rust 프로그램에서 패닉이 발생했을 때 에러 정보를 직렬 포트로 출력하고 CPU를 안전하게 정지시키는 커스텀 패닉 핸들러를 구현합니다. 이는 디버깅에 매우 중요한 정보를 제공하며, 시스템을 예측 가능한 상태로 유지합니다.

첫 번째로, SerialPort 구조체와 Write 트레이트 구현은 직렬 포트(COM1, 0x3F8)로 텍스트를 출력하는 기능을 제공합니다. write_str 메서드 내부에서는 인라인 어셈블리를 사용하여 각 바이트를 out 명령어로 직렬 포트에 전송합니다.

이렇게 하는 이유는 표준 라이브러리의 println!이 없는 환경에서 디버그 메시지를 출력할 유일한 방법이기 때문입니다. 그 다음으로, panic 함수가 실행되면서 PanicInfo 객체로부터 패닉에 대한 상세 정보를 추출합니다.

info.message()는 panic!("error: {}", value) 같은 호출에서 전달된 메시지를 가져오고, info.location()은 패닉이 발생한 소스 코드의 정확한 파일명, 줄 번호, 컬럼 위치를 제공합니다. 이 정보들은 QEMU나 실제 하드웨어에서 직렬 콘솔을 통해 확인할 수 있어 디버깅에 결정적인 역할을 합니다.

세 번째 단계로, 모든 정보를 출력한 후 무한 루프에 진입하며, 각 반복마다 hlt(halt) 명령어를 실행합니다. 단순한 loop {}는 CPU를 100% 사용하여 전력을 낭비하고 열을 발생시키지만, hlt 명령어는 다음 인터럽트가 발생할 때까지 CPU를 저전력 상태로 전환하여 효율적입니다.

마지막으로, 반환 타입 !는 이 함수가 절대 반환하지 않음을 타입 시스템 수준에서 보장합니다. 컴파일러는 이를 통해 panic 이후의 코드는 실행되지 않는다는 것을 알고, 죽은 코드(dead code)를 제거하는 등의 최적화를 수행할 수 있습니다.

여러분이 이 코드를 사용하면 패닉 발생 시 정확한 에러 위치를 파악하여 디버깅 시간을 크게 단축할 수 있습니다. 또한 시스템이 예측 불가능한 상태로 계속 실행되는 것을 방지하여 데이터 손상이나 하드웨어 오동작을 예방할 수 있으며, 실제 제품 환경에서는 패닉 로그를 플래시 메모리에 저장하거나 네트워크로 전송하는 등의 고급 기능으로 확장할 수 있습니다.

실전 팁

💡 실무에서는 패닉 정보를 휘발성 메모리가 아닌 플래시나 EEPROM에 저장하여 재부팅 후에도 확인할 수 있도록 구현하세요.

💡 흔한 실수로 panic_handler 내부에서 다시 패닉을 일으키면 무한 재귀가 발생할 수 있으니, writeln! 결과를 let _ =로 무시하여 두 번째 패닉을 방지하세요.

💡 디버깅 시 QEMU에서 -serial stdio 옵션을 사용하면 직렬 포트 출력을 터미널에서 직접 볼 수 있어 매우 편리합니다.

💡 패닉 핸들러에서 스택 언와인딩을 구현하려면 eh_personality 언어 아이템도 필요하지만, 대부분의 OS 개발에서는 panic=abort 옵션으로 간단히 처리합니다.

💡 성능 최적화를 위해 release 빌드에서는 패닉 메시지를 제거하고 LED 깜빡임이나 재부팅만 수행하도록 조건부 컴파일(#[cfg(debug_assertions)])을 활용하세요.


3. eh_personality와 언어 아이템 - 예외 처리 메커니즘

시작하며

여러분이 panic_handler를 구현하고 나서 특정 타겟으로 컴파일하려고 할 때 "language item required, but not found: eh_personality"라는 에러를 만난 적 있나요? 특히 x86_64-unknown-linux-gnu 같은 타겟에서 이런 현상이 발생합니다.

이런 문제는 Rust의 예외 처리(exception handling) 메커니즘과 관련이 있습니다. 일부 플랫폼에서는 패닉 발생 시 스택을 되감아(unwinding) 올라가면서 각 스코프의 Drop 구현을 실행하여 리소스를 정리합니다.

이 과정을 관리하는 것이 바로 eh_personality 함수인데, C++의 예외 처리와 유사한 메커니즘을 Rust에서도 지원하기 위한 것입니다. 바로 이럴 때 필요한 것이 언어 아이템(language items) 이해입니다.

이것들은 컴파일러가 내부적으로 사용하는 특별한 함수나 트레이트들로, no_std 환경에서는 직접 제공해야 하는 경우가 있습니다.

개요

간단히 말해서, eh_personality는 예외 처리(exception handling) 과정에서 스택 언와인딩을 관리하는 언어 아이템입니다. 하지만 OS 개발에서는 대부분 이 기능을 비활성화합니다.

왜 이 개념이 필요한지 실무 관점에서 설명하자면, 스택 언와인딩은 상당한 코드 크기와 복잡성을 추가하며, 베어메탈 환경에서는 구현이 매우 어렵습니다. 예를 들어, 임베디드 시스템이나 커널에서는 패닉 시 즉시 중단(abort)하는 것이 더 예측 가능하고 안전합니다.

리소스 정리가 필요하다면 RAII 패턴보다는 명시적 관리가 더 적합합니다. 기존에는 표준 라이브러리가 libunwind나 libgcc를 통해 복잡한 언와인딩 로직을 제공했다면, 이제는 Cargo.toml에서 panic = "abort"를 설정하여 언와인딩 없이 즉시 중단하는 방식을 선택할 수 있습니다.

언어 아이템의 핵심 특징은 첫째, 컴파일러 내부에서 특별히 취급되는 함수나 트레이트이고, 둘째, #[lang = "..."] 속성으로 표시되며, 셋째, no_std 환경에서는 일부를 직접 구현해야 한다는 것입니다. 이러한 특징들이 중요한 이유는 Rust의 저수준 동작을 커스터마이징할 수 있게 해주며, 플랫폼별 특성에 맞게 최적화할 수 있기 때문입니다.

코드 예제

// Cargo.toml에 추가하여 언와인딩 비활성화 (권장 방법)
// [profile.dev]
// panic = "abort"
//
// [profile.release]
// panic = "abort"

// 또는 직접 구현하는 방법 (일반적으로 불필요)
#![no_std]
#![no_main]
#![feature(lang_items)]

use core::panic::PanicInfo;

// eh_personality 언어 아이템 구현
// 대부분의 경우 빈 구현으로 충분함
#[lang = "eh_personality"]
extern "C" fn eh_personality() {}

// 패닉 핸들러
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    loop {}
}

// 프로그램 진입점
#[no_mangle]
pub extern "C" fn _start() -> ! {
    // 메인 로직
    loop {}
}

설명

이것이 하는 일: 이 코드는 Rust 컴파일러가 요구하는 예외 처리 관련 언어 아이템을 제공하거나, Cargo 설정을 통해 아예 언와인딩 메커니즘을 비활성화하는 방법을 보여줍니다. 실무에서는 대부분 후자의 방법을 사용합니다.

첫 번째로, Cargo.toml의 panic = "abort" 설정은 패닉 발생 시 스택 언와인딩 없이 즉시 프로그램을 중단하도록 지시합니다. 이렇게 하면 eh_personality 구현이 완전히 불필요해지며, 최종 바이너리 크기도 수십 KB 줄어듭니다.

이 방법이 권장되는 이유는 베어메탈 환경에서 언와인딩은 복잡하고 디버깅하기 어려우며, 대부분의 경우 패닉은 복구 불가능한 치명적 에러를 의미하기 때문입니다. 그 다음으로, 만약 특별한 이유로 직접 구현해야 한다면 #[lang = "eh_personality"] 속성을 사용하여 컴파일러에게 이것이 특별한 언어 아이템임을 알려줍니다.

extern "C"는 C ABI를 사용하도록 지정하며, 실제 구현은 비어있어도 컴파일러는 만족합니다. 하지만 이 방법은 #![feature(lang_items)]라는 불안정(unstable) 기능을 요구하므로 nightly 컴파일러가 필요합니다.

세 번째로, 전체 구조를 보면 no_std, no_main, 언어 아이템, panic_handler, _start 함수가 모두 조화롭게 작동해야 합니다. 각 요소는 서로 의존하며, 하나라도 빠지면 컴파일 에러가 발생합니다.

예를 들어, panic_handler 없이 eh_personality만 있어도 컴파일이 실패합니다. 마지막으로, 이러한 설정은 타겟 트리플에 따라 필요 여부가 달라집니다.

x86_64-unknown-none 같은 베어메탈 타겟은 기본적으로 언와인딩을 지원하지 않지만, x86_64-unknown-linux-gnu는 언와인딩을 기대하므로 명시적으로 비활성화해야 합니다. 여러분이 이 코드를 사용하면 불필요한 언와인딩 코드 없이 깔끔하고 작은 바이너리를 생성할 수 있습니다.

또한 panic = "abort"를 사용하면 nightly 컴파일러 없이도 stable 버전으로 OS 개발이 가능하며, 런타임 동작이 더 예측 가능해져 디버깅이 쉬워집니다. 최종 바이너리 크기는 10-50KB 정도 줄어들 수 있어 임베디드 시스템에서 특히 유용합니다.

실전 팁

💡 실무에서는 거의 항상 panic = "abort"를 사용하세요. 언와인딩이 정말 필요한 경우는 극히 드뭅니다.

💡 흔한 실수로 dev 프로필에만 설정하고 release에는 설정하지 않는 경우가 있으니 두 프로필 모두에 panic = "abort"를 추가하세요.

💡 타겟 사양 파일(JSON)을 직접 만들 때는 "panic-strategy": "abort"를 포함시켜 타겟 레벨에서 언와인딩을 비활성화할 수 있습니다.

💡 만약 언와인딩을 구현하고 싶다면 DWARF unwind 정보를 파싱하고 각 프레임의 cleanup 코드를 실행하는 복잡한 로직이 필요하니 libunwind 소스를 참고하세요.

💡 CI/CD 파이프라인에서는 cargo build --release --target x86_64-unknown-none -Z build-std=core 같은 명령으로 core 라이브러리까지 재컴파일하여 완전히 최적화된 바이너리를 생성할 수 있습니다.


4. 커스텀 타겟 스펙 생성 - 타겟 트리플 이해하기

시작하며

여러분이 cargo build --target x86_64-unknown-linux-gnu로 컴파일할 때는 성공하는데, 막상 OS 개발을 위해 베어메탈 타겟으로 빌드하려니 적절한 타겟이 없어서 막막한 경험을 하신 적 있나요? rustup target list로 확인해보면 수많은 타겟이 있지만, 여러분의 커스텀 OS에 딱 맞는 것은 없습니다.

이런 문제는 Rust의 기본 타겟들이 기존 운영체제(Linux, Windows, macOS)나 잘 알려진 임베디드 플랫폼을 위해 만들어졌기 때문에 발생합니다. 각 타겟은 CPU 아키텍처, OS, ABI, 링커, 기본 라이브러리 등 수십 가지 설정을 포함하며, 이들이 여러분의 커스텀 OS 요구사항과 정확히 일치하지 않을 수 있습니다.

예를 들어, 기본 x86_64 타겟은 리눅스 시스템 콜을 가정하므로 여러분의 OS에서는 사용할 수 없습니다. 바로 이럴 때 필요한 것이 커스텀 타겟 스펙 생성입니다.

JSON 파일로 타겟의 모든 세부사항을 정의하여 여러분의 OS에 완벽하게 맞는 컴파일 환경을 만들 수 있습니다.

개요

간단히 말해서, 타겟 스펙(target specification)은 Rust 컴파일러에게 코드를 어떤 플랫폼용으로 컴파일할지 알려주는 JSON 형식의 설정 파일입니다. CPU 아키텍처, 데이터 레이아웃, 링커 설정, OS 타입 등을 정의합니다.

왜 이 개념이 필요한지 실무 관점에서 설명하자면, 커스텀 OS를 개발할 때는 기존 타겟이 제공하지 않는 특별한 설정이 필요합니다. 예를 들어, 여러분의 OS가 레드존(red zone)을 지원하지 않거나, 특별한 링커 스크립트를 사용하거나, SSE 명령어를 비활성화해야 할 수 있습니다.

인터럽트 핸들러에서 레드존이 활성화되어 있으면 스택 손상이 발생할 수 있으므로 이를 비활성화하는 것이 중요합니다. 기존에는 rustup target add로 미리 정의된 타겟을 설치했다면, 이제는 JSON 파일로 타겟을 정의하고 cargo build --target my-custom-target.json으로 직접 사용할 수 있습니다.

커스텀 타겟의 핵심 특징은 첫째, llvm-target으로 LLVM 백엔드에 전달할 타겟 트리플을 지정하고, 둘째, data-layout으로 메모리의 바이트 순서와 정렬 방식을 정의하며, 셋째, features 필드로 CPU 기능을 세밀하게 제어한다는 것입니다. 이러한 특징들이 중요한 이유는 하드웨어와 직접 상호작용하는 OS 코드는 잘못된 설정 하나로도 부팅 실패나 시스템 크래시가 발생할 수 있기 때문입니다.

코드 예제

{
  "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",
  "executables": true,
  "linker-flavor": "ld.lld",
  "linker": "rust-lld",
  "panic-strategy": "abort",
  "disable-redzone": true,
  "features": "-mmx,-sse,+soft-float",
  "code-model": "kernel",
  "relocation-model": "static"
}

설명

이것이 하는 일: 이 JSON 파일은 x86_64 아키텍처용 베어메탈 OS를 위한 완전한 타겟 명세를 정의합니다. 각 필드는 컴파일러와 링커의 동작을 세밀하게 제어하여 OS 개발에 적합한 바이너리를 생성하도록 합니다.

첫 번째로, llvm-target과 data-layout은 LLVM 백엔드에게 코드 생성 방식을 알려줍니다. "x86_64-unknown-none"은 64비트 x86 아키텍처이며 OS가 없는(none) 환경을 의미합니다.

data-layout 문자열은 리틀 엔디안(e), 64비트 포인터, 128비트 long double 등 메모리 표현의 모든 세부사항을 인코딩하며, 이것이 잘못되면 데이터 접근 시 정렬 오류나 잘못된 값을 읽게 됩니다. 그 다음으로, disable-redzone과 features 필드는 OS 개발에서 매우 중요한 설정입니다.

레드존은 함수가 스택 포인터 아래 128바이트를 임시로 사용할 수 있게 하는 최적화인데, 인터럽트 핸들러에서는 이 영역이 덮어써질 수 있어 위험합니다. "-mmx,-sse,+soft-float"는 MMX/SSE 명령어를 비활성화하고 소프트웨어 부동소수점을 사용하도록 지시하는데, 이는 SIMD 레지스터를 저장/복원하는 오버헤드를 제거하고 커널 코드를 단순하게 만듭니다.

세 번째로, linker-flavor와 panic-strategy는 빌드 과정을 제어합니다. "ld.lld"는 LLVM의 빠른 링커를 사용하며, "panic-strategy": "abort"는 앞서 설명한 대로 언와인딩을 비활성화합니다.

이러한 설정들은 최종 바이너리가 불필요한 의존성 없이 독립적으로 실행될 수 있도록 보장합니다. 마지막으로, code-model과 relocation-model은 코드 배치 방식을 결정합니다.

"code-model": "kernel"은 코드가 상위 2GB 주소 공간에 위치할 것을 가정하여 짧은 명령어를 사용하고, "relocation-model": "static"은 위치 독립 코드(PIC)를 생성하지 않아 성능을 향상시킵니다. 커널은 고정된 주소에 로드되므로 재배치가 불필요합니다.

여러분이 이 타겟 스펙을 사용하면 x86_64-my-os.json 같은 이름으로 저장하고 cargo build --target x86_64-my-os.json으로 컴파일할 수 있습니다. 이를 통해 OS 요구사항에 완벽하게 맞는 바이너리를 생성하고, 인터럽트 처리, 메모리 관리, 디바이스 드라이버 등 저수준 코드가 올바르게 작동하도록 보장하며, 불필요한 기능을 제거하여 커널 크기를 최소화할 수 있습니다.

실전 팁

💡 rustc --print target-spec-json --target x86_64-unknown-none 명령으로 기존 타겟의 스펙을 출력하여 시작점으로 사용하세요.

💡 흔한 실수로 data-layout을 잘못 복사하면 segfault나 데이터 손상이 발생하니, 반드시 공식 문서나 기존 타겟에서 정확히 복사하세요.

💡 ARM 같은 다른 아키텍처를 타겟팅한다면 "pre-link-args"와 "post-link-args"로 링커에 추가 플래그를 전달할 수 있습니다.

💡 개발 중에는 features에서 "+soft-float"를 제거하고 SSE를 활성화하면 부동소수점 연산이 빨라지지만, 인터럽트 핸들러 진입/종료 시 레지스터 저장/복원 코드를 추가해야 합니다.

💡 .cargo/config.toml에 [build] target = "x86_64-my-os.json"을 추가하면 매번 --target을 지정하지 않아도 되어 편리합니다.


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

시작하며

여러분이 no_std 프로그램을 컴파일하고 QEMU나 실제 하드웨어에서 실행했을 때 아무것도 동작하지 않거나 이상한 주소에서 크래시가 발생한 경험이 있나요? objdump로 바이너리를 분석해보면 .text 섹션이 0x0 같은 이상한 주소에 배치되어 있습니다.

이런 문제는 링커가 여러분의 OS의 메모리 구조를 모르기 때문에 발생합니다. 일반 프로그램은 OS가 메모리를 관리하므로 아무 주소에 로드해도 되지만, 커널은 특정 주소에 배치되어야 합니다.

예를 들어, BIOS는 0x7C00에 부트로더를 로드하고, 64비트 커널은 보통 0xFFFFFFFF80000000 같은 상위 주소에 위치해야 합니다. 또한 .text, .data, .bss 섹션의 순서와 정렬도 중요합니다.

바로 이럴 때 필요한 것이 커스텀 링커 스크립트입니다. 이것은 링커에게 각 섹션을 어디에 배치하고, 어떤 심볼을 생성하며, 어떻게 정렬할지 정확히 지시합니다.

개요

간단히 말해서, 링커 스크립트는 링커에게 실행 파일의 메모리 레이아웃을 지시하는 스크립트 파일입니다. 각 섹션(.text, .data, .rodata, .bss)의 위치, 크기, 정렬, 순서를 세밀하게 제어합니다.

왜 이 개념이 필요한지 실무 관점에서 설명하자면, OS 커널은 정확한 주소에서 실행되어야 하며, 부트로더가 기대하는 위치에 코드가 있어야 합니다. 예를 들어, 멀티부트2 헤더는 커널 시작 부분 8KB 이내에 위치해야 하고, 커널 코드는 물리 메모리 1MB 이상에 로드되어야 합니다.

또한 .bss 섹션의 시작과 끝 주소를 알아야 초기화할 수 있습니다. 기존에는 기본 링커 스크립트가 OS의 로더를 위해 적절한 메모리 레이아웃을 생성했다면, 이제는 직접 링커 스크립트를 작성하여 부트로더 요구사항, 페이지 정렬, 심볼 위치 등을 완벽하게 제어할 수 있습니다.

링커 스크립트의 핵심 특징은 첫째, ENTRY()로 프로그램 진입점을 지정하고, 둘째, SECTIONS {}로 각 섹션의 배치를 정의하며, 셋째, PROVIDE()로 Rust 코드에서 참조할 수 있는 심볼을 생성한다는 것입니다. 이러한 특징들이 중요한 이유는 커널은 일반 프로그램과 달리 로더가 없으므로 모든 것을 직접 관리해야 하기 때문입니다.

코드 예제

/* 링커 스크립트: linker.ld */
ENTRY(_start)

SECTIONS
{
    /* 커널을 1MB 위치에 배치 (부트로더 관례) */
    . = 1M;

    /* 커널 시작 심볼 */
    _kernel_start = .;

    /* 코드 섹션 - 4KB 정렬 (페이지 크기) */
    .text : ALIGN(4K)
    {
        *(.text.boot)  /* 부트 코드를 제일 앞에 */
        *(.text .text.*)
    }

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

    /* 초기화된 데이터 섹션 */
    .data : ALIGN(4K)
    {
        *(.data .data.*)
    }

    /* 초기화되지 않은 데이터 섹션 */
    .bss : ALIGN(4K)
    {
        _bss_start = .;
        *(.bss .bss.*)
        *(COMMON)
        _bss_end = .;
    }

    /* 커널 끝 심볼 */
    _kernel_end = .;
}

설명

이것이 하는 일: 이 링커 스크립트는 커널 바이너리의 모든 섹션을 1MB 시작 주소에서부터 정확한 순서와 정렬로 배치합니다. 또한 Rust 코드에서 사용할 수 있는 심볼들을 생성하여 BSS 섹션 초기화나 커널 크기 계산이 가능하게 합니다.

첫 번째로, ENTRY(_start)와 . = 1M은 프로그램의 시작점과 위치를 정의합니다.

_start는 Rust 코드의 #[no_mangle] pub extern "C" fn _start()와 매칭되며, 1M(0x100000)은 GRUB 같은 부트로더가 커널을 로드하는 표준 위치입니다. 이 주소 이하는 BIOS 데이터, VGA 버퍼, 부트로더 코드 등으로 사용되므로 피해야 합니다.

그 다음으로, .text 섹션은 : ALIGN(4K)로 4KB 경계에 정렬되며, 이는 x86_64 페이지 크기와 일치합니다. *(.text.boot)를 제일 앞에 배치하여 초기 부트 코드가 커널의 첫 부분에 오도록 하고, (.text .text.)로 모든 코드 섹션을 수집합니다.

페이지 정렬이 중요한 이유는 나중에 페이징을 활성화할 때 각 섹션에 다른 권한(실행, 읽기, 쓰기)을 부여할 수 있기 때문입니다. 세 번째로, .rodata, .data, .bss 섹션은 각각 읽기 전용 데이터, 초기화된 데이터, 초기화되지 않은 데이터를 담습니다.

특히 .bss 섹션에는 _bss_start와 _bss_end 심볼을 정의하여 Rust 코드에서 다음과 같이 사용할 수 있습니다: rust extern "C" { static _bss_start: u8; static _bss_end: u8; } unsafe { let bss_start = &_bss_start as *const u8 as usize; let bss_end = &_bss_end as *const u8 as usize; let bss_size = bss_end - bss_start; // BSS를 0으로 초기화 core::ptr::write_bytes(bss_start as *mut u8, 0, bss_size); } 마지막으로, _kernel_start와 _kernel_end 심볼은 커널의 전체 크기를 계산하거나, 커널 이후의 여유 메모리를 찾는 데 사용됩니다. 이는 메모리 관리자를 초기화할 때 필수적입니다.

여러분이 이 링커 스크립트를 사용하면 .cargo/config.toml에 rustflags = ["-C", "link-arg=-Tlinker.ld"]를 추가하여 자동으로 적용할 수 있습니다. 이를 통해 커널이 정확한 주소에 배치되어 부트로더가 올바르게 실행할 수 있고, 각 섹션이 페이지 경계에 정렬되어 나중에 페이지 테이블 설정이 간편해지며, BSS 초기화, 스택 설정, 메모리 관리 등에 필요한 심볼을 Rust 코드에서 참조할 수 있습니다.

실전 팁

💡 objdump -h your_kernel.elf로 섹션 배치를 확인하고, readelf -l로 프로그램 헤더와 로드 주소를 검증하세요.

💡 흔한 실수로 ALIGN(4K)를 빠뜨리면 페이징 활성화 시 정렬 오류가 발생하니 모든 주요 섹션에 추가하세요.

💡 스택 영역도 링커 스크립트에서 정의할 수 있습니다: .stack (NOLOAD) : { . = . + 16K; _stack_top = .; } 같은 방식으로요.

💡 멀티부트2를 사용한다면 .multiboot 섹션을 .text 앞에 배치하여 헤더가 첫 8KB 내에 오도록 해야 GRUB이 인식합니다.

💡 디버깅을 위해 .debug_* 섹션을 DISCARD하지 말고 유지하면 GDB나 LLDB로 소스 레벨 디버깅이 가능하며, 최종 빌드에서만 strip으로 제거하세요.


6. VGA 텍스트 모드 출력 - 첫 번째 화면 출력

시작하며

여러분이 no_std 환경에서 코드를 작성하고 나면 가장 먼저 부딪히는 문제가 "디버깅을 어떻게 하지?"입니다. println!

매크로는 사용할 수 없고, 직렬 포트는 설정이 복잡하며, 코드가 실행되는지조차 확인하기 어렵습니다. 이런 문제는 표준 입출력 장치가 없는 베어메탈 환경의 본질적인 어려움입니다.

하지만 다행히도 x86 시스템에는 VGA 텍스트 모드라는 간단한 출력 방법이 있습니다. 이것은 BIOS 부팅 직후부터 사용 가능하며, 별도의 드라이버 없이 메모리 매핑된 버퍼(0xB8000)에 값을 쓰기만 하면 화면에 텍스트가 표시됩니다.

바로 이럴 때 필요한 것이 VGA 텍스트 버퍼 구현입니다. 이것은 OS 개발에서 첫 번째 "Hello, World!"를 출력하는 전통적인 방법이며, 이후 모든 디버깅의 기초가 됩니다.

개요

간단히 말해서, VGA 텍스트 모드는 80x25 문자 그리드로 화면에 텍스트를 표시하는 하드웨어 기능입니다. 메모리 주소 0xB8000에 2바이트씩(문자 1바이트 + 색상 1바이트) 쓰면 즉시 화면에 반영됩니다.

왜 이 개념이 필요한지 실무 관점에서 설명하자면, OS 개발 초기 단계에서는 복잡한 그래픽 드라이버를 구현할 여유가 없습니다. 예를 들어, 부트로더에서 커널로 제어를 넘긴 직후 메모리 관리자, 인터럽트 핸들러, 페이징 같은 기능을 구현하면서 각 단계의 진행 상황을 확인해야 하는데, VGA 텍스트 모드는 이런 초기 디버깅에 완벽합니다.

기존에는 println! 매크로로 표준 출력에 로그를 출력했다면, 이제는 VGA 버퍼에 직접 쓰는 방식으로 화면에 정보를 표시할 수 있습니다.

심지어 panic_handler에서도 사용하여 커널 패닉 메시지를 화면에 출력할 수 있습니다. VGA 텍스트 모드의 핵심 특징은 첫째, 설정 없이 즉시 사용 가능하고, 둘째, 2000바이트(80x25x2) 버퍼를 메모리 매핑 I/O로 접근하며, 셋째, 각 문자는 ASCII 코드와 4비트 배경색 + 4비트 전경색으로 구성된다는 것입니다.

이러한 특징들이 중요한 이유는 복잡한 초기화 없이 빠르게 출력 기능을 확보할 수 있어 OS 개발의 생산성을 크게 높이기 때문입니다.

코드 예제

#[repr(u8)]
#[derive(Copy, Clone)]
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,
}

#[repr(transparent)]
#[derive(Copy, Clone)]
struct ColorCode(u8);

impl ColorCode {
    fn new(foreground: Color, background: Color) -> ColorCode {
        ColorCode((background as u8) << 4 | (foreground as u8))
    }
}

#[repr(C)]
#[derive(Copy, Clone)]
struct ScreenChar {
    ascii_character: u8,
    color_code: ColorCode,
}

const BUFFER_HEIGHT: usize = 25;
const BUFFER_WIDTH: usize = 80;

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;

                self.buffer.chars[row][col] = ScreenChar {
                    ascii_character: byte,
                    color_code: self.color_code,
                };
                self.column_position += 1;
            }
        }
    }

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

// 사용 예제
pub fn print_hello() {
    let mut writer = Writer {
        column_position: 0,
        color_code: ColorCode::new(Color::Yellow, Color::Black),
        buffer: unsafe { &mut *(0xb8000 as *mut Buffer) },
    };

    writer.write_byte(b'H');
    writer.write_byte(b'e');
    writer.write_byte(b'l');
    writer.write_byte(b'l');
    writer.write_byte(b'o');
}

설명

이것이 하는 일: 이 코드는 VGA 텍스트 모드의 80x25 버퍼를 안전하고 편리하게 다룰 수 있는 Writer 구조체를 구현합니다. 문자 출력, 색상 제어, 줄바꿈, 스크롤링 기능을 제공하여 println!

매크로와 유사한 경험을 제공합니다. 첫 번째로, Color와 ColorCode는 16가지 색상을 타입 안전하게 표현합니다.

#[repr(u8)]은 각 Color 값을 정확히 1바이트로 표현하도록 보장하며, ColorCode는 상위 4비트에 배경색, 하위 4비트에 전경색을 패킹합니다. 예를 들어, 검은 배경에 노란 전경색은 0x0E가 됩니다.

이렇게 하는 이유는 VGA 하드웨어가 정확히 이 형식을 기대하기 때문입니다. 그 다음으로, ScreenChar는 #[repr(C)]로 C 언어 메모리 레이아웃을 사용하여 2바이트 정렬을 보장합니다.

Buffer는 25행 80열의 2차원 배열로 정의되며, Writer는 0xB8000 주소를 &'static mut Buffer로 캐스팅하여 안전하지 않은(unsafe) 메모리 접근을 캡슐화합니다. 'static 라이프타임은 이 버퍼가 프로그램 전체 기간 동안 유효함을 나타냅니다.

세 번째로, write_byte 메서드는 바이트를 출력하며, 개행 문자(\n)를 만나면 new_line을 호출하고, 일반 문자는 현재 위치에 씁니다. 줄 끝에 도달하면 자동으로 다음 줄로 이동합니다.

new_line 메서드는 모든 행을 위로 한 칸씩 이동시켜 스크롤 효과를 만들고, 마지막 행을 공백으로 채웁니다. 이 과정은 VGA 하드웨어에 의해 실시간으로 화면에 반영됩니다.

마지막으로, print_hello 함수는 사용 예제를 보여줍니다. unsafe 블록에서 0xB8000을 Buffer 타입으로 해석하는데, 이는 실제로 유효한 VGA 버퍼 주소이므로 안전합니다.

Writer를 생성한 후 각 바이트를 쓰면 화면 왼쪽 상단에 "Hello"가 노란색으로 표시됩니다. 여러분이 이 코드를 사용하면 커널의 부팅 과정을 시각적으로 확인할 수 있어 "부트로더가 커널을 실행했는가?"라는 기본적인 질문에 즉시 답을 얻을 수 있습니다.

또한 core::fmt::Write 트레이트를 구현하여 write! 매크로를 사용할 수 있게 확장하면 포맷팅된 출력도 가능하며, Mutex로 감싸면 멀티스레딩 환경에서도 안전하게 사용할 수 있습니다.

실제 OS 개발에서는 이 VGA 드라이버를 초기화 단계에서 가장 먼저 구현하여 이후 모든 개발 과정의 디버깅 도구로 활용합니다.

실전 팁

💡 실무에서는 core::fmt::Write 트레이트를 Writer에 구현하면 write!(writer, "Value: {}", 42) 같은 포맷팅이 가능해집니다.

💡 흔한 실수로 volatile 읽기/쓰기를 사용하지 않으면 컴파일러 최적화로 쓰기가 제거될 수 있으니 volatile crate를 사용하세요: Volatile<ScreenChar>.

💡 멀티코어 환경에서는 전역 Writer를 spin::Mutex<Writer>로 감싸서 동시 접근을 방지해야 합니다.

💡 성능 최적화를 위해 write_byte 대신 write_string으로 버퍼에 한 번에 쓰면 함수 호출 오버헤드를 줄일 수 있습니다.

💡 QEMU에서 테스트할 때 -vga std 옵션을 사용하면 VGA 텍스트 모드를 정확히 에뮬레이션하며, -serial stdio와 함께 사용하면 직렬 포트와 VGA를 동시에 활용할 수 있습니다.


7. 전역 할당자 없이 작동하기 - 스택 기반 프로그래밍

시작하며

여러분이 no_std 환경에서 코드를 작성하다가 Vec, String, Box 같은 타입을 사용하려고 하면 "no global memory allocator found" 에러를 만난 적 있나요? 이것은 표준 라이브러리의 가장 기본적인 기능들이 동작하지 않는다는 것을 의미합니다.

이런 문제는 힙 할당이 운영체제의 메모리 관리 기능에 의존하기 때문에 발생합니다. malloc이나 new 같은 동적 할당 함수는 OS의 시스템 콜(sbrk, mmap)을 사용하여 메모리를 요청하는데, 여러분이 만들고 있는 OS 자체에서는 아직 이런 기능이 없습니다.

또한 전역 힙 할당자를 구현하려면 메모리 관리자, 페이지 할당자, 프레임 할당자 등 복잡한 인프라가 필요합니다. 바로 이럴 때 필요한 것이 스택 기반 프로그래밍 패턴입니다.

이것은 컴파일 타임에 크기가 결정되는 고정 크기 배열, 옵션 타입, 인라인 데이터 구조를 활용하여 힙 할당 없이도 강력한 기능을 구현하는 방법입니다.

개요

간단히 말해서, 스택 기반 프로그래밍은 힙 메모리를 사용하지 않고 스택과 정적 메모리만으로 데이터를 관리하는 방식입니다. 고정 크기 배열, 인라인 구조체, 참조를 활용하여 동적 할당의 필요성을 제거합니다.

왜 이 개념이 필요한지 실무 관점에서 설명하자면, OS 커널의 초기 단계에서는 힙 할당자를 구현하기 전에도 많은 기능이 필요합니다. 예를 들어, GDT(Global Descriptor Table), IDT(Interrupt Descriptor Table), 페이지 테이블 같은 핵심 데이터 구조들은 고정 크기이므로 힙이 없어도 구현할 수 있습니다.

또한 임베디드 시스템에서는 메모리가 제한적이고 예측 가능성이 중요하므로 정적 할당이 선호됩니다. 기존에는 Vec<T>나 Box<T>로 동적 크기 데이터를 관리했다면, 이제는 [T; N] 같은 고정 크기 배열이나 arrayvec::ArrayVec 같은 스택 기반 컬렉션을 사용할 수 있습니다.

또한 Option<T>와 Result<T, E>는 힙 할당 없이도 사용 가능합니다. 스택 기반 프로그래밍의 핵심 특징은 첫째, 모든 데이터 크기가 컴파일 타임에 결정되고, 둘째, 메모리 할당 실패가 발생하지 않으며, 셋째, 메모리 단편화 문제가 없다는 것입니다.

이러한 특징들이 중요한 이유는 안정성과 예측 가능성이 최우선인 저수준 시스템 프로그래밍에서 런타임 에러를 최소화할 수 있기 때문입니다.

코드 예제

// 동적 할당 대신 고정 크기 배열 사용
pub struct FixedVec<T, const N: usize> {
    data: [Option<T>; N],
    len: usize,
}

impl<T, const N: usize> FixedVec<T, N>
where
    T: Copy,
{
    pub const fn new() -> Self {
        Self {
            data: [None; N],
            len: 0,
        }
    }

    pub fn push(&mut self, value: T) -> Result<(), T> {
        if self.len < N {
            self.data[self.len] = Some(value);
            self.len += 1;
            Ok(())
        } else {
            Err(value)  // 공간 부족
        }
    }

    pub fn pop(&mut self) -> Option<T> {
        if self.len > 0 {
            self.len -= 1;
            self.data[self.len].take()
        } else {
            None
        }
    }

    pub fn get(&self, index: usize) -> Option<&T> {
        if index < self.len {
            self.data[index].as_ref()
        } else {
            None
        }
    }
}

// 사용 예제
pub fn test_fixed_vec() {
    let mut vec: FixedVec<u32, 10> = FixedVec::new();

    vec.push(42).unwrap();
    vec.push(100).unwrap();

    assert_eq!(vec.get(0), Some(&42));
    assert_eq!(vec.pop(), Some(100));
}

// 정적 전역 변수로 대용량 버퍼 선언
static mut PACKET_BUFFER: [u8; 4096] = [0; 4096];

설명

이것이 하는 일: 이 코드는 Vec<T>와 유사한 인터페이스를 제공하지만 힙 할당을 전혀 사용하지 않는 FixedVec 컬렉션을 구현합니다. const 제네릭을 활용하여 컴파일 타임에 최대 크기를 지정하고, Option<T> 배열로 요소를 저장합니다.

첫 번째로, 구조체 정의에서 const N: usize는 Rust의 const 제네릭 기능으로, 타입 시스템 수준에서 배열 크기를 지정합니다. data: [Option<T>; N]은 N개의 Option<T>를 스택에 할당하며, None은 비어있는 슬롯을 나타냅니다.

이렇게 하는 이유는 Rust가 부분적으로 초기화된 배열을 직접 지원하지 않기 때문이며, Option을 사용하면 안전하게 "사용 중" 상태를 추적할 수 있습니다. 그 다음으로, push 메서드는 len이 최대 크기 N보다 작을 때만 값을 추가하고, 공간이 부족하면 Err(value)를 반환하여 호출자가 상황을 처리하도록 합니다.

이는 Vec<T>의 push가 자동으로 재할당하는 것과 달리, 명시적으로 용량 제한을 강제하여 예측 가능한 메모리 사용을 보장합니다. pop 메서드는 take()를 사용하여 Option<T>에서 값을 꺼내고 None으로 교체합니다.

세 번째로, where T: Copy 트레이트 바운드는 Option<T>의 배열을 [None; N] 구문으로 초기화하기 위해 필요합니다. Copy가 아닌 타입에는 MaybeUninit<T>를 사용하는 더 복잡한 구현이 필요하지만, 대부분의 저수준 프로그래밍에서는 u8, u32, 포인터 같은 Copy 타입을 주로 사용합니다.

마지막으로, static mut PACKET_BUFFER 예제는 정적 전역 변수를 사용하여 4KB 버퍼를 .bss 섹션에 할당하는 방법을 보여줍니다. 이는 네트워크 패킷 버퍼, DMA 버퍼, 디스크 캐시 같은 대용량 버퍼에 유용하지만, mut static은 본질적으로 unsafe하므로 접근 시 unsafe 블록이 필요하며, 가능하면 Mutex로 감싸서 안전성을 높여야 합니다.

여러분이 이 코드를 사용하면 힙 할당자 없이도 OS 초기화 과정의 거의 모든 기능을 구현할 수 있습니다. GDT는 [Descriptor; 8] 같은 고정 배열로, IDT는 [InterruptHandler; 256]으로, 페이지 테이블은 [[Entry; 512]; 4]로 표현 가능하며, 이들은 모두 정적 또는 스택 메모리에 위치합니다.

또한 arrayvec, heapless, tinyvec 같은 no_std 호환 크레이트를 활용하면 더 풍부한 컬렉션을 사용할 수 있으며, 나중에 힙 할당자를 구현한 후에도 고정 크기 컬렉션은 성능이 중요한 부분에서 계속 유용하게 사용됩니다.

실전 팁

💡 arrayvec::ArrayVec은 FixedVec보다 훨씬 최적화되어 있고 no_std를 지원하니 실무에서는 이것을 사용하세요.

💡 흔한 실수로 const N이 너무 크면 스택 오버플로우가 발생할 수 있으니, 큰 버퍼는 static으로 선언하거나 Box::leak을 사용하세요 (힙 할당자 구현 후).

💡 Copy가 아닌 타입에는 core::mem::MaybeUninit을 사용하여 초기화되지 않은 메모리를 안전하게 다룰 수 있지만, 코드가 복잡해지니 처음에는 Copy 타입으로 시작하세요.

💡 정적 mut 변수 대신 spin::Mutex<RefCell<[T; N]>> 같은 내부 가변성 패턴을 사용하면 unsafe 없이 안전하게 공유 상태를 관리할 수 있습니다.

💡 디버깅 시 static ALLOCATOR_CALLS: AtomicUsize 같은 카운터로 할당 횟수를 추적하면 나중에 힙 할당자를 구현할 때 메모리 사용 패턴을 파악하는 데 도움이 됩니다.


8. 인라인 어셈블리 활용 - 하드웨어 직접 제어

시작하며

여러분이 VGA 버퍼나 직렬 포트 같은 하드웨어를 제어하려고 할 때, Rust의 안전한 추상화만으로는 한계가 있다는 것을 느끼신 적 있나요? CPU 포트에 직접 값을 쓰거나, 특수 레지스터를 읽거나, 인터럽트를 활성화/비활성화하는 등의 작업은 일반 Rust 코드로는 불가능합니다.

이런 문제는 OS 개발이 하드웨어와 직접 상호작용해야 하는 저수준 프로그래밍이기 때문에 발생합니다. x86_64의 in/out 명령어, cli/sti 명령어, MSR(Model-Specific Register) 접근, CPUID 명령어 같은 것들은 특별한 어셈블리 명령어를 통해서만 가능합니다.

예를 들어, 직렬 포트(0x3F8)에 바이트를 쓰려면 out 명령어를 사용해야 하는데, 이는 Rust 문법에 없습니다. 바로 이럴 때 필요한 것이 인라인 어셈블리입니다.

이것은 Rust 코드 안에 어셈블리 명령어를 직접 삽입하여 하드웨어를 완전히 제어할 수 있게 해주며, core::arch::asm! 매크로를 통해 안전하게 사용할 수 있습니다.

개요

간단히 말해서, 인라인 어셈블리는 Rust 함수 내부에 어셈블리 코드를 직접 작성하여 CPU 명령어를 실행하는 기능입니다. asm!

매크로를 통해 입출력 레지스터, 클로버(clobber) 리스트, 제약 조건을 지정합니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, OS 개발의 핵심 기능들은 모두 특수 CPU 명령어에 의존합니다.

예를 들어, 포트 I/O(out, in)는 디바이스와 통신하는 유일한 방법이고, CR3 레지스터 설정은 페이징을 활성화하는 데 필수이며, cli/sti는 인터럽트를 제어하는 유일한 수단입니다. 또한 CPUID로 CPU 기능을 확인하거나, rdtsc로 타임스탬프를 읽거나, wrmsr/rdmsr로 고급 CPU 설정을 변경하는 것도 어셈블리 없이는 불가능합니다.

기존에는 C/C++의 asm volatile을 사용했다면, 이제는 Rust의 core::arch::asm!을 사용하여 타입 안전성과 레지스터 할당 최적화를 활용하면서도 동일한 저수준 제어를 할 수 있습니다. 인라인 어셈블리의 핵심 특징은 첫째, in(reg)과 out(reg)으로 Rust 변수와 어셈블리 레지스터 간 데이터 전달이 가능하고, 둘째, clobber_abi("C")로 함수 호출 규약을 명시하며, 셋째, options(nostack, nomem)로 컴파일러 최적화를 제어한다는 것입니다.

이러한 특징들이 중요한 이유는 어셈블리와 Rust 코드가 안전하게 상호작용하면서도 최대 성능을 낼 수 있기 때문입니다.

코드 예제

// 포트 I/O - 8비트 출력
pub unsafe fn outb(port: u16, value: u8) {
    core::arch::asm!(
        "out dx, al",
        in("dx") port,
        in("al") value,
        options(nomem, nostack, preserves_flags)
    );
}

// 포트 I/O - 8비트 입력
pub unsafe fn inb(port: u16) -> u8 {
    let value: u8;
    core::arch::asm!(
        "in al, dx",
        in("dx") port,
        out("al") value,
        options(nomem, nostack, preserves_flags)
    );
    value
}

// 인터럽트 비활성화
pub fn disable_interrupts() {
    unsafe {
        core::arch::asm!("cli", options(nomem, nostack));
    }
}

// 인터럽트 활성화
pub fn enable_interrupts() {
    unsafe {
        core::arch::asm!("sti", options(nomem, nostack));
    }
}

// CPU 정지 (전력 절약)
pub fn hlt() {
    unsafe {
        core::arch::asm!("hlt", options(nomem, nostack));
    }
}

// CR3 레지스터 읽기 (페이지 테이블 주소)
pub unsafe fn read_cr3() -> u64 {
    let value: u64;
    core::arch::asm!(
        "mov {}, cr3",
        out(reg) value,
        options(nomem, nostack, preserves_flags)
    );
    value
}

// CR3 레지스터 쓰기 (페이지 테이블 변경)
pub unsafe fn write_cr3(value: u64) {
    core::arch::asm!(
        "mov cr3, {}",
        in(reg) value,
        options(nostack, preserves_flags)
    );
}

설명

이것이 하는 일: 이 코드는 OS 개발에 필수적인 저수준 하드웨어 제어 함수들을 인라인 어셈블리로 구현합니다. 각 함수는 특정 CPU 명령어를 캡슐화하여 Rust 코드에서 안전하게 사용할 수 있는 인터페이스를 제공합니다.

첫 번째로, outb와 inb 함수는 x86의 포트 매핑 I/O를 구현합니다. out dx, al 명령어는 dx 레지스터(포트 번호)의 포트에 al 레지스터(값)를 씁니다.

in("dx") port는 Rust 변수 port를 dx 레지스터에 바인딩하고, in("al") value는 value를 al에 바인딩합니다. options(nomem, nostack, preserves_flags)는 컴파일러에게 이 어셈블리가 메모리를 접근하지 않고, 스택을 사용하지 않으며, CPU 플래그를 보존한다고 알려줘 최적화를 가능하게 합니다.

그 다음으로, cli와 sti 명령어는 인터럽트를 제어합니다. cli(clear interrupt flag)는 IF 플래그를 0으로 설정하여 모든 마스커블 인터럽트를 차단하고, sti(set interrupt flag)는 다시 활성화합니다.

이는 임계 구역(critical section)을 구현하거나 초기화 과정에서 중요하며, 예를 들어 IDT를 설정하는 동안 인터럽트가 발생하면 시스템이 크래시할 수 있습니다. 세 번째로, hlt 명령어는 CPU를 저전력 상태로 전환하여 다음 인터럽트까지 대기합니다.

이는 idle 상태에서 전력 소비를 줄이는 데 필수적이며, loop { hlt(); } 패턴은 OS 개발에서 "아무것도 할 일이 없을 때" 표준적인 방법입니다. hlt 없이 빈 루프를 돌리면 CPU가 100% 사용률로 열과 전력을 낭비합니다.

마지막으로, CR3 레지스터 읽기/쓰기는 페이징 시스템의 핵심입니다. CR3는 최상위 페이지 테이블(PML4)의 물리 주소를 담고 있으며, 이를 변경하면 프로세스 컨텍스트 전환이 일어납니다.

mov {}, cr3 구문에서 {}는 컴파일러가 자동으로 선택한 레지스터를 나타내며, out(reg)는 64비트 범용 레지스터를 사용하도록 지시합니다. write_cr3는 새로운 페이지 테이블을 활성화할 때 사용되며, 즉시 TLB(Translation Lookaside Buffer)가 플러시됩니다.

여러분이 이 코드를 사용하면 직렬 포트로 디버그 로그를 출력하거나(outb/inb), 인터럽트를 안전하게 관리하면서 데이터 구조를 수정하거나(cli/sti), 멀티태스킹 시 각 프로세스의 가상 주소 공간을 전환할 수 있습니다(read_cr3/write_cr3). 또한 x86 크레이트를 사용하면 이런 함수들이 이미 구현되어 있어 직접 작성할 필요가 없으며, 타입 안전한 래퍼를 제공하여 실수를 방지합니다.

실무에서는 이러한 저수준 함수들을 모듈로 분리하여 unsafe를 최소화하고, 상위 레벨 추상화는 안전한 Rust로 구현하는 것이 권장됩니다.

실전 팁

💡 실무에서는 x86_64 크레이트의 instructions 모듈을 사용하면 이미 구현된 함수들을 사용할 수 있어 휠을 재발명하지 않아도 됩니다.

💡 흔한 실수로 in/out을 inb/outb(8비트), inw/outw(16비트), inl/outl(32비트)로 구분하지 않으면 데이터가 잘리거나 잘못 읽힐 수 있습니다.

💡 options(noreturn)을 사용하면 함수가 절대 반환하지 않음을 나타내며, 부트로더에서 커널로 점프할 때 유용합니다.

💡 성능 최적화를 위해 핫 패스(hot path)에서는 volatile 읽기를 피하고, 한 번 읽어서 로컬 변수에 캐시한 후 사용하세요.

💡 디버깅 시 asm!에 options(att_syntax) 대신 options(intel_syntax)를 명시하면 Intel 문법을 사용할 수 있으며, 일부 개발자는 이것이 더 읽기 쉽다고 느낍니다.


9. 부트로더 통합 - bootloader 크레이트 활용

시작하며

여러분이 no_std 커널을 완성하고 나서 "이제 어떻게 실행하지?"라는 질문에 막막함을 느낀 적 있나요? BIOS 부트 섹터를 직접 작성하거나, GRUB 설정 파일을 만들거나, 멀티부트2 헤더를 추가하는 등의 복잡한 작업이 필요해 보입니다.

이런 문제는 커널이 스스로 부팅할 수 없기 때문에 발생합니다. CPU가 전원을 켜면 BIOS/UEFI가 실행되고, 이것이 부트로더를 로드하며, 부트로더가 커널을 메모리에 적재하고 보호 모드나 롱 모드로 전환한 후에야 커널이 실행됩니다.

이 과정은 레거시 BIOS와 현대 UEFI에서 완전히 다르며, 16비트 리얼 모드, 32비트 보호 모드, 64비트 롱 모드 전환, GDT/IDT 설정, A20 게이트 활성화 등 수십 가지 세부사항을 처리해야 합니다. 바로 이럴 때 필요한 것이 bootloader 크레이트입니다.

이것은 Rust로 작성된 부트로더로, BIOS와 UEFI를 모두 지원하며, 여러분의 커널을 자동으로 로드하고 64비트 롱 모드로 전환하여 실행합니다.

개요

간단히 말해서, bootloader 크레이트는 Rust 커널을 위한 완전한 부트로더 솔루션입니다. 커널 바이너리를 부팅 가능한 디스크 이미지로 패키징하고, 메모리 맵, 페이지 테이블, 프레임 버퍼 정보를 커널에 전달합니다.

왜 이 개념이 필요한지 실무 관점에서 설명하자면, 부트로더를 직접 작성하는 것은 매우 복잡하고 시간이 오래 걸립니다. 예를 들어, UEFI 부트로더는 FAT32 파일 시스템을 읽고, PE 포맷 실행 파일을 파싱하며, GOP(Graphics Output Protocol)로 프레임 버퍼를 설정하는 등 수천 줄의 코드가 필요합니다.

bootloader 크레이트는 이 모든 것을 처리하여 여러분이 커널 개발에만 집중할 수 있게 해줍니다. 기존에는 GRUB을 사용하여 멀티부트 헤더를 작성하고 grub-mkrescue로 ISO 이미지를 생성했다면, 이제는 bootloader 크레이트를 Cargo 의존성으로 추가하고 bootimage 도구로 한 번에 부팅 가능한 이미지를 생성할 수 있습니다.

bootloader 크레이트의 핵심 특징은 첫째, BIOS와 UEFI를 자동으로 감지하고 지원하며, 둘째, BootInfo 구조체로 메모리 맵과 프레임 버퍼를 커널에 전달하고, 셋째, 커널을 상위 반 가상 주소(0xFFFF800000000000)에 매핑한다는 것입니다. 이러한 특징들이 중요한 이유는 커널이 가상 메모리를 사용하면서도 안정적으로 동작할 수 있게 하며, 메모리 관리자를 초기화하는 데 필요한 정보를 제공하기 때문입니다.

코드 예제

// Cargo.toml에 의존성 추가
// [dependencies]
// bootloader = "0.11"

use bootloader::{BootInfo, entry_point};

// 부트로더가 호출할 커널 진입점 정의
entry_point!(kernel_main);

// 커널 메인 함수 - BootInfo를 받음
fn kernel_main(boot_info: &'static mut BootInfo) -> ! {
    // VGA 버퍼 초기화
    vga_buffer::init();
    println!("Hello from Rust OS!");

    // 물리 메모리 정보 출력
    let memory_map = &boot_info.memory_regions;
    println!("Memory regions:");
    for region in memory_map.iter() {
        println!("  {:?}: {:#x} - {:#x}",
            region.kind,
            region.start,
            region.end
        );
    }

    // 프레임 버퍼 정보 (UEFI의 경우)
    if let Some(framebuffer) = boot_info.framebuffer.as_mut() {
        let info = framebuffer.info();
        println!("Framebuffer: {}x{} @ {:p}",
            info.width,
            info.height,
            framebuffer.buffer().as_ptr()
        );
    }

    // 물리 메모리 오프셋 (가상 주소 계산에 사용)
    let phys_mem_offset = boot_info.physical_memory_offset
        .into_option()
        .expect("Physical memory offset required");

    println!("Physical memory offset: {:#x}", phys_mem_offset);

    // 메인 루프
    loop {
        x86_64::instructions::hlt();
    }
}

// bootimage로 이미지 생성: cargo bootimage --release

설명

이것이 하는 일: 이 코드는 bootloader 크레이트를 사용하여 Rust 커널의 진입점을 정의하고, 부트로더가 제공하는 하드웨어 정보를 받아 초기화를 수행합니다. entry_point!

매크로는 타입 안전한 커널 진입점을 생성하며, BootInfo는 메모리 관리에 필요한 모든 정보를 담고 있습니다. 첫 번째로, entry_point!(kernel_main) 매크로는 부트로더와 커널 사이의 계약을 정의합니다.

이 매크로는 실제로 _start 함수를 생성하고, 스택을 설정하며, BSS 섹션을 0으로 초기화한 후 kernel_main을 호출합니다. 이렇게 하는 이유는 부트로더가 어떤 호출 규약을 사용하든 상관없이 일관된 인터페이스를 제공하기 위함입니다.

kernel_main의 시그니처는 반드시 fn(&'static mut BootInfo) -> !이어야 하며, 컴파일러가 이를 검증합니다. 그 다음으로, BootInfo 구조체는 memory_regions 필드로 물리 메모리의 레이아웃을 제공합니다.

각 MemoryRegion은 시작 주소, 끝 주소, 타입(사용 가능, 예약됨, ACPI, MMIO 등)을 포함하며, 이 정보는 프레임 할당자를 초기화하는 데 필수적입니다. 예를 들어, Usable 타입 영역만 메모리 관리자에게 등록하여 안전하게 할당할 수 있도록 해야 하며, ACPI나 MMIO 영역을 덮어쓰면 시스템이 불안정해집니다.

세 번째로, framebuffer 필드는 UEFI 부팅 시 그래픽 출력을 위한 정보를 제공합니다. 프레임 버퍼는 화면의 각 픽셀에 대응하는 메모리 영역이며, buffer()는 픽셀 데이터의 슬라이스를, info()는 해상도와 픽셀 포맷을 반환합니다.

이를 사용하면 VGA 텍스트 모드보다 훨씬 풍부한 그래픽 출력이 가능하며, 커스텀 폰트, 색상, 그래픽 요소를 렌더링할 수 있습니다. 마지막으로, physical_memory_offset은 물리 주소를 가상 주소로 변환하는 데 사용됩니다.

bootloader는 커널을 상위 반 주소 공간에 매핑하고, 전체 물리 메모리를 이 오프셋에서 시작하는 주소에 매핑합니다. 예를 들어, physical_memory_offset이 0xFFFF800000000000이면, 물리 주소 0x1000은 가상 주소 0xFFFF800000001000에 매핑되며, 이를 통해 커널이 모든 물리 메모리에 접근할 수 있습니다.

여러분이 이 코드를 사용하면 cargo install bootimage 후 cargo bootimage만으로 QEMU나 실제 하드웨어에서 부팅 가능한 이미지를 생성할 수 있습니다. qemu-system-x86_64 -drive format=raw,file=target/x86_64-my-os/debug/bootimage-my-os.bin 명령으로 즉시 테스트할 수 있으며, BIOS와 UEFI를 모두 지원하므로 현대적인 컴퓨터와 레거시 시스템 모두에서 동작합니다.

또한 메모리 맵 정보를 바탕으로 bump allocator나 buddy allocator 같은 프레임 할당자를 구현할 수 있으며, 이는 나중에 힙 할당자를 만드는 기반이 됩니다.

실전 팁

💡 실무에서는 .cargo/config.toml에 [target.'cfg(target_os = "none")'] runner = "bootimage runner"를 추가하면 cargo run만으로 QEMU가 자동 실행됩니다.

💡 흔한 실수로 BootInfo를 복사하려고 하면 크기가 너무 커서 스택 오버플로우가 발생하니, 항상 참조(&)로 사용하세요.

💡 UEFI 전용 기능이 필요하다면 Cargo.toml에 bootloader = { version = "0.11", features = ["uefi"] }를 지정하세요.

💡 디버깅 시 bootloader의 map-physical-memory 기능을 사용하면 커널이 모든 물리 메모리에 접근할 수 있어 페이지 테이블 디버깅이 편해집니다.

💡 성능 최적화를 위해 release 빌드에서는 LTO를 활성화하고(lto = true), 불필요한 디버그 심볼을 제거하여(strip = true) 이미지 크기를 수 MB에서 수백 KB로 줄일 수 있습니다.


10. no_std 테스트 프레임워크 - 자동화된 테스트

시작하며

여러분이 VGA 버퍼나 메모리 관리자를 구현하고 나서 "이게 제대로 작동하는지 어떻게 테스트하지?"라는 고민을 하신 적 있나요? cargo test를 실행하면 표준 라이브러리가 없어서 테스트 프레임워크가 동작하지 않습니다.

이런 문제는 Rust의 기본 테스트 러너가 std 라이브러리에 의존하기 때문에 발생합니다. 일반적으로 cargo test는 각 테스트를 스레드로 실행하고, 패닉을 캐치하며, 결과를 터미널에 예쁘게 출력하는데, 이 모든 것이 OS 기능을 필요로 합니다.

또한 no_std 환경에서는 println!도 사용할 수 없어 테스트 결과를 확인할 방법조차 없습니다. 바로 이럴 때 필요한 것이 커스텀 테스트 프레임워크입니다.

#![feature(custom_test_frameworks)]를 사용하여 여러분만의 테스트 러너를 구현하고, 직렬 포트로 테스트 결과를 출력하며, QEMU의 종료 코드로 성공/실패를 전달할 수 있습니다.

개요

간단히 말해서, 커스텀 테스트 프레임워크는 no_std 환경에서 자동화된 단위 테스트와 통합 테스트를 가능하게 하는 시스템입니다. 테스트 함수를 수집하고, 실행하며, 결과를 보고하는 모든 과정을 직접 제어합니다.

왜 이 개념이 필요한지 실무 관점에서 설명하자면, OS 개발에서도 TDD(Test-Driven Development)와 CI/CD가 매우 중요합니다. 예를 들어, 페이지 테이블 구현을 변경할 때마다 수동으로 QEMU를 실행하고 화면을 확인하는 것은 비효율적이며, 커밋할 때마다 자동으로 수백 개의 테스트를 실행하여 회귀 버그를 즉시 발견하는 것이 훨씬 생산적입니다.

기존에는 cargo test로 표준 테스트를 실행했다면, 이제는 커스텀 테스트 러너를 구현하여 no_std 환경에서도 #[test_case] 속성으로 테스트를 정의하고 자동으로 실행할 수 있습니다. 또한 QEMU의 isa-debug-exit 디바이스를 사용하여 테스트 성공/실패를 종료 코드로 전달할 수 있습니다.

커스텀 테스트 프레임워크의 핵심 특징은 첫째, test_runner 함수로 테스트 실행 로직을 완전히 제어하고, 둘째, 직렬 포트로 테스트 결과를 출력하며, 셋째, QEMU 종료 코드로 CI/CD 시스템과 통합한다는 것입니다. 이러한 특징들이 중요한 이유는 OS 개발의 품질을 보장하고 장기적인 유지보수를 가능하게 하기 때문입니다.

코드 예제

// main.rs - 테스트 프레임워크 설정
#![cfg_attr(test, no_main)]
#![feature(custom_test_frameworks)]
#![test_runner(crate::test_runner)]
#![reexport_test_harness_main = "test_main"]

use core::panic::PanicInfo;

// QEMU 종료를 위한 포트 정의
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u32)]
pub enum QemuExitCode {
    Success = 0x10,
    Failed = 0x11,
}

pub fn exit_qemu(exit_code: QemuExitCode) {
    unsafe {
        // isa-debug-exit 디바이스 (0xf4 포트)
        let port: u16 = 0xf4;
        core::arch::asm!(
            "out dx, eax",
            in("dx") port,
            in("eax") exit_code as u32,
        );
    }
}

// 테스트 러너 구현
pub fn test_runner(tests: &[&dyn Testable]) {
    serial_println!("Running {} tests", tests.len());
    for test in tests {
        test.run();
    }
    exit_qemu(QemuExitCode::Success);
}

// 테스트 트레이트
pub trait Testable {
    fn run(&self);
}

impl<T: Fn()> Testable for T {
    fn run(&self) {
        serial_print!("{}...\t", core::any::type_name::<T>());
        self();
        serial_println!("[ok]");
    }
}

// 테스트 모드 진입점
#[cfg(test)]
#[no_mangle]
pub extern "C" fn _start() -> ! {
    test_main();
    loop {}
}

// 테스트 패닉 핸들러
#[cfg(test)]
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
    serial_println!("[failed]");
    serial_println!("Error: {}", info);
    exit_qemu(QemuExitCode::Failed);
    loop {}
}

// 예제 테스트
#[test_case]
fn test_basic_addition() {
    assert_eq!(1 + 1, 2);
}

#[test_case]
fn test_vga_buffer() {
    let mut writer = vga_buffer::Writer::new();
    writer.write_byte(b'X');
    // 추가 검증...
}

설명

이것이 하는 일: 이 코드는 no_std 환경에서 완전히 작동하는 테스트 프레임워크를 구현합니다. Rust의 불안정 기능인 custom_test_frameworks를 활용하여 테스트 함수를 수집하고, 실행하며, 결과를 직렬 포트로 출력한 후 QEMU를 적절한 종료 코드로 종료시킵니다.

첫 번째로, #![test_runner(crate::test_runner)] 속성은 컴파일러에게 기본 테스트 러너 대신 우리가 정의한 test_runner 함수를 사용하도록 지시합니다. #![reexport_test_harness_main = "test_main"]은 테스트 진입점을 test_main이라는 이름으로 노출하여 _start에서 호출할 수 있게 합니다.

이렇게 하는 이유는 no_std 환경에서는 기본 테스트 러너가 std::process::exit 같은 OS 기능을 사용하여 작동하지 않기 때문입니다. 그 다음으로, exit_qemu 함수는 QEMU의 isa-debug-exit 디바이스를 사용하여 에뮬레이터를 종료합니다.

이 디바이스는 0xf4 포트에 값을 쓰면 QEMU가 그 값을 종료 코드로 사용하며, 실제 종료 코드는 (value << 1) | 1 공식으로 계산됩니다. 따라서 0x10은 33(성공), 0x11은 35(실패)가 되며, CI 스크립트에서 이를 확인하여 테스트 성공 여부를 판단할 수 있습니다.

세 번째로, test_runner와 Testable 트레이트는 테스트 실행 로직을 구현합니다. 컴파일러는 #[test_case]가 붙은 모든 함수를 수집하여 &[&dyn Testable] 슬라이스로 test_runner에 전달합니다.

impl<T: Fn()> Testable for T는 모든 함수 타입에 대해 Testable을 구현하며, core::any::type_name::<T>()로 함수 이름을 얻어 출력합니다. 각 테스트를 실행하고 패닉이 발생하지 않으면 [ok]를 출력하며, 패닉이 발생하면 panic_handler가 [failed]를 출력하고 QEMU를 종료합니다.

마지막으로, #[cfg(test)]는 조건부 컴파일을 사용하여 테스트 모드에서만 특정 코드를 포함시킵니다. 테스트 모드의 _start는 test_main()을 호출하여 모든 테스트를 실행하고, 테스트용 panic_handler는 에러 정보를 출력한 후 즉시 QEMU를 종료하여 다음 테스트로 진행하지 않습니다.

이는 하나의 실패한 테스트가 전체 시스템을 손상시키는 것을 방지합니다. 여러분이 이 코드를 사용하면 cargo test로 자동화된 테스트를 실행할 수 있으며, tests/ 디렉토리에 통합 테스트를 추가하면 각각이 독립적인 바이너리로 컴파일되어 격리된 환경에서 실행됩니다.

CI/CD 시스템(GitHub Actions, GitLab CI)에서 이를 자동화하면 모든 커밋에서 회귀 버그를 즉시 발견할 수 있으며, 테스트 커버리지를 추적하여 코드 품질을 유지할 수 있습니다. 또한 QEMU의 -device isa-debug-exit,iobase=0xf4,iosize=0x04 옵션을 .cargo/config.toml의 runner에 추가하여 자동으로 디바이스를 활성화할 수 있습니다.

실전 팁

💡 .cargo/config.toml에 test-success-exit-code = 33을 추가하면 cargo test가 종료 코드 33을 성공으로 인식합니다.

💡 흔한 실수로 serial_println!을 구현하지 않으면 테스트 결과를 볼 수 없으니, uart_16550 크레이트를 사용하여 직렬 포트를 설정하세요.

💡 통합 테스트를 작성할 때는 tests/should_panic.rs 같은 파일을 만들어 패닉이 예상되는 상황을 테스트할 수 있습니다.

💡 디버깅 시 QEMU의 -serial file:serial.log 옵션으로 직렬 출력을 파일에 저장하면 나중에 분석할 수 있습니다.

💡 성능 테스트를 위해 rdtsc 명령어로 타임스탬프를 측정하고, 여러 번 실행한 평균을 계산하여 안정적인 벤치마크를 얻을 수 있습니다.


#Rust#no_std#OS개발#베어메탈#시스템프로그래밍

댓글 (0)

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