이미지 로딩 중...

Rust로 만드는 나만의 OS 첫 베어메탈 프로그램 실행 - 슬라이드 1/10
A

AI Generated

2025. 11. 14. · 3 Views

Rust로 만드는 나만의 OS 첫 베어메탈 프로그램 실행

Rust로 OS를 직접 만들어보는 여정의 첫 걸음, 베어메탈 환경에서 프로그램을 실행하는 방법을 단계별로 알아봅니다. 부트로더 설정부터 타겟 설정, 실제 하드웨어에서의 실행까지 모든 과정을 다룹니다.


목차

  1. 베어메탈 환경 이해하기 - OS 없이 돌아가는 프로그램의 세계
  2. 커스텀 타겟 설정하기 - CPU에게 직접 말 걸기
  3. 부트로더 설정 - bootimage로 부팅 가능한 이미지 만들기
  4. VGA 텍스트 모드 - 화면에 직접 글자 쓰기
  5. 매크로 구현 - println! 만들기
  6. 테스팅 프레임워크 - no_std 환경에서 테스트하기
  7. CPU 예외 처리 - 인터럽트 디스크립터 테이블 설정
  8. 하드웨어 인터럽트 - 타이머와 키보드 입력 처리
  9. 메모리 관리 기초 - 페이지 테이블 다루기

1. 베어메탈 환경 이해하기 - OS 없이 돌아가는 프로그램의 세계

시작하며

여러분이 평소에 작성하는 Rust 프로그램을 생각해보세요. cargo run을 입력하면 프로그램이 실행되고, println! 매크로로 콘솔에 출력하고, 파일을 읽고 쓰죠.

하지만 이 모든 것이 운영체제가 제공하는 기능 덕분이라는 걸 아시나요? 베어메탈(Bare-metal) 프로그래밍은 이런 운영체제의 도움 없이 하드웨어 위에서 직접 실행되는 프로그램을 만드는 것입니다.

표준 라이브러리도, 시스템 콜도, 심지어 메모리 할당자조차 없는 환경에서 여러분의 코드가 CPU와 직접 대화합니다. 바로 이것이 자신만의 OS를 만드는 첫 번째 단계입니다.

운영체제 자체가 베어메탈 프로그램이기 때문이죠. 지금부터 Rust로 이 신비로운 세계에 발을 들여놓아보겠습니다.

개요

간단히 말해서, 베어메탈 프로그래밍은 하드웨어 위에서 직접 실행되는 코드를 작성하는 것입니다. 일반적인 애플리케이션 개발과 달리, OS 개발에서는 표준 라이브러리를 사용할 수 없습니다.

Linux나 Windows가 제공하는 시스템 콜, 파일 시스템, 네트워크 스택 같은 것들이 전혀 없죠. 예를 들어, 화면에 글자 하나를 출력하려면 VGA 버퍼의 메모리 주소에 직접 접근해야 합니다.

기존에는 C/C++로 OS를 개발했다면, 이제는 Rust의 메모리 안전성과 제로 코스트 추상화를 활용할 수 있습니다. Rust는 컴파일 타임에 메모리 안전성을 보장하면서도 런타임 오버헤드가 없어 시스템 프로그래밍에 최적입니다.

베어메탈 환경의 핵심 특징은 세 가지입니다. 첫째, no_std 속성으로 표준 라이브러리를 사용하지 않습니다.

둘째, 커스텀 타겟 스펙으로 CPU 아키텍처를 직접 정의합니다. 셋째, 패닉 핸들러와 언어 항목들을 직접 구현해야 합니다.

이러한 특징들이 여러분에게 하드웨어에 대한 완전한 제어권을 줍니다.

코드 예제

// 표준 라이브러리를 사용하지 않음
#![no_std]
// main 함수를 진입점으로 사용하지 않음
#![no_main]

use core::panic::PanicInfo;

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

// 진입점 함수
#[no_mangle]
pub extern "C" fn _start() -> ! {
    // VGA 버퍼에 직접 접근하여 'H' 출력
    let vga_buffer = 0xb8000 as *mut u8;
    unsafe {
        *vga_buffer.offset(0) = b'H';
        *vga_buffer.offset(1) = 0x0f; // 흰색 글자
    }

    loop {}
}

설명

이것이 하는 일: 이 코드는 운영체제 없이 부팅 직후 CPU가 실행할 수 있는 최소한의 Rust 프로그램을 정의합니다. 첫 번째로, #![no_std] 속성은 Rust 표준 라이브러리를 사용하지 않겠다고 선언합니다.

표준 라이브러리는 OS의 기능에 의존하기 때문에 베어메탈 환경에서는 사용할 수 없습니다. 대신 core 라이브러리만 사용할 수 있는데, 이것은 OS 독립적인 기본 타입과 트레이트만 제공합니다.

#![no_main]은 일반적인 main 함수 대신 커스텀 진입점을 사용하겠다는 의미입니다. 그 다음으로, panic_handler를 직접 구현해야 합니다.

일반적인 Rust 프로그램에서는 패닉이 발생하면 표준 라이브러리가 스택 추적을 출력하고 프로그램을 종료합니다. 하지만 베어메탈 환경에서는 이런 기능이 없으므로 직접 구현해야 하죠.

여기서는 간단히 무한 루프를 돌며 CPU를 정지시킵니다. _start 함수는 부트로더가 호출하는 실제 진입점입니다.

#[no_mangle]은 컴파일러가 함수 이름을 변경하지 않도록 보장하고, extern "C"는 C 호출 규약을 사용하도록 지정합니다. VGA 버퍼(0xb8000)에 직접 접근하여 화면의 첫 번째 위치에 흰색 'H' 문자를 출력합니다.

마지막 무한 루프는 프로그램이 종료되지 않도록 하며, 반환 타입 !은 이 함수가 절대 반환하지 않음을 나타냅니다. 여러분이 이 코드를 컴파일하고 실행하면 검은 화면 왼쪽 상단에 하얀 'H' 글자 하나가 나타납니다.

단순해 보이지만, 이것은 OS 없이 하드웨어와 직접 소통한 첫 번째 성과입니다. 이 작은 프로그램이 여러분만의 운영체제를 만드는 출발점이 됩니다.

실전 팁

💡 no_std 환경에서는 Vec, String 같은 힙 할당 타입을 사용할 수 없습니다. 대신 배열이나 스택 기반 자료구조를 활용하거나, 나중에 직접 힙 할당자를 구현해야 합니다.

💡 VGA 버퍼에 쓸 때는 반드시 volatile 접근을 사용하세요. 컴파일러 최적화로 인해 쓰기가 생략될 수 있습니다. volatile 크레이트를 사용하면 안전하게 처리할 수 있습니다.

💡 _start 함수 이름은 링커가 찾는 기본 심볼입니다. 다른 이름을 사용하려면 링커 스크립트를 수정해야 합니다.

💡 디버깅이 어렵기 때문에 초기에는 QEMU 에뮬레이터를 사용하세요. 실제 하드웨어보다 디버깅 기능이 훨씬 풍부합니다.

💡 loop {} 대신 hlt 명령어를 사용하면 CPU가 절전 모드로 들어가 전력 소비를 줄일 수 있습니다. 인라인 어셈블리로 구현 가능합니다.


2. 커스텀 타겟 설정하기 - CPU에게 직접 말 걸기

시작하며

여러분의 Rust 프로그램을 컴파일할 때 cargo build만 입력하면 자동으로 현재 시스템에 맞는 실행 파일이 만들어지죠? 이건 Rust 컴파일러가 기본 타겟(예: x86_64-unknown-linux-gnu)을 알고 있기 때문입니다.

하지만 OS를 만들 때는 상황이 다릅니다. 리눅스도, 윈도우도 없는 베어메탈 환경을 위한 바이너리를 만들어야 하는데, 기본 타겟은 모두 OS 기능을 전제로 합니다.

부동소수점 연산 방식, 스택 해제 방법, 심지어 메모리 레이아웃까지 모두 달라야 하죠. 바로 이럴 때 필요한 것이 커스텀 타겟 스펙입니다.

JSON 파일 하나로 CPU에게 "이렇게 동작해!"라고 정확히 지시할 수 있습니다. 지금부터 여러분만의 타겟을 만들어보겠습니다.

개요

간단히 말해서, 타겟 스펙은 컴파일러에게 어떤 환경을 위한 코드를 생성할지 알려주는 설정 파일입니다. 일반적인 애플리케이션은 호스트 운영체제의 ABI(Application Binary Interface)를 따릅니다.

시스템 콜 규약, 링킹 방식, 실행 파일 포맷 등이 모두 정해져 있죠. 예를 들어, Linux에서는 ELF 포맷을 사용하고 glibc와 링크하는 등의 암묵적 규칙들이 있습니다.

하지만 베어메탈 환경에서는 이런 규칙들을 여러분이 직접 정의해야 합니다. 기존에는 복잡한 빌드 스크립트와 크로스 컴파일 도구 체인을 구성했다면, Rust에서는 JSON 파일 하나로 모든 것을 제어할 수 있습니다.

LLVM 기반 컴파일러 덕분에 타겟 변경이 매우 유연합니다. 커스텀 타겟의 핵심 설정은 다섯 가지입니다.

CPU 아키텍처(x86_64), 데이터 레이아웃, 링커 설정, 패닉 전략(abort), 그리고 스택 해제 비활성화입니다. 이러한 설정들이 베어메탈 환경에 최적화된 바이너리를 생성하게 해줍니다.

코드 예제

{
  "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"
}

설명

이것이 하는 일: 이 JSON 파일은 Rust 컴파일러에게 OS 없는 x86_64 환경을 위한 바이너리를 생성하는 방법을 정확히 알려줍니다. 첫 번째로, llvm-targetos 필드가 핵심입니다.

x86_64-unknown-none은 64비트 x86 아키텍처이지만 운영체제가 없다는 의미입니다. os: none도 같은 맥락이죠.

이 설정으로 컴파일러는 OS 관련 코드를 전혀 생성하지 않습니다. data-layout은 메모리에서 데이터가 어떻게 배치될지 정의하는데, 엔디안, 포인터 크기, 정렬 방식 등을 지정합니다.

그 다음으로, panic-strategy: abort는 매우 중요합니다. 기본값인 unwind는 스택을 되감으며 소멸자를 호출하는데, 이는 OS의 기능이 필요합니다.

abort로 설정하면 패닉 시 즉시 프로그램을 중단하여 추가 런타임 지원 없이도 동작합니다. disable-redzone: true는 레드존(함수가 스택 포인터를 조정하지 않고 사용하는 128바이트 영역)을 비활성화합니다.

인터럽트 핸들러에서 레드존을 사용하면 데이터가 손상될 수 있기 때문입니다. features: "-mmx,-sse,+soft-float"는 SIMD 명령어를 비활성화하고 소프트웨어 부동소수점을 활성화합니다.

MMX와 SSE는 추가 레지스터를 사용하는데, 인터럽트 핸들러에서 이들을 저장/복원하는 것은 복잡하고 느립니다. 소프트웨어 부동소수점은 느리지만 안전합니다.

linkerlinker-flavor 설정은 Rust와 함께 제공되는 LLD 링커를 사용하도록 지정하여 별도의 시스템 링커 없이도 빌드할 수 있게 합니다. 여러분이 이 타겟 스펙을 사용하면 완전히 독립적인 바이너리를 얻을 수 있습니다.

어떤 라이브러리에도 의존하지 않고, 부팅 직후 CPU가 실행할 수 있는 순수한 기계어 코드만 포함됩니다. 이 파일을 x86_64-custom_os.json으로 저장하고 cargo build --target x86_64-custom_os.json으로 빌드하면 됩니다.

실전 팁

💡 타겟 스펙 파일 이름은 자유롭게 지을 수 있지만, 관례적으로 아키텍처-벤더-os.json 형식을 따릅니다. 예: x86_64-blog_os-none.json

💡 data-layout을 잘못 설정하면 정렬 오류로 인한 크래시가 발생합니다. 기존 타겟의 레이아웃을 복사하여 사용하는 것이 안전합니다.

💡 .cargo/config.tomlbuild-std = ["core", "compiler_builtins"]를 추가하면 core 라이브러리를 타겟에 맞게 재컴파일합니다. 이는 필수입니다.

💡 disable-redzone을 설정하지 않으면 인터럽트 핸들러에서 스택 손상이 발생할 수 있습니다. OS 개발에서는 반드시 비활성화하세요.

💡 부동소수점 연산이 정말 필요하다면 나중에 SSE를 활성화하되, 인터럽트 핸들러에서 XMM 레지스터를 저장/복원하는 코드를 추가해야 합니다.


3. 부트로더 설정 - bootimage로 부팅 가능한 이미지 만들기

시작하며

여러분이 열심히 작성한 베어메탈 코드를 컴파일했습니다. ELF 바이너리가 생성되었죠.

이제 이걸 어떻게 실행할까요? USB에 복사해서 부팅하면 될까요?

안타깝게도 그렇지 않습니다. 컴퓨터가 부팅될 때 BIOS나 UEFI는 특정 형식의 부트 섹터를 찾습니다.

512바이트 크기에 특정 시그니처(0xAA55)가 있어야 하고, 16비트 리얼 모드로 시작해야 하죠. 여러분의 64비트 Rust 코드를 직접 부팅할 수는 없습니다.

CPU를 보호 모드로 전환하고, 페이징을 설정하고, 코드를 메모리에 로드하는 복잡한 과정이 필요합니다. 바로 이럴 때 필요한 것이 부트로더입니다.

bootimage 도구를 사용하면 이 모든 복잡한 과정을 자동화할 수 있습니다. 한 줄의 명령어로 부팅 가능한 디스크 이미지를 만들어보겠습니다.

개요

간단히 말해서, 부트로더는 여러분의 커널을 메모리에 로드하고 실행하는 작은 프로그램입니다. OS 개발에서 부트로더는 필수적입니다.

BIOS/UEFI 펌웨어와 여러분의 커널 사이를 연결하는 다리 역할을 하죠. CPU를 16비트 리얼 모드에서 32비트 보호 모드를 거쳐 64비트 롱 모드로 전환하고, 페이지 테이블을 설정하며, 커널 바이너리를 적절한 메모리 위치에 로드합니다.

예를 들어, 멀티부트2 호환 부트로더는 메모리 맵 정보를 커널에 전달하여 사용 가능한 RAM 영역을 알려줍니다. 기존에는 GRUB 같은 복잡한 부트로더를 설정하거나 직접 어셈블리로 부트 섹터를 작성했다면, Rust 생태계에서는 bootloader 크레이트와 bootimage 도구가 이를 자동화합니다.

단 몇 줄의 설정으로 부팅 가능한 이미지를 생성할 수 있습니다. 부트로더의 핵심 기능은 네 가지입니다.

BIOS 호출을 통한 디스크 읽기, CPU 모드 전환, 페이징 설정, 그리고 커널로 제어 이동입니다. 이러한 기능들이 여러분의 Rust 코드가 64비트 환경에서 실행될 수 있게 준비해줍니다.

코드 예제

# Cargo.toml에 추가
[dependencies]
bootloader = "0.9"

# .cargo/config.toml 파일 생성
[build]
target = "x86_64-custom_os.json"

[unstable]
build-std = ["core", "compiler_builtins"]
build-std-features = ["compiler-builtins-mem"]

# bootimage 설치 및 사용
# $ cargo install bootimage
# $ cargo bootimage

# QEMU로 실행
# $ qemu-system-x86_64 -drive format=raw,file=target/x86_64-custom_os/debug/bootimage-your_os.bin

설명

이것이 하는 일: 이 설정은 Rust 커널을 부팅 가능한 디스크 이미지로 변환하는 전체 파이프라인을 구성합니다. 첫 번째로, bootloader 크레이트를 의존성에 추가합니다.

이 크레이트는 BIOS 부팅을 지원하는 작은 부트로더를 제공하며, 여러분의 커널 바이너리와 자동으로 링크됩니다. 컴파일 타임에 부트로더가 커널의 진입점(_start)을 찾아 호출하도록 설정됩니다.

bootloader 크레이트는 최소한의 환경 설정(Identity 매핑된 페이지 테이블, 스택 등)을 제공한 후 커널로 제어를 넘깁니다. 그 다음으로, .cargo/config.tomlbuild-std 설정이 중요합니다.

이것은 corecompiler_builtins 라이브러리를 커스텀 타겟용으로 재컴파일합니다. 일반적으로 이들은 미리 컴파일된 형태로 제공되지만, 커스텀 타겟은 표준에 없으므로 소스에서 빌드해야 합니다.

compiler-builtins-mem 피처는 memcpy, memset 같은 메모리 관련 함수들을 포함시킵니다. cargo bootimage 명령은 세 단계로 동작합니다.

먼저 커널을 빌드하고, 부트로더를 빌드한 후, 둘을 결합하여 부팅 가능한 디스크 이미지를 생성합니다. 생성된 이미지는 실제 USB에 dd 명령으로 쓰거나 QEMU 같은 에뮬레이터로 실행할 수 있습니다.

QEMU 명령은 생성된 바이너리를 가상 하드 디스크로 마운트하여 부팅을 시뮬레이션합니다. 여러분이 이 설정을 완료하고 cargo bootimage를 실행하면 target 디렉토리에 .bin 파일이 생성됩니다.

이 파일은 완전히 독립적인 부팅 가능한 이미지로, 실제 하드웨어나 가상 머신에서 실행할 수 있습니다. QEMU로 실행하면 여러분의 코드가 화면에 'H'를 출력하는 것을 볼 수 있습니다.

이것은 여러분이 만든 첫 번째 운영체제의 순간입니다!

실전 팁

💡 bootimage는 내부적으로 cargo buildbootloader 빌드를 순차적으로 실행합니다. 빌드 시간을 줄이려면 --release 플래그를 사용하세요.

💡 QEMU에서 디버깅하려면 -s -S 옵션을 추가하세요. GDB를 포트 1234로 연결하여 단계별 실행이 가능합니다.

💡 실제 하드웨어에서 테스트할 때는 dd if=bootimage.bin of=/dev/sdX bs=512로 USB에 쓸 수 있습니다. 단, 디바이스를 잘못 지정하면 데이터가 손실되니 주의하세요.

💡 bootloader 크레이트 버전에 따라 제공하는 기능이 다릅니다. 0.9 버전은 안정적이지만 UEFI를 지원하지 않습니다. UEFI가 필요하면 최신 버전을 고려하세요.

💡 부트로더가 제공하는 메모리 맵은 bootloader::bootinfo::BootInfo 구조체를 통해 접근할 수 있습니다. 커널 진입점에서 이를 받아 메모리 관리에 활용하세요.


4. VGA 텍스트 모드 - 화면에 직접 글자 쓰기

시작하며

여러분의 OS가 부팅되었습니다! 하지만 사용자에게 아무것도 보여줄 수 없다면 소용이 없겠죠?

println! 매크로는 사용할 수 없으니, 화면에 출력하는 방법을 직접 구현해야 합니다. 다행히 x86 아키텍처는 VGA 텍스트 버퍼라는 간단한 방법을 제공합니다.

메모리 주소 0xb8000부터 시작하는 영역에 문자와 색상 정보를 쓰면 자동으로 화면에 표시됩니다. 복잡한 그래픽 드라이버 없이도 80x25 크기의 텍스트 콘솔을 사용할 수 있죠.

바로 이것이 대부분의 OS가 부팅 초기에 사용하는 방법입니다. 안전하고 간단하며, Rust의 타입 시스템으로 더 안전하게 만들 수 있습니다.

지금부터 컬러풀한 텍스트를 출력하는 라이터를 만들어보겠습니다.

개요

간단히 말해서, VGA 텍스트 버퍼는 화면에 표시되는 문자들을 담고 있는 메모리 영역입니다. 일반적인 애플리케이션에서는 GUI 프레임워크나 터미널 에뮬레이터가 텍스트 렌더링을 처리합니다.

하지만 OS 개발 초기에는 이런 것들이 없으므로 하드웨어가 제공하는 가장 기본적인 기능을 사용해야 합니다. VGA 텍스트 모드는 1980년대부터 사용된 방식으로, 오늘날에도 모든 x86 시스템이 호환성을 위해 지원합니다.

예를 들어, GRUB 부트로더의 메뉴도 VGA 텍스트 모드로 표시됩니다. 기존에는 C로 포인터 연산과 타입 캐스팅을 사용했다면, Rust에서는 타입 안전성을 유지하면서 동일한 기능을 구현할 수 있습니다.

volatile 크레이트를 사용하면 컴파일러 최적화로 인한 문제도 방지할 수 있습니다. VGA 버퍼의 핵심 개념은 세 가지입니다.

각 문자는 2바이트(ASCII 코드 1바이트 + 색상 속성 1바이트)로 표현되고, 80x25 = 2000개의 문자 셀이 있으며, 메모리 매핑 I/O 방식으로 쓰기만 하면 자동으로 화면에 반영됩니다. 이러한 단순함이 초기 OS 개발을 가능하게 합니다.

코드 예제

use core::fmt;
use volatile::Volatile;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum Color {
    Black = 0,
    Blue = 1,
    Green = 2,
    Cyan = 3,
    Red = 4,
    Magenta = 5,
    Brown = 6,
    LightGray = 7,
    DarkGray = 8,
    LightBlue = 9,
    LightGreen = 10,
    LightCyan = 11,
    LightRed = 12,
    Pink = 13,
    Yellow = 14,
    White = 15,
}

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

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

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

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

#[repr(transparent)]
struct Buffer {
    chars: [[Volatile<ScreenChar>; BUFFER_WIDTH]; BUFFER_HEIGHT],
}

pub struct Writer {
    column_position: usize,
    color_code: ColorCode,
    buffer: &'static mut Buffer,
}

impl Writer {
    pub fn write_byte(&mut self, byte: u8) {
        match byte {
            b'\n' => self.new_line(),
            byte => {
                if self.column_position >= BUFFER_WIDTH {
                    self.new_line();
                }

                let row = BUFFER_HEIGHT - 1;
                let col = self.column_position;

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

    fn new_line(&mut self) {
        // 스크롤 구현 생략
        self.column_position = 0;
    }

    pub fn write_string(&mut self, s: &str) {
        for byte in s.bytes() {
            match byte {
                // 출력 가능한 ASCII 또는 개행
                0x20..=0x7e | b'\n' => self.write_byte(byte),
                // 출력 불가능한 문자
                _ => self.write_byte(0xfe),
            }
        }
    }
}

impl fmt::Write for Writer {
    fn write_str(&mut self, s: &str) -> fmt::Result {
        self.write_string(s);
        Ok(())
    }
}

설명

이것이 하는 일: 이 코드는 VGA 하드웨어와 안전하게 상호작용하여 컬러 텍스트를 화면에 출력하는 완전한 라이터를 구현합니다. 첫 번째로, Color 열거형은 VGA가 지원하는 16가지 색상을 정의합니다.

#[repr(u8)]은 각 색상 값이 정확히 1바이트로 표현되도록 보장합니다. 이는 VGA 하드웨어가 기대하는 형식과 일치해야 하기 때문에 중요합니다.

ColorCode는 전경색과 배경색을 결합하여 하나의 바이트로 만듭니다. 상위 4비트는 배경색, 하위 4비트는 전경색을 나타냅니다.

그 다음으로, ScreenChar 구조체는 화면의 한 문자를 표현합니다. #[repr(C)]는 C 언어와 동일한 메모리 레이아웃을 사용하도록 강제하여, 총 2바이트(ASCII 1바이트 + 색상 1바이트)가 연속적으로 배치됩니다.

Buffer 구조체는 80x25 배열로 전체 화면을 표현하며, Volatile 래퍼로 컴파일러 최적화를 방지합니다. VGA 버퍼는 하드웨어가 읽는 영역이므로 쓰기가 절대 생략되면 안 됩니다.

Writer 구조체는 실제 출력 로직을 담당합니다. write_byte 메서드는 개행 문자를 처리하거나 현재 커서 위치에 문자를 씁니다.

줄이 가득 차면 자동으로 새 줄로 이동합니다. buffer.chars[row][col].write()Volatile의 메서드로, 휘발성 쓰기를 수행합니다.

write_string은 UTF-8 문자열을 받아 출력 가능한 ASCII 범위(0x20~0x7e)만 출력하고, 나머지는 문자(0xfe)로 대체합니다. 마지막으로, fmt::Write 트레이트를 구현하면 Rust의 포매팅 매크로(write!, writeln!)를 사용할 수 있게 됩니다.

이를 통해 나중에 println! 같은 매크로를 쉽게 구현할 수 있습니다. 여러분이 이 라이터를 사용하면 단순히 문자를 출력하는 것을 넘어, 색상 있는 로그 메시지, 경고, 에러 표시 등을 구현할 수 있습니다.

전경색과 배경색을 조합하여 16x16 = 256가지 조합이 가능하므로, 시각적으로 풍부한 콘솔 인터페이스를 만들 수 있습니다. 예를 들어, 에러는 빨간색, 경고는 노란색, 정보는 파란색으로 표시할 수 있죠.

실전 팁

💡 Volatile 사용이 필수입니다. 그렇지 않으면 컴파일러가 "아무도 읽지 않는 메모리"로 판단하여 쓰기를 최적화로 제거할 수 있습니다.

💡 스크롤 기능을 구현하려면 모든 줄을 한 칸씩 위로 이동시키고 마지막 줄을 지워야 합니다. chars.copy_within() 메서드가 유용합니다.

💡 lazy_static! 매크로로 전역 Writer를 만들면 코드 어디서나 println!처럼 사용할 수 있습니다. 스핀락으로 동기화하세요.

💡 VGA 버퍼는 메모리 매핑 I/O이므로 캐싱 문제가 발생할 수 있습니다. 페이지 테이블 엔트리에서 해당 페이지를 uncacheable로 설정하세요.

💡 ASCII 범위 밖의 문자는 Code Page 437 폰트를 사용합니다. 이를 활용하면 간단한 박스 그리기 문자나 특수 기호를 표시할 수 있습니다.


5. 매크로 구현 - println! 만들기

시작하며

여러분은 이제 Writer를 사용해서 화면에 글자를 쓸 수 있습니다. 하지만 매번 writer.write_string("Hello")를 타이핑하는 건 너무 불편하죠?

Rust 개발자라면 println!("Hello, {}", name) 같은 편리한 매크로에 익숙할 것입니다. 표준 라이브러리 없는 환경에서는 std::println!을 사용할 수 없지만, 우리만의 println! 매크로를 만들 수 있습니다.

Rust의 매크로 시스템은 강력해서 포매팅, 가변 인자, 컴파일 타임 검사까지 모두 지원합니다. 바로 이것이 OS 개발을 더 생산적으로 만드는 방법입니다.

한 번 매크로를 구현하면 이후 모든 디버깅과 로깅이 편해집니다. 지금부터 표준 라이브러리 수준의 println! 매크로를 만들어보겠습니다.

개요

간단히 말해서, 매크로는 컴파일 타임에 코드를 생성하는 Rust의 메타프로그래밍 기능입니다. 일반적인 함수와 달리 매크로는 가변 개수의 인자를 받을 수 있고, 포매팅 문자열을 컴파일 타임에 검증할 수 있습니다.

println!("x = {}", x)에서 {}의 개수와 인자의 개수가 맞지 않으면 컴파일 오류가 발생하는 것도 매크로 덕분이죠. 예를 들어, 런타임 오버헤드 없이 디버그 정보를 조건부로 출력하는 것도 가능합니다.

기존에는 C의 printf처럼 런타임에 포맷 문자열을 파싱했다면, Rust의 매크로는 컴파일 타임에 모든 것을 검증하고 최적화된 코드를 생성합니다. 타입 안전성도 보장되어 잘못된 타입을 출력하려는 시도는 컴파일 오류로 이어집니다.

매크로 구현의 핵심은 세 가지입니다. 전역 Writer 인스턴스를 lazy_static!으로 생성하고, 스핀락으로 동기화하며, core::fmt::Write 트레이트를 활용한 포매팅입니다.

이러한 요소들이 안전하고 효율적인 출력 시스템을 만들어줍니다.

코드 예제

use lazy_static::lazy_static;
use spin::Mutex;

lazy_static! {
    pub static ref WRITER: Mutex<Writer> = Mutex::new(Writer {
        column_position: 0,
        color_code: ColorCode::new(Color::Yellow, Color::Black),
        buffer: unsafe { &mut *(0xb8000 as *mut Buffer) },
    });
}

#[macro_export]
macro_rules! print {
    ($($arg:tt)*) => ($crate::vga_buffer::_print(format_args!($($arg)*)));
}

#[macro_export]
macro_rules! println {
    () => ($crate::print!("\n"));
    ($($arg:tt)*) => ($crate::print!("{}\n", format_args!($($arg)*)));
}

#[doc(hidden)]
pub fn _print(args: fmt::Arguments) {
    use core::fmt::Write;
    WRITER.lock().write_fmt(args).unwrap();
}

// 사용 예제
pub fn kernel_main() {
    println!("Hello World!");
    println!("숫자: {}, 16진수: {:#x}", 42, 255);

    for i in 0..10 {
        println!("카운트: {}", i);
    }
}

설명

이것이 하는 일: 이 코드는 스레드 안전한 전역 출력 시스템과 편리한 매크로를 제공하여, no_std 환경에서도 표준 Rust와 동일한 개발 경험을 만들어줍니다. 첫 번째로, lazy_static! 매크로는 런타임 초기화가 필요한 정적 변수를 만듭니다.

일반적인 static은 컴파일 타임 상수만 허용하지만, lazy_static!은 첫 접근 시 초기화 코드를 실행합니다. WRITERMutex로 감싸져 있어 여러 스레드(나중에 구현할)에서 안전하게 접근할 수 있습니다.

spin::Mutex는 표준 라이브러리의 뮤텍스와 달리 OS 기능 없이 동작하는 스핀락 기반 뮤텍스입니다. VGA 버퍼 주소 0xb8000을 unsafe 블록에서 raw pointer로 변환하여 참조를 만듭니다.

그 다음으로, print!println! 매크로는 표준 라이브러리의 것과 동일한 문법을 사용합니다. $($arg:tt)*는 "임의의 토큰 트리를 0개 이상 받는다"는 의미로, 가변 인자를 지원합니다.

format_args!는 컴파일러 내장 매크로로, 포맷 문자열과 인자들을 fmt::Arguments 구조체로 변환합니다. 이때 타입 검사와 포맷 문자열 검증이 모두 수행됩니다.

#[macro_export]는 매크로를 크레이트 루트에서 export하여 다른 모듈에서도 사용 가능하게 합니다. _print 함수는 실제 출력을 담당합니다.

#[doc(hidden)]으로 문서에는 나타나지 않지만 매크로에서 호출됩니다. WRITER.lock()은 뮤텍스를 획득하며, 다른 스레드가 이미 락을 보유 중이면 스핀(busy-wait)합니다.

write_fmtcore::fmt::Write 트레이트의 메서드로, fmt::Arguments를 받아 포매팅된 문자열을 출력합니다. unwrap()은 쓰기가 실패하면 패닉을 일으키지만, VGA 버퍼 쓰기는 거의 실패하지 않습니다.

여러분이 이 매크로를 사용하면 디버깅이 엄청나게 편해집니다. 변수 값 확인, 함수 진입/종료 추적, 에러 메시지 출력 등을 표준 Rust와 동일한 방식으로 할 수 있습니다.

포매팅 기능({}, {:x}, {:#?} 등)도 모두 지원되므로, 복잡한 데이터 구조도 쉽게 출력할 수 있습니다. 예를 들어, 페이지 테이블의 엔트리를 16진수로 출력하여 디버깅하는 것도 한 줄로 가능합니다.

실전 팁

💡 패닉 핸들러에서 println!을 사용하려면 락 획득 실패를 처리해야 합니다. try_lock()을 사용하거나, 패닉 시 락을 강제로 해제하는 방법을 고려하세요.

💡 lazy_static! 대신 Rust 1.63+의 Mutex::new()const fn에서 사용할 수 있습니다. 하지만 spin::Mutex는 아직 const 생성자를 지원하지 않을 수 있으니 확인하세요.

💡 컬러 출력을 위한 매크로를 추가로 만들 수 있습니다. 예: println_red!, println_error! 등. 이들은 내부적으로 WRITERcolor_code를 변경합니다.

💡 성능이 중요하다면 버퍼링을 구현하세요. 문자를 즉시 VGA 버퍼에 쓰는 대신 내부 버퍼에 모았다가 한 번에 쓰면 더 빠릅니다.

💡 직렬 포트(UART) 출력도 함께 구현하면 QEMU의 -serial 옵션으로 로그를 파일로 저장할 수 있어 디버깅이 더 편해집니다.


6. 테스팅 프레임워크 - no_std 환경에서 테스트하기

시작하며

여러분의 OS 코드가 점점 복잡해지고 있습니다. VGA 출력, 메모리 관리, 인터럽트 처리...

이 모든 것이 제대로 작동하는지 어떻게 확인할까요? 일반 Rust 프로젝트라면 cargo test를 실행하면 되지만, 베어메탈 환경은 다릅니다.

표준 테스트 프레임워크는 OS 기능(프로세스 생성, 파일 I/O 등)에 의존하므로 사용할 수 없습니다. 테스트 결과를 어디에 출력할까요?

테스트 실패 시 어떻게 종료할까요? QEMU에서 자동으로 테스트를 실행하고 결과를 받아올 방법은 없을까요?

바로 이럴 때 필요한 것이 커스텀 테스트 프레임워크입니다. Rust의 custom_test_frameworks 기능을 사용하면 여러분만의 테스트 러너를 만들 수 있습니다.

QEMU의 isa-debug-exit 디바이스로 테스트 성공/실패를 호스트에 전달하는 방법까지 알아보겠습니다.

개요

간단히 말해서, 커스텀 테스트 프레임워크는 OS 없이도 자동화된 테스트를 실행할 수 있게 해줍니다. 일반적인 Rust 테스트는 std::test 프레임워크를 사용하며, 각 테스트를 별도 스레드로 실행하고 결과를 콘솔에 출력합니다.

하지만 베어메탈 환경에서는 스레드도, 콘솔도 없죠. 예를 들어, VGA 버퍼 쓰기가 정확한 메모리 위치에 값을 설정하는지 테스트하려면, 테스트 함수를 호출하고 결과를 VGA나 직렬 포트로 출력하는 커스텀 러너가 필요합니다.

기존에는 테스트를 수동으로 실행하고 결과를 눈으로 확인했다면, 이제는 CI/CD 파이프라인에서 자동으로 실행되는 테스트를 만들 수 있습니다. QEMU의 종료 코드를 활용하면 테스트 성공(exit code 0)과 실패(exit code 1)를 구분할 수 있습니다.

테스트 프레임워크의 핵심 요소는 네 가지입니다. #[test_case] 속성으로 테스트 함수를 마킹하고, 커스텀 테스트 러너로 모든 테스트를 실행하며, 직렬 포트로 결과를 출력하고, QEMU 디바이스로 성공/실패를 전달합니다.

이러한 구조가 완전히 자동화된 테스팅 환경을 만들어줍니다.

코드 예제

// main.rs의 상단에 추가
#![feature(custom_test_frameworks)]
#![test_runner(crate::test_runner)]
#![reexport_test_harness_main = "test_main"]

#[cfg(test)]
fn test_runner(tests: &[&dyn Fn()]) {
    serial_println!("Running {} tests", tests.len());
    for test in tests {
        test();
    }
    exit_qemu(QemuExitCode::Success);
}

pub fn test_panic_handler(info: &PanicInfo) -> ! {
    serial_println!("[failed]\n");
    serial_println!("Error: {}\n", info);
    exit_qemu(QemuExitCode::Failed);
    loop {}
}

#[cfg(test)]
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
    test_panic_handler(info)
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u32)]
pub enum QemuExitCode {
    Success = 0x10,
    Failed = 0x11,
}

pub fn exit_qemu(exit_code: QemuExitCode) {
    use x86_64::instructions::port::Port;

    unsafe {
        let mut port = Port::new(0xf4);
        port.write(exit_code as u32);
    }
}

// 테스트 예제
#[test_case]
fn trivial_assertion() {
    serial_print!("trivial assertion... ");
    assert_eq!(1, 1);
    serial_println!("[ok]");
}

#[test_case]
fn test_println() {
    serial_print!("test_println... ");
    println!("test_println output");
    serial_println!("[ok]");
}

// Cargo.toml에 추가
[package.metadata.bootimage]
test-args = ["-device", "isa-debug-exit,iobase=0xf4,iosize=0x04"]
test-success-exit-code = 33  # (0x10 << 1) | 1

설명

이것이 하는 일: 이 코드는 OS 기능 없이도 완전히 자동화된 테스트 실행과 결과 수집이 가능한 인프라를 구축합니다. 첫 번째로, #![feature(custom_test_frameworks)]는 unstable 기능을 활성화하여 커스텀 테스트 러너를 사용 가능하게 합니다.

#![test_runner(crate::test_runner)]cargo test 실행 시 여러분의 test_runner 함수가 호출되도록 지정합니다. #![reexport_test_harness_main = "test_main"]은 테스트 진입점을 test_main이라는 이름으로 export하여, 일반 실행 시에는 무시되고 cargo test 시에만 호출됩니다.

그 다음으로, test_runner 함수는 모든 #[test_case] 함수들의 포인터를 슬라이스로 받습니다. 각 테스트를 순회하며 호출하고, 모두 성공하면 exit_qemu로 QEMU를 종료합니다.

테스트 중 하나라도 assert!panic!을 일으키면 패닉 핸들러가 호출되어 실패 코드로 종료합니다. serial_println!은 직렬 포트로 출력하는 매크로로(별도 구현 필요), QEMU의 -serial 옵션으로 호스트의 stdout에 출력됩니다.

exit_qemu 함수는 특수한 I/O 포트(0xf4)에 값을 쓰는 방식으로 QEMU와 통신합니다. isa-debug-exit 디바이스는 이 포트에 쓰인 값을 받아 (value << 1) | 1을 종료 코드로 사용합니다.

따라서 0x10을 쓰면 종료 코드 33이 되고, 0x11을 쓰면 35가 됩니다. Cargo.toml의 test-success-exit-code = 33 설정은 33을 성공으로 인식하게 합니다.

x86_64 크레이트의 Port 타입은 안전한 포트 I/O를 제공합니다. 테스트 함수들은 #[test_case] 속성으로 마킹됩니다.

컴파일러는 이들을 모아 배열로 만들어 테스트 러너에 전달합니다. 각 테스트는 독립적으로 실행되며, 하나가 실패하면 즉시 패닉 핸들러가 호출되어 QEMU가 종료됩니다.

성공한 테스트는 [ok]를 출력하고 계속 진행합니다. 여러분이 이 프레임워크를 설정하면 cargo test로 모든 테스트를 자동 실행할 수 있습니다.

테스트는 QEMU에서 실행되고, 결과는 터미널에 표시되며, CI 시스템에서도 동일하게 작동합니다. 예를 들어, GitHub Actions에서 커밋할 때마다 자동으로 테스트를 돌려 회귀를 방지할 수 있습니다.

디버깅도 쉬워집니다. 버그가 발생하면 먼저 실패하는 테스트를 작성하고, 수정 후 테스트가 통과하는지 확인하는 TDD 방식을 적용할 수 있습니다.

실전 팁

💡 직렬 포트 출력을 위한 serial_println! 매크로는 UART 16550 드라이버로 구현합니다. uart_16550 크레이트를 사용하면 간단합니다.

💡 각 테스트를 별도의 QEMU 인스턴스로 실행하면 격리가 보장됩니다. test-argstest-timeout을 적절히 설정하세요.

💡 통합 테스트를 tests/ 디렉토리에 추가할 수 있습니다. 각 파일은 별도의 실행 파일로 컴파일되어 완전히 독립적으로 테스트됩니다.

💡 should_panic 속성을 구현하려면 테스트가 패닉을 일으켰는지 추적하는 플래그를 추가해야 합니다. 패닉 핸들러에서 이를 확인하여 성공/실패를 결정합니다.

💡 코드 커버리지를 측정하려면 cargo-tarpaulin 대신 LLVM의 소스 기반 커버리지 도구를 사용하세요. QEMU에서도 작동합니다.


7. CPU 예외 처리 - 인터럽트 디스크립터 테이블 설정

시작하며

여러분의 OS가 실행되다가 0으로 나누기를 시도하거나 잘못된 메모리 주소에 접근하면 어떻게 될까요? 일반 애플리케이션이라면 OS가 프로세스를 종료하고 에러 메시지를 보여주겠지만, 여러분이 만드는 것이 바로 그 OS입니다!

CPU는 예외 상황(나누기 오류, 페이지 폴트, 더블 폴트 등)이 발생하면 특정 핸들러를 호출하려고 시도합니다. 하지만 핸들러가 어디에 있는지 알려주지 않으면 CPU는 혼란에 빠져 트리플 폴트를 일으키고 리부팅됩니다.

실제로 초기 OS 개발에서 가장 흔한 증상이 "무한 리부팅"입니다. 바로 이럴 때 필요한 것이 IDT(Interrupt Descriptor Table)입니다.

CPU에게 "이 예외가 발생하면 저 함수를 호출해!"라고 알려주는 테이블이죠. 지금부터 안전하게 예외를 처리하는 시스템을 구축해보겠습니다.

개요

간단히 말해서, IDT는 CPU 예외와 인터럽트에 대한 핸들러 함수들의 주소를 담고 있는 테이블입니다. 일반적인 애플리케이션에서는 OS가 이미 IDT를 설정해놓았기 때문에 예외가 발생하면 커널의 핸들러가 호출됩니다.

하지만 OS를 만들 때는 이 IDT를 직접 구성해야 합니다. x86_64 아키텍처는 256개의 엔트리를 지원하며, 처음 32개는 CPU 예외용으로 예약되어 있습니다.

예를 들어, 엔트리 0은 나누기 오류(Division Error), 엔트리 13은 일반 보호 폴트(General Protection Fault), 엔트리 14는 페이지 폴트입니다. 기존에는 복잡한 어셈블리 코드와 구조체 정의가 필요했다면, Rust에서는 x86_64 크레이트가 타입 안전한 API를 제공합니다.

핸들러 함수 시그니처도 컴파일 타임에 검증되어 잘못된 호출 규약을 사용하면 컴파일 오류가 발생합니다. IDT 설정의 핵심 단계는 네 가지입니다.

IDT 구조체를 생성하고, 각 엔트리에 핸들러 함수를 등록하며, lidt 명령으로 CPU에 IDT 주소를 알려주고, 예외 발생 시 핸들러가 호출되도록 합니다. 이러한 과정이 안정적인 예외 처리 기반을 만들어줍니다.

코드 예제

use x86_64::structures::idt::{InterruptDescriptorTable, InterruptStackFrame};

lazy_static! {
    static ref IDT: InterruptDescriptorTable = {
        let mut idt = InterruptDescriptorTable::new();
        idt.breakpoint.set_handler_fn(breakpoint_handler);
        idt.double_fault.set_handler_fn(double_fault_handler);
        idt
    };
}

pub fn init_idt() {
    IDT.load();
}

extern "x86-interrupt" fn breakpoint_handler(
    stack_frame: InterruptStackFrame)
{
    println!("EXCEPTION: BREAKPOINT\n{:#?}", stack_frame);
}

extern "x86-interrupt" fn double_fault_handler(
    stack_frame: InterruptStackFrame,
    _error_code: u64) -> !
{
    panic!("EXCEPTION: DOUBLE FAULT\n{:#?}", stack_frame);
}

// 커널 초기화 함수에서 호출
pub fn kernel_main() -> ! {
    println!("Hello World!");

    init_idt();

    // 브레이크포인트 예외 발생시키기
    x86_64::instructions::interrupts::int3();

    println!("It did not crash!");

    loop {}
}

설명

이것이 하는 일: 이 코드는 CPU가 예외 상황에서 패닉 대신 여러분의 핸들러 함수를 호출하도록 설정하여, 안정적인 예외 처리 시스템을 구축합니다. 첫 번째로, lazy_static!으로 IDT를 정적 변수로 생성합니다.

InterruptDescriptorTable::new()는 모든 엔트리가 비어있는 IDT를 만듭니다. set_handler_fn 메서드로 특정 예외에 대한 핸들러를 등록합니다.

여기서는 브레이크포인트(int3 명령)와 더블 폴트 두 가지를 설정했습니다. 브레이크포인트는 디버깅에 유용하고, 더블 폴트는 다른 예외의 핸들러가 실패했을 때 발생하므로 반드시 처리해야 합니다.

그 다음으로, init_idt 함수는 IDT.load()를 호출하여 CPU의 IDTR(IDT Register)에 테이블 주소를 설정합니다. 이는 특권 명령(privileged instruction)이므로 커널 모드에서만 실행 가능합니다.

로드된 후부터는 CPU가 예외 발생 시 IDT를 참조하여 핸들러를 찾습니다. 핸들러 함수들은 extern "x86-interrupt" 호출 규약을 사용합니다.

이는 일반 함수와 달리 특별한 진입/종료 시퀀스를 가집니다. CPU는 예외 발생 시 자동으로 스택에 리턴 주소와 플래그를 저장하고, 핸들러 종료 시 iretq 명령으로 복귀합니다.

InterruptStackFrame은 CPU가 스택에 푸시한 정보들(명령 포인터, 코드 세그먼트, 플래그 등)을 담고 있습니다. 더블 폴트 핸들러는 추가로 에러 코드를 받으며, 반환 타입이 !입니다.

더블 폴트에서 복귀는 불가능하기 때문입니다. x86_64::instructions::interrupts::int3()는 브레이크포인트 예외를 발생시키는 명령입니다.

디버거가 중단점을 설정할 때 사용하는 명령이죠. 이 명령이 실행되면 CPU는 IDT의 엔트리 3을 찾아 breakpoint_handler를 호출합니다.

핸들러는 스택 프레임을 출력한 후 정상적으로 반환하므로, "It did not crash!" 메시지가 출력됩니다. 여러분이 이 IDT를 설정하면 OS가 훨씬 안정적으로 동작합니다.

버그로 인한 예외 발생 시 즉시 리부팅되는 대신, 유용한 에러 메시지와 스택 트레이스를 볼 수 있습니다. 디버깅이 엄청나게 쉬워지죠.

페이지 폴트 핸들러를 추가하면 어떤 주소에 접근하려다 실패했는지 정확히 알 수 있고, 일반 보호 폴트 핸들러로는 권한 위반을 감지할 수 있습니다. 나중에는 타이머 인터럽트와 키보드 인터럽트도 동일한 방식으로 처리합니다.

실전 팁

💡 더블 폴트는 스택 오버플로우 시에도 발생합니다. 별도의 IST(Interrupt Stack Table) 엔트리를 설정하여 새로운 스택을 사용하도록 해야 안전합니다.

💡 x86-interrupt 호출 규약은 nightly Rust에서만 사용 가능합니다. #![feature(abi_x86_interrupt)]를 추가해야 합니다.

💡 페이지 폴트 핸들러에서는 CR2 레지스터를 읽어 어떤 주소에 접근하려 했는지 확인할 수 있습니다. x86_64::registers::control::Cr2::read()를 사용하세요.

💡 인터럽트 핸들러 내에서 힙 할당이나 락 획득은 위험합니다. 데드락이나 재진입 문제가 발생할 수 있으므로 최소한의 작업만 수행하세요.

💡 모든 예외에 기본 핸들러를 설정하여 예상치 못한 예외도 처리하세요. 루프를 돌며 각 엔트리에 set_handler_fn을 호출하면 됩니다.


8. 하드웨어 인터럽트 - 타이머와 키보드 입력 처리

시작하며

여러분의 OS가 이제 CPU 예외를 처리할 수 있게 되었습니다. 하지만 외부 세계와 소통하려면 어떻게 해야 할까요?

키보드 입력은 어떻게 받을까요? 주기적인 작업은 어떻게 스케줄링할까요?

하드웨어 인터럽트가 바로 그 답입니다. 키보드, 타이머, 네트워크 카드 같은 디바이스들은 데이터가 준비되면 CPU에 인터럽트를 보냅니다.

CPU는 하던 일을 멈추고 인터럽트 핸들러를 실행한 후 다시 돌아옵니다. 이것이 현대 OS의 핵심 메커니즘입니다.

바로 이것을 구현하려면 PIC(Programmable Interrupt Controller)를 설정하고, 타이머와 키보드 인터럽트 핸들러를 등록해야 합니다. 지금부터 OS가 외부 이벤트에 반응하도록 만들어보겠습니다.

개요

간단히 말해서, 하드웨어 인터럽트는 외부 디바이스가 CPU의 주의를 끌기 위해 보내는 신호입니다. 일반적인 애플리케이션에서는 OS가 인터럽트를 처리하고 시스템 콜이나 이벤트를 통해 결과를 전달합니다.

하지만 OS 개발에서는 직접 하드웨어 인터럽트를 처리해야 합니다. x86 시스템은 PIC(8259 PIC)를 사용하여 최대 15개의 인터럽트 라인을 관리합니다.

예를 들어, IRQ 0은 타이머, IRQ 1은 키보드, IRQ 3/4는 직렬 포트입니다. 기존에는 복잡한 PIC 초기화 시퀀스와 I/O 포트 조작이 필요했다면, Rust에서는 pic8259 크레이트가 이를 추상화합니다.

안전한 API로 PIC를 초기화하고 EOI(End of Interrupt) 신호를 보낼 수 있습니다. 하드웨어 인터럽트 처리의 핵심은 네 가지입니다.

PIC 초기화로 인터럽트 번호 범위를 설정하고, IDT에 인터럽트 핸들러를 등록하며, sti 명령으로 인터럽트를 활성화하고, 핸들러에서 EOI를 보내 PIC에 처리 완료를 알립니다. 이러한 단계들이 반응형 OS를 만들어줍니다.

코드 예제

use pic8259::ChainedPics;
use spin::Mutex;

pub const PIC_1_OFFSET: u8 = 32;
pub const PIC_2_OFFSET: u8 = PIC_1_OFFSET + 8;

pub static PICS: Mutex<ChainedPics> =
    Mutex::new(unsafe { ChainedPics::new(PIC_1_OFFSET, PIC_2_OFFSET) });

#[derive(Debug, Clone, Copy)]
#[repr(u8)]
pub enum InterruptIndex {
    Timer = PIC_1_OFFSET,
    Keyboard,
}

impl InterruptIndex {
    fn as_u8(self) -> u8 {
        self as u8
    }

    fn as_usize(self) -> usize {
        usize::from(self.as_u8())
    }
}

lazy_static! {
    static ref IDT: InterruptDescriptorTable = {
        let mut idt = InterruptDescriptorTable::new();
        idt.breakpoint.set_handler_fn(breakpoint_handler);
        idt[InterruptIndex::Timer.as_usize()]
            .set_handler_fn(timer_interrupt_handler);
        idt[InterruptIndex::Keyboard.as_usize()]
            .set_handler_fn(keyboard_interrupt_handler);
        idt
    };
}

extern "x86-interrupt" fn timer_interrupt_handler(
    _stack_frame: InterruptStackFrame)
{
    print!(".");

    unsafe {
        PICS.lock()
            .notify_end_of_interrupt(InterruptIndex::Timer.as_u8());
    }
}

extern "x86-interrupt" fn keyboard_interrupt_handler(
    _stack_frame: InterruptStackFrame)
{
    use x86_64::instructions::port::Port;

    let mut port = Port::new(0x60);
    let scancode: u8 = unsafe { port.read() };

    println!("Key: {:x}", scancode);

    unsafe {
        PICS.lock()
            .notify_end_of_interrupt(InterruptIndex::Keyboard.as_u8());
    }
}

pub fn init() {
    init_idt();
    unsafe { PICS.lock().initialize() };
    x86_64::instructions::interrupts::enable();
}

설명

이것이 하는 일: 이 코드는 하드웨어 디바이스의 인터럽트를 받아 처리하는 완전한 시스템을 구축하여, OS가 외부 이벤트에 반응할 수 있게 합니다. 첫 번째로, PIC 오프셋 설정이 중요합니다.

기본적으로 PIC는 인터럽트 번호 0-15를 사용하는데, 이는 CPU 예외(0-31)와 겹칩니다. 따라서 32-47 범위로 재매핑해야 합니다.

ChainedPics::new(32, 40)은 마스터 PIC를 32-39로, 슬레이브 PIC를 40-47로 설정합니다. InterruptIndex 열거형은 각 인터럽트에 의미 있는 이름을 부여하여 매직 넘버를 방지합니다.

그 다음으로, IDT에 핸들러를 등록합니다. idt[32]는 타이머 인터럽트이고, idt[33]은 키보드 인터럽트입니다.

타이머는 기본적으로 초당 약 18.2Hz로 틱하므로 핸들러가 매우 자주 호출됩니다. 여기서는 간단히 점(.)을 출력하여 시간이 흐르는 것을 시각화합니다.

나중에는 이를 활용해 태스크 스케줄링을 구현할 수 있습니다. 키보드 인터럽트 핸들러는 포트 0x60에서 스캔 코드를 읽습니다.

스캔 코드는 키보드가 보내는 바이트로, 어떤 키가 눌렸는지(make code) 또는 떼어졌는지(break code)를 나타냅니다. 예를 들어, 'A' 키를 누르면 0x1e, 떼면 0x9e(0x1e + 0x80)가 전송됩니다.

pc-keyboard 크레이트를 사용하면 스캔 코드를 ASCII나 유니코드로 변환할 수 있습니다. 모든 인터럽트 핸들러는 마지막에 반드시 EOI(End of Interrupt)를 PIC에 보내야 합니다.

notify_end_of_interrupt가 이를 수행합니다. EOI를 보내지 않으면 PIC는 인터럽트가 아직 처리 중이라고 판단하여 같은 우선순위나 낮은 우선순위의 인터럽트를 차단합니다.

init 함수는 IDT 로드, PIC 초기화, 인터럽트 활성화(sti 명령)를 순서대로 수행합니다. 여러분이 이 코드를 실행하면 화면에 점들이 계속 찍히고, 키보드를 누를 때마다 스캔 코드가 출력됩니다.

이것은 OS가 살아있고 반응한다는 증거입니다! 타이머 인터럽트를 활용하면 멀티태스킹을 구현할 수 있고, 키보드 인터럽트로는 셸이나 텍스트 에디터를 만들 수 있습니다.

네트워크 카드, 디스크 컨트롤러 등 다른 하드웨어도 동일한 방식으로 처리합니다. 이것이 모든 인터럽트 기반 프로그래밍의 기초입니다.

실전 팁

💡 인터럽트 핸들러는 가능한 한 빨리 실행되어야 합니다. 시간이 오래 걸리는 작업은 별도의 태스크로 미루고, 핸들러는 최소한의 작업만 수행하세요.

💡 현대 시스템은 APIC(Advanced PIC)를 사용합니다. 멀티코어 지원이 필요하면 PIC 대신 APIC를 설정해야 합니다.

💡 키보드 버퍼를 구현하여 스캔 코드를 저장하세요. 핸들러에서 직접 처리하지 말고, 메인 루프에서 버퍼를 읽어 처리하는 것이 안전합니다.

💡 타이머 주파수를 변경하려면 PIT(Programmable Interval Timer)의 분주기를 설정해야 합니다. 포트 0x40-0x43을 통해 제어할 수 있습니다.

💡 인터럽트가 예상보다 자주 발생하면 "인터럽트 스톰"이 발생할 수 있습니다. CPU가 핸들러 실행에만 시간을 쓰게 되므로, 인터럽트 빈도를 모니터링하세요.


9. 메모리 관리 기초 - 페이지 테이블 다루기

시작하며

여러분의 OS가 이제 외부 입력에 반응합니다. 하지만 메모리는 어떻게 관리할까요?

프로그램이 임의의 물리 메모리 주소에 접근하는 것을 어떻게 막을까요? 여러 프로세스가 동시에 실행될 때 메모리 충돌은 어떻게 방지할까요?

가상 메모리와 페이징이 바로 그 답입니다. 모든 프로그램은 가상 주소 공간을 사용하고, CPU의 MMU(Memory Management Unit)가 페이지 테이블을 참조하여 물리 주소로 변환합니다.

이를 통해 메모리 보호, 프로세스 격리, 심지어 디스크 스왑까지 가능해집니다. 바로 이것을 이해하고 조작하는 것이 OS 개발의 핵심입니다.

부트로더가 이미 기본 페이지 테이블을 설정했지만, 여러분이 직접 읽고 수정할 수 있어야 합니다. 지금부터 페이지 테이블의 구조를 파악하고 새로운 매핑을 만들어보겠습니다.

개요

간단히 말해서, 페이지 테이블은 가상 주소를 물리 주소로 변환하는 정보를 담고 있는 계층적 자료구조입니다. 일반적인 애플리케이션은 가상 주소만 사용하며 물리 주소를 전혀 의식하지 않습니다.

OS가 페이지 테이블을 관리하여 각 프로세스에게 독립적인 주소 공간을 제공하죠. x86_64 아키텍처는 4단계 페이지 테이블을 사용합니다.

48비트 가상 주소를 9비트씩 나누어 PML4, PDP, PD, PT 테이블을 인덱싱하고, 최종적으로 4KB 페이지의 물리 주소를 얻습니다. 예를 들어, 가상 주소 0x1000을 읽으려 하면 CPU는 자동으로 4단계 테이블을 순회하여 실제 물리 주소를 찾습니다.

기존에는 복잡한 비트 연산과 포인터 조작이 필요했다면, x86_64 크레이트는 안전한 추상화를 제공합니다. PageTable, PageTableEntry 같은 타입으로 테이블 구조를 타입 안전하게 다룰 수 있습니다.

페이지 테이블 조작의 핵심은 네 가지입니다. CR3 레지스터에서 최상위 테이블 주소를 읽고, 가상 주소를 인덱스로 분해하며, 각 레벨의 엔트리를 순회하고, 새로운 매핑을 위해 플래그를 설정합니다.

이러한 작업들이 유연한 메모리 관리를 가능하게 합니다.

코드 예제

use x86_64::structures::paging::{PageTable, PhysFrame, Page, Size4KiB, FrameAllocator};
use x86_64::{VirtAddr, PhysAddr};

pub unsafe fn active_level_4_table(physical_memory_offset: VirtAddr)
    -> &'static mut PageTable
{
    use x86_64::registers::control::Cr3;

    let (level_4_table_frame, _) = Cr3::read();

    let phys = level_4_table_frame.start_address();
    let virt = physical_memory_offset + phys.as_u64();
    let page_table_ptr: *mut PageTable = virt.as_mut_ptr();

    &mut *page_table_ptr
}

pub fn translate_addr(addr: VirtAddr, physical_memory_offset: VirtAddr)
    -> Option<PhysAddr>
{
    use x86_64::structures::paging::page_table::FrameError;

    let table = unsafe { active_level_4_table(physical_memory_offset) };

    let table_indexes = [
        addr.p4_index(), addr.p3_index(),
        addr.p2_index(), addr.p1_index()
    ];

    let mut frame = table[table_indexes[0]].frame().ok()?;

    for &index in &table_indexes[1..] {
        let virt = physical_memory_offset + frame.start_address().as_u64();
        let table_ptr: *const PageTable = virt.as_ptr();
        let table = unsafe { &*table_ptr };

        let entry = &table[index];
        frame = entry.frame().ok()?;
    }

    Some(frame.start_address() + u64::from(addr.page_offset()))
}

pub fn create_example_mapping(
    page: Page,
    mapper: &mut OffsetPageTable,
    frame_allocator: &mut impl FrameAllocator<Size4KiB>,
) {
    use x86_64::structures::paging::PageTableFlags as Flags;

    let frame = PhysFrame::containing_address(PhysAddr::new(0xb8000));
    let flags = Flags::PRESENT | Flags::WRITABLE;

    let map_to_result = unsafe {
        mapper.map_to(page, frame, flags, frame_allocator)
    };
    map_to_result.expect("map_to failed").flush();
}

설명

이것이 하는 일: 이 코드는 페이지 테이블을 읽고 수정하여 가상 메모리 시스템을 완전히 제어할 수 있게 해줍니다. 첫 번째로, active_level_4_table 함수는 현재 활성화된 최상위 페이지 테이블에 접근합니다.

CR3 레지스터는 PML4 테이블의 물리 주소를 담고 있습니다. 하지만 코드는 가상 주소만 사용할 수 있으므로, 물리 주소를 가상 주소로 변환해야 합니다.

physical_memory_offset은 부트로더가 제공하는 값으로, 모든 물리 메모리가 매핑된 가상 주소의 시작점입니다. 예를 들어, 물리 주소 0x1000은 가상 주소 physical_memory_offset + 0x1000으로 접근할 수 있습니다.

이것을 Identity 매핑 또는 오프셋 매핑이라고 합니다. 그 다음으로, translate_addr 함수는 4단계 테이블 순회를 수동으로 구현합니다.

가상 주소의 각 9비트 세그먼트(p4_index(), p3_index() 등)를 사용해 테이블을 인덱싱합니다. 첫 번째 테이블(PML4)의 엔트리에서 두 번째 테이블(PDP)의 프레임 주소를 읽고, 이를 가상 주소로 변환하여 접근합니다.

이 과정을 4단계 반복하면 최종적으로 물리 페이지 프레임을 얻습니다. page_offset()은 가상 주소의 하위 12비트로, 페이지 내 오프셋을 나타냅니다.

이것을 물리 프레임 주소에 더하면 최종 물리 주소가 됩니다. create_example_mapping 함수는 새로운 페이지 매핑을 생성합니다.

OffsetPageTablex86_64 크레이트가 제공하는 헬퍼로, 오프셋 매핑된 환경에서 페이지 테이블 조작을 쉽게 해줍니다. map_to 메서드는 가상 페이지를 물리 프레임에 매핑하며, 필요하면 중간 테이블 페이지를 자동으로 할당합니다(여기서 frame_allocator 사용).

PRESENT 플래그는 매핑이 유효함을, WRITABLE은 쓰기 가능함을 나타냅니다. flush()는 TLB(Translation Lookaside Buffer)에서 해당 엔트리를 무효화하여 CPU가 새 매핑을 즉시 인식하게 합니다.

여러분이 이 코드를 활용하면 동적 메모리 할당, 프로세스 격리, 메모리 보호 등을 구현할 수 있습니다. 예를 들어, 각 프로세스에게 별도의 PML4 테이블을 제공하여 완전히 독립된 주소 공간을 만들 수 있습니다.

커널 코드는 읽기 전용으로 매핑하여 실수로 수정하는 것을 방지하고, 사용자 프로그램 영역은 USER_ACCESSIBLE 플래그를 설정하여 접근을 허용할 수 있습니다. 페이지 폴트 핸들러와 결합하면 Copy-On-Write, Demand Paging, Memory-Mapped Files 같은 고급 기능도 구현 가능합니다.

실전 팁

💡 TLB 플러시는 비용이 큽니다. 여러 페이지를 한 번에 매핑한 후 한 번만 플러시하거나, invlpg 명령으로 특정 페이지만 무효화하세요.

💡 Huge Pages(2MB 또는 1GB)를 사용하면 TLB 미스가 줄어들어 성능이 향상됩니다. PD 또는 PDP 레벨에서 HUGE_PAGE 플래그를 설정하면 됩니다.

💡 재귀 페이지 테이블 트릭을 사용하면 물리 메모리 오프셋 없이도 모든 페이지 테이블에 접근할 수 있습니다. PML4의 마지막 엔트리를 자기 자신으로 매핑하세요.

💡 NO_EXECUTE 플래그를 활성화하여 데이터 영역에서 코드 실행을 방지하면 많은 보안 공격을 막을 수 있습니다.

💡 페이지 테이블 수정 시 멀티코어 환경에서는 다른 코어의 TLB도 무효화해야 합니다. IPI(Inter-Processor Interrupt)를 사용하여 TLB shootdown을 구현하세요.


#Rust#Bare-metal#OS-Development#Bootloader#x86-64#시스템프로그래밍

댓글 (0)

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