이미지 로딩 중...

Rust로 만드는 나만의 OS 전역 Writer 구현 완벽 가이드 - 슬라이드 1/10
A

AI Generated

2025. 11. 13. · 4 Views

Rust로 만드는 나만의 OS 전역 Writer 구현 완벽 가이드

Rust로 OS를 만들 때 전역 Writer를 구현하는 방법을 단계별로 알아봅니다. VGA 버퍼를 활용한 화면 출력부터 전역 정적 변수, Mutex를 이용한 동기화까지 실무에서 바로 활용 가능한 코드와 함께 설명합니다.


목차

  1. VGA 버퍼와 Writer 기본 구조 - 화면 출력의 시작점
  2. lazy_static과 Mutex로 전역 Writer 만들기 - 어디서든 접근 가능하게
  3. fmt::Write 트레이트 구현하기 - Rust 포매팅 시스템 통합
  4. 커스텀 print! 매크로 만들기 - println!처럼 사용하기
  5. 자동 스크롤링 구현하기 - 화면 넘침 처리
  6. 색상 관리 시스템 - ColorCode와 Color 열거형
  7. 패닉 핸들러에서 Writer 사용하기 - 크래시 정보 출력
  8. 인터럽트 안전 Writer - 스핀락의 한계 극복
  9. 테스트 프레임워크 통합 - Writer 동작 검증

1. VGA 버퍼와 Writer 기본 구조 - 화면 출력의 시작점

시작하며

여러분이 OS를 만들면서 화면에 텍스트를 출력하려고 할 때, 가장 먼저 마주하는 문제가 무엇인가요? 바로 "어디에, 어떻게 글자를 써야 하는가"입니다.

일반적인 프로그래밍에서는 println!이나 printf를 쓰면 되지만, OS를 만들 때는 그런 함수들이 존재하지 않습니다. 이런 문제는 베어메탈(bare metal) 환경에서 개발할 때 가장 먼저 부딪히는 벽입니다.

표준 라이브러리가 없고, 운영체제도 없는 상황에서는 하드웨어와 직접 대화해야 합니다. VGA 텍스트 모드는 x86 시스템에서 가장 간단하게 화면에 텍스트를 표시할 수 있는 방법입니다.

바로 이럴 때 필요한 것이 VGA 버퍼를 직접 제어하는 Writer 구조체입니다. 이를 통해 메모리 주소 0xb8000에 직접 접근하여 화면에 글자를 출력할 수 있습니다.

개요

간단히 말해서, VGA 버퍼는 화면에 표시될 텍스트가 저장되는 메모리 영역입니다. x86 아키텍처에서 VGA 텍스트 모드는 메모리 주소 0xb8000부터 시작하는 영역을 사용합니다.

이 영역에 데이터를 쓰면 자동으로 화면에 표시됩니다. 25행 80열의 텍스트를 표시할 수 있으며, 각 문자는 2바이트(1바이트는 ASCII 코드, 1바이트는 색상 정보)로 구성됩니다.

전통적인 OS 개발에서는 C로 이 영역에 직접 접근했습니다. 하지만 Rust를 사용하면 타입 안전성과 메모리 안전성을 보장받으면서도 동일한 작업을 수행할 수 있습니다.

Writer 구조체의 핵심 특징은 세 가지입니다. 첫째, 현재 커서 위치를 추적합니다.

둘째, VGA 버퍼에 대한 안전한 참조를 유지합니다. 셋째, 자동 스크롤링과 줄바꿈을 처리합니다.

이러한 특징들이 있어야 실제로 사용 가능한 화면 출력 시스템을 만들 수 있습니다.

코드 예제

use volatile::Volatile;

#[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 => {
                // 현재 위치에 문자 쓰기
                let row = BUFFER_HEIGHT - 1;
                let col = self.column_position;
                self.buffer.chars[row][col].write(ScreenChar {
                    ascii_character: byte,
                    color_code: self.color_code,
                });
                self.column_position += 1;
            }
        }
    }
}

설명

이것이 하는 일: VGA 버퍼라는 특수한 메모리 영역에 안전하게 접근하여 화면에 텍스트를 표시하는 인터페이스를 제공합니다. 첫 번째로, Buffer 구조체는 VGA 텍스트 버퍼의 메모리 레이아웃을 정확히 표현합니다.

#[repr(transparent)]를 사용하여 Rust의 구조체가 메모리상에서 내부 필드와 동일한 레이아웃을 갖도록 보장합니다. Volatile 래퍼를 사용하는 이유는 컴파일러가 이 메모리 쓰기를 "사용되지 않는 코드"로 판단하여 최적화로 제거하는 것을 방지하기 위함입니다.

그 다음으로, Writer 구조체가 초기화되면 column_position으로 현재 커서 위치를 추적하고, buffer는 0xb8000 주소를 가리키는 정적 참조를 갖습니다. write_byte 메서드가 호출되면 바이트 값을 확인하여 줄바꿈 문자인지 일반 문자인지 판단합니다.

일반 문자라면 현재 행의 현재 열 위치에 ScreenChar를 생성하여 write()를 호출합니다. 마지막으로, Volatile의 write() 메서드는 컴파일러 최적화를 거치지 않고 실제로 해당 메모리 주소에 값을 쓰도록 보장하며, 이렇게 쓰인 데이터는 VGA 하드웨어에 의해 자동으로 화면에 표시됩니다.

최종적으로 column_position을 증가시켜 다음 문자를 쓸 위치를 준비합니다. 여러분이 이 코드를 사용하면 표준 라이브러리 없이도 화면에 텍스트를 출력할 수 있습니다.

실무에서의 이점은 첫째, 부팅 초기 단계부터 디버깅 메시지를 출력할 수 있고, 둘째, 메모리 안전성을 보장받으면서도 하드웨어를 직접 제어할 수 있으며, 셋째, 타입 시스템을 통해 잘못된 메모리 접근을 컴파일 타임에 방지할 수 있습니다.

실전 팁

💡 Volatile 타입을 반드시 사용하세요. 일반 참조로 VGA 버퍼에 쓰면 컴파일러가 "읽히지 않는 변수"로 판단하여 최적화로 제거할 수 있습니다. 실제로 release 빌드에서 화면에 아무것도 출력되지 않는 버그의 주범입니다.

💡 Buffer의 lifetime은 'static이어야 합니다. VGA 버퍼는 프로그램이 실행되는 동안 항상 존재하는 하드웨어 메모리이므로 정적 수명이 적절합니다.

💡 #[repr(transparent)]와 #[repr(C)]를 구분하세요. 단일 필드 구조체는 transparent를, 여러 필드가 있는 구조체는 C를 사용하여 메모리 레이아웃을 제어합니다.

💡 write_byte에서 UTF-8이 아닌 ASCII만 처리하는 이유는 VGA 텍스트 모드가 Code Page 437 문자 집합만 지원하기 때문입니다. 한글 등 유니코드 문자는 별도 처리가 필요합니다.

💡 디버깅 시 QEMU의 -serial mon:stdio 옵션을 함께 사용하면 VGA 출력과 시리얼 출력을 동시에 볼 수 있어 문제 추적이 쉬워집니다.


2. lazy_static과 Mutex로 전역 Writer 만들기 - 어디서든 접근 가능하게

시작하며

여러분이 OS의 여러 모듈에서 화면에 로그를 출력하고 싶을 때, 매번 Writer 인스턴스를 전달하는 것은 너무 번거롭습니다. 커널의 메모리 관리자에서도, 인터럽트 핸들러에서도, 프로세스 스케줄러에서도 화면에 메시지를 출력하고 싶은데 매번 Writer를 파라미터로 넘기는 것은 현실적이지 않습니다.

이런 문제는 실제 OS 개발에서 매우 자주 발생합니다. 전역적으로 접근 가능한 출력 시스템이 없으면 코드가 복잡해지고, 디버깅도 어려워지며, 특히 패닉 핸들러나 인터럽트 컨텍스트에서는 Writer를 전달받을 방법조차 없습니다.

바로 이럴 때 필요한 것이 전역 정적 변수와 lazy_static입니다. Rust의 안전성을 유지하면서도 전역적으로 접근 가능한 Writer를 만들 수 있습니다.

개요

간단히 말해서, lazy_static은 런타임에 초기화되는 전역 정적 변수를 만들 수 있게 해주는 매크로입니다. 일반적인 Rust의 static 변수는 컴파일 타임에 초기화되어야 하므로 const 함수만 사용할 수 있습니다.

하지만 Writer를 초기화하려면 0xb8000 주소를 unsafe 블록에서 참조로 변환해야 하는데, 이는 컴파일 타임에 수행할 수 없습니다. lazy_static을 사용하면 첫 접근 시점에 초기화 코드가 실행되어 이 문제를 해결할 수 있습니다.

기존에는 전역 변수를 만들려면 unsafe static mut을 사용하고 모든 접근을 unsafe 블록으로 감싸야 했습니다. 이제는 lazy_static과 Mutex를 조합하여 안전하게 전역 상태를 관리할 수 있습니다.

핵심 특징은 세 가지입니다. 첫째, 처음 사용 시점에 자동으로 초기화됩니다.

둘째, Mutex로 감싸서 동시 접근을 제어합니다. 셋째, 타입 시스템이 안전성을 보장하므로 unsafe 블록이 최소화됩니다.

이러한 특징들이 있어야 멀티스레딩 환경에서도 안전하게 사용할 수 있습니다.

코드 예제

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

// 어디서든 사용 가능
pub fn print_something() {
    use core::fmt::Write;
    WRITER.lock().write_str("Hello from anywhere!\n").unwrap();
}

// 다른 모듈에서도
pub fn another_function() {
    WRITER.lock().write_byte(b'X');
}

설명

이것이 하는 일: 프로그램의 어느 곳에서든 접근할 수 있으면서도 Rust의 안전성 보장을 유지하는 전역 Writer 인스턴스를 생성합니다. 첫 번째로, lazy_static!

매크로는 WRITER라는 전역 정적 변수를 선언합니다. 이 변수는 처음 WRITER에 접근하는 순간(예: WRITER.lock()을 호출할 때) 중괄호 안의 초기화 코드가 실행됩니다.

unsafe 블록에서 0xb8000이라는 물리 주소를 *mut Buffer 포인터로 캐스팅하고, 이를 다시 mutable 참조로 변환하여 Writer를 생성합니다. 그 다음으로, Mutex<Writer>로 감싸는 것은 동시성 제어를 위함입니다.

OS 환경에서는 인터럽트가 발생하거나 멀티코어에서 여러 CPU가 동시에 화면에 쓰려고 할 수 있습니다. Mutex의 lock() 메서드를 호출하면 MutexGuard가 반환되고, 이를 통해 Writer에 안전하게 접근할 수 있습니다.

lock()이 반환한 가드가 스코프를 벗어나면 자동으로 락이 해제됩니다. 마지막으로, 이렇게 만든 WRITER는 pub으로 선언되어 있어 다른 모듈에서도 use 문으로 가져와 사용할 수 있습니다.

print_something()이나 another_function()처럼 서로 다른 함수에서 동일한 WRITER 인스턴스에 접근하여 화면에 출력할 수 있으며, Mutex가 데이터 레이스를 방지합니다. 여러분이 이 코드를 사용하면 OS의 모든 부분에서 일관된 방식으로 화면 출력을 수행할 수 있습니다.

실무에서의 이점은 첫째, 함수 시그니처가 단순해져 Writer를 파라미터로 전달할 필요가 없고, 둘째, 패닉 핸들러나 인터럽트 핸들러처럼 컨텍스트 전달이 어려운 곳에서도 출력이 가능하며, 셋째, Mutex가 자동으로 동시성을 관리하여 멀티코어 환경에서도 안전하게 동작합니다.

실전 팁

💡 spin::Mutex를 사용하는 이유는 std의 Mutex는 운영체제 기능(스레드 블로킹)에 의존하기 때문입니다. OS를 만드는 중이므로 spinlock 기반의 no_std 호환 Mutex가 필요합니다.

💡 lazy_static의 초기화는 단 한 번만 실행되며 thread-safe합니다. 여러 코어에서 동시에 첫 접근이 발생해도 초기화는 한 번만 수행되도록 보장됩니다.

💡 WRITER.lock()을 호출한 변수를 오래 유지하지 마세요. MutexGuard가 살아있는 동안 다른 코드가 WRITER에 접근할 수 없으므로 데드락이 발생할 수 있습니다. 사용 후 즉시 drop하거나 스코프를 좁게 유지하세요.

💡 인터럽트 핸들러에서 WRITER를 사용할 때는 데드락에 주의하세요. 인터럽트가 이미 lock을 보유한 코드를 중단시키고 다시 lock을 시도하면 데드락이 발생합니다. 인터럽트 비활성화나 interrupt-safe 락을 고려하세요.

💡 lazy_static 대신 const fn이 안정화되면서 OnceCell이나 OnceLock 같은 표준 라이브러리 도구를 사용할 수도 있습니다. 프로젝트의 Rust 버전과 no_std 제약을 고려하여 선택하세요.


3. fmt::Write 트레이트 구현하기 - Rust 포매팅 시스템 통합

시작하며

여러분이 write_byte로 한 글자씩 쓰는 것은 가능하지만, 실제로는 문자열이나 숫자를 편리하게 출력하고 싶을 것입니다. "Hello"라는 문자열을 출력하려고 write_byte(b'H'), write_byte(b'e')...

이렇게 하나씩 호출하는 것은 너무 비효율적입니다. 이런 문제는 로깅이나 디버깅을 할 때 특히 심각합니다.

변수 값을 출력하거나 포맷팅된 문자열을 만들려면 복잡한 수동 변환 과정이 필요하고, 코드가 지저분해지며 실수하기 쉬워집니다. Rust의 강력한 포매팅 시스템을 활용할 수 없다면 개발 생산성이 크게 떨어집니다.

바로 이럴 때 필요한 것이 fmt::Write 트레이트 구현입니다. 이를 통해 write!, writeln!

매크로를 사용하고, 나아가 println! 매크로까지 만들 수 있습니다.

개요

간단히 말해서, fmt::Write 트레이트는 포맷팅된 텍스트를 출력할 수 있는 타입이 구현해야 하는 인터페이스입니다. core::fmt::Write 트레이트는 write_str 메서드 하나만 구현하면 됩니다.

이 메서드는 문자열 슬라이스를 받아서 출력하고 Result를 반환합니다. 이 단순한 인터페이스만 구현하면 Rust 컴파일러가 자동으로 write!, writeln!

매크로를 지원하며, format_args!와 함께 복잡한 포매팅도 가능해집니다. 전통적으로는 sprintf 같은 함수를 직접 구현하거나 복잡한 포매팅 로직을 수동으로 작성해야 했습니다.

이제는 fmt::Write 트레이트만 구현하면 Rust의 전체 포매팅 인프라를 무료로 얻을 수 있습니다. 핵심 특징은 세 가지입니다.

첫째, write_str 하나만 구현하면 모든 포매팅 기능을 사용할 수 있습니다. 둘째, 제로 코스트 추상화로 성능 오버헤드가 없습니다.

셋째, 타입 안전하게 다양한 타입을 출력할 수 있습니다. 이러한 특징들이 있어야 실용적인 출력 시스템을 만들 수 있습니다.

코드 예제

use core::fmt;

impl fmt::Write for Writer {
    fn write_str(&mut self, s: &str) -> fmt::Result {
        for byte in s.bytes() {
            match byte {
                // 출력 가능한 ASCII 또는 줄바꿈
                0x20..=0x7e | b'\n' => self.write_byte(byte),
                // 출력 불가능한 문자는 ■로 표시
                _ => self.write_byte(0xfe),
            }
        }
        Ok(())
    }
}

// 이제 write! 매크로 사용 가능
pub fn test_write_macro() {
    use core::fmt::Write;
    let mut writer = WRITER.lock();
    writer.write_str("Hello ").unwrap();
    write!(writer, "The answer is {}", 42).unwrap();
    writeln!(writer, "!").unwrap();
}

설명

이것이 하는 일: Writer 타입이 Rust의 표준 포매팅 인프라와 호환되도록 만들어 복잡한 출력을 간편하게 처리할 수 있게 합니다. 첫 번째로, impl fmt::Write for Writer는 Writer 타입이 fmt::Write 트레이트를 구현한다고 선언합니다.

write_str 메서드는 &str을 받아 각 바이트를 순회하면서 처리합니다. match 표현식으로 바이트 값을 검사하여 출력 가능한 ASCII 범위(0x20~0x7e)와 줄바꿈 문자는 그대로 출력하고, 그 외의 문자는 0xfe(■ 문자)로 대체합니다.

그 다음으로, write_byte를 호출할 때마다 실제로 VGA 버퍼에 문자가 기록됩니다. UTF-8 문자열의 각 바이트가 순차적으로 처리되므로 ASCII 문자는 정상적으로 표시되지만, 멀티바이트 UTF-8 문자(한글 등)는 각 바이트가 출력 불가능 범위에 속하여 여러 개의 ■로 표시됩니다.

마지막으로 Ok(())를 반환하여 성공적으로 쓰기가 완료되었음을 알립니다. test_write_macro 함수에서 볼 수 있듯이, fmt::Write를 구현한 후에는 write!

매크로에 포맷 문자열과 인자를 전달할 수 있습니다. write!(writer, "The answer is {}", 42)를 호출하면 컴파일러가 42를 문자열로 변환하고, 전체 문자열을 writer.write_str()로 전달합니다.

이 과정은 컴파일 타임에 최적화되어 런타임 오버헤드가 거의 없습니다. 여러분이 이 코드를 사용하면 복잡한 로깅과 디버깅을 간편하게 수행할 수 있습니다.

실무에서의 이점은 첫째, 포인터 주소, 숫자, 불리언 등 다양한 타입을 자동으로 문자열로 변환하여 출력할 수 있고, 둘째, format_args!를 통해 조건부 포매팅이나 정렬, 패딩 같은 고급 기능을 사용할 수 있으며, 셋째, 기존 Rust 코드와 동일한 방식으로 출력을 다룰 수 있어 학습 곡선이 낮습니다.

실전 팁

💡 use core::fmt::Write를 임포트해야 write! 매크로를 사용할 수 있습니다. fmt::Write와 std::io::Write는 다른 트레이트이므로 혼동하지 마세요. OS 개발에서는 core::fmt::Write를 사용합니다.

💡 unwrap() 대신 에러를 무시하는 패턴도 고려하세요. 패닉 핸들러나 크리티컬한 코드에서는 write!가 실패해도 프로그램이 멈추면 안 됩니다. let _ = write!(...) 패턴을 사용할 수 있습니다.

💡 출력 불가능 문자를 0xfe로 대체하는 것은 디버깅에 유용합니다. 완전히 무시하면 문자열 길이를 파악하기 어렵지만, ■로 표시하면 몇 바이트가 출력되었는지 시각적으로 확인할 수 있습니다.

💡 성능이 중요한 경우 write_str을 최적화하세요. 현재는 바이트 단위로 처리하지만, 버퍼링이나 배치 쓰기를 구현하면 VGA 메모리 접근 횟수를 줄일 수 있습니다.

💡 write! 매크로는 실패할 수 있으므로 Result를 반환합니다. 실제 프로덕션 코드에서는 에러 처리 전략을 명확히 하세요. 로깅 실패가 프로그램 전체를 멈추게 해서는 안 됩니다.


4. 커스텀 print! 매크로 만들기 - println!처럼 사용하기

시작하며

여러분이 write! 매크로를 사용할 수 있게 되었지만, 매번 WRITER.lock()을 호출하고 use core::fmt::Write를 임포트하는 것은 여전히 번거롭습니다.

일반 Rust 프로그래밍에서는 println!("Hello")라고 간단히 쓰는데, 우리 OS에서도 그렇게 하고 싶지 않나요? 이런 문제는 코드의 가독성과 유지보수성에 영향을 미칩니다.

매번 보일러플레이트 코드를 작성하면 실수하기 쉽고, 코드가 지저분해지며, 다른 개발자가 프로젝트에 참여할 때도 진입 장벽이 높아집니다. 표준 라이브러리와 비슷한 인터페이스를 제공하면 학습 비용을 크게 줄일 수 있습니다.

바로 이럴 때 필요한 것이 커스텀 print!와 println! 매크로입니다.

Rust의 매크로 시스템을 활용하여 표준 라이브러리의 println!과 동일한 방식으로 사용할 수 있는 출력 인터페이스를 만들 수 있습니다.

개요

간단히 말해서, 매크로는 코드를 생성하는 코드로, 반복적인 패턴을 자동화하고 편리한 인터페이스를 제공합니다. Rust의 macro_rules!를 사용하면 println!

같은 함수처럼 보이는 매크로를 정의할 수 있습니다. 매크로는 컴파일 타임에 확장되므로 런타임 오버헤드가 없으며, format_args!

같은 내장 매크로와 조합하여 강력한 기능을 구현할 수 있습니다. #[macro_export]를 사용하면 다른 모듈에서도 이 매크로를 사용할 수 있습니다.

전통적으로는 printf 같은 variadic 함수를 만들어야 했지만, Rust는 variadic 함수를 지원하지 않습니다. 대신 매크로를 사용하면 타입 안전성을 유지하면서도 가변 인자를 받을 수 있습니다.

핵심 특징은 세 가지입니다. 첫째, format_args!를 활용하여 제로 코스트 포매팅을 달성합니다.

둘째, $crate를 사용하여 매크로 호출 위치와 무관하게 동작합니다. 셋째, 자동으로 락을 획득하고 해제하여 사용자가 동시성을 신경 쓰지 않아도 됩니다.

이러한 특징들이 있어야 편리하고 안전한 출력 시스템이 완성됩니다.

코드 예제

#[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 test_println() {
    println!("Hello, OS World!");
    println!("Number: {}, Hex: {:#x}", 42, 255);
    print!("No newline");
}

설명

이것이 하는 일: 복잡한 락 관리와 포매팅 로직을 숨기고, 사용자에게 println!("Hello")처럼 간단한 인터페이스를 제공합니다. 첫 번째로, macro_rules!

print는 print! 매크로를 정의합니다.

$($arg:tt)는 "0개 이상의 토큰 트리를 받는다"는 의미로, 가변 인자를 표현합니다. 매크로 본문에서 format_args!($($arg))는 받은 인자들을 fmt::Arguments 타입으로 변환합니다.

$crate::vga_buffer::_print는 절대 경로를 사용하여 매크로가 어디서 호출되든 올바른 함수를 찾을 수 있게 합니다. 그 다음으로, println!

매크로는 두 가지 패턴을 처리합니다. 첫 번째 패턴 ()는 인자 없이 println!()을 호출한 경우 빈 줄을 출력합니다.

두 번째 패턴 ($($arg:tt)*)는 인자가 있는 경우 format_args!로 포매팅하고 끝에 "\n"을 추가합니다. 이렇게 print!

매크로를 재사용하여 코드 중복을 피합니다. 마지막으로, _print 함수는 실제 출력을 수행하는 헬퍼 함수입니다.

#[doc(hidden)]으로 문서에서 숨기고, fmt::Arguments를 받아 WRITER.lock()으로 뮤텍스를 획득한 후 write_fmt()를 호출합니다. write_fmt()는 fmt::Write 트레이트가 자동으로 제공하는 메서드로, Arguments를 write_str()을 사용하여 출력합니다.

함수가 반환되면 MutexGuard가 drop되어 자동으로 락이 해제됩니다. 여러분이 이 코드를 사용하면 OS 코드를 일반 Rust 코드처럼 작성할 수 있습니다.

실무에서의 이점은 첫째, 보일러플레이트 코드가 사라져 가독성이 크게 향상되고, 둘째, 팀원들이 새로운 문법을 배울 필요 없이 익숙한 println!을 사용할 수 있으며, 셋째, 매크로가 자동으로 락을 관리하여 데이터 레이스나 데드락 같은 동시성 버그를 예방할 수 있습니다.

실전 팁

💡 #[macro_export]는 매크로를 crate 루트에 export합니다. 따라서 다른 모듈에서는 crate::println!으로 접근할 수 있습니다. 매크로는 일반적인 pub과 다른 가시성 규칙을 따릅니다.

💡 _print 함수를 public으로 만들되 #[doc(hidden)]을 사용하는 이유는 매크로가 접근할 수 있어야 하지만 사용자에게는 노출하고 싶지 않기 때문입니다. 언더스코어 접두사로도 내부 함수임을 표시합니다.

💡 format_args!는 힙 할당 없이 스택에서 포매팅을 수행합니다. 이는 OS 개발에서 매우 중요한데, 초기 부팅 단계에서는 힙 할당자가 아직 초기화되지 않았을 수 있기 때문입니다.

💡 unwrap()를 사용하는 것이 걱정된다면 _print 함수를 수정하여 에러를 무시하거나 로그로 남기세요. 출력 실패가 패닉을 일으키면 디버깅이 매우 어려워집니다.

💡 매크로 디버깅이 어렵다면 cargo expand 명령을 사용하세요. 매크로가 어떻게 확장되는지 실제 Rust 코드로 볼 수 있어 문제를 찾기 쉬워집니다.


5. 자동 스크롤링 구현하기 - 화면 넘침 처리

시작하며

여러분이 계속 텍스트를 출력하다 보면 화면의 마지막 줄에 도달하게 됩니다. 이때 새로운 텍스트를 어디에 써야 할까요?

그냥 화면 밖으로 사라지게 둘 수는 없습니다. 실제 터미널처럼 기존 내용을 위로 스크롤하고 맨 아래에 새 줄을 추가해야 합니다.

이런 문제는 로그를 많이 출력하거나 부팅 과정을 추적할 때 반드시 발생합니다. 스크롤링이 없으면 처음 25줄의 출력만 볼 수 있고, 그 이후의 중요한 정보는 모두 사라집니다.

디버깅이 불가능해지고, 사용자 경험도 매우 나빠집니다. 바로 이럴 때 필요한 것이 자동 스크롤링 로직입니다.

화면이 가득 차면 모든 줄을 한 줄씩 위로 이동시키고, 마지막 줄을 비워서 새로운 출력을 받을 준비를 합니다.

개요

간단히 말해서, 스크롤링은 화면 버퍼의 내용을 한 줄씩 위로 복사하고 마지막 줄을 지우는 작업입니다. VGA 텍스트 버퍼는 25행 80열의 고정 크기 배열이므로, 첫 번째 줄은 사라지고 두 번째 줄이 첫 번째 줄로 이동하는 식으로 모든 줄을 복사해야 합니다.

Rust에서는 배열 슬라이스의 copy_within()이나 수동 루프를 사용하여 이를 구현할 수 있습니다. 마지막 줄은 공백 문자로 채워 깨끗하게 만듭니다.

전통적으로는 memcpy나 memmove를 사용하여 메모리 블록을 복사했습니다. Rust에서는 타입 안전한 방식으로 동일한 작업을 수행하면서도 버퍼 오버플로우 같은 위험을 방지할 수 있습니다.

핵심 특징은 세 가지입니다. 첫째, Volatile 래퍼를 통해 읽기와 쓰기가 최적화되지 않도록 보장합니다.

둘째, 한 줄씩 복사하여 메모리 안전성을 유지합니다. 셋째, column_position을 0으로 리셋하여 새 줄의 시작 위치를 준비합니다.

이러한 특징들이 있어야 지속적으로 출력할 수 있는 실용적인 시스템이 됩니다.

코드 예제

impl Writer {
    fn new_line(&mut self) {
        for row in 1..BUFFER_HEIGHT {
            for col in 0..BUFFER_WIDTH {
                // 다음 줄의 문자를 현재 줄로 복사
                let character = self.buffer.chars[row][col].read();
                self.buffer.chars[row - 1][col].write(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].write(blank);
        }
    }
}

설명

이것이 하는 일: 화면이 가득 찼을 때 오래된 내용을 버리고 새로운 출력을 위한 공간을 만듭니다. 첫 번째로, new_line() 메서드는 이중 루프로 모든 행과 열을 순회합니다.

바깥 루프는 row를 1부터 BUFFER_HEIGHT까지 반복하여 두 번째 줄부터 마지막 줄까지 처리합니다. 안쪽 루프는 col을 0부터 BUFFER_WIDTH까지 반복하여 각 문자를 복사합니다.

self.buffer.chars[row][col].read()는 Volatile을 통해 해당 위치의 문자를 읽고, write()로 한 줄 위인 [row - 1][col]에 씁니다. 그 다음으로, 모든 줄의 복사가 완료되면 첫 번째 줄은 사라지고 각 줄이 위로 한 칸씩 이동한 상태가 됩니다.

하지만 마지막 줄은 이전 마지막에서 두 번째 줄의 내용을 그대로 가지고 있으므로, clear_row(BUFFER_HEIGHT - 1)을 호출하여 마지막 줄을 지웁니다. clear_row()는 공백 ScreenChar를 생성하고 해당 행의 모든 열에 write()로 씁니다.

마지막으로, self.column_position = 0으로 설정하여 커서를 줄의 시작 위치로 이동시킵니다. 이제 write_byte()를 호출하면 새로 비워진 마지막 줄에 문자가 출력됩니다.

Volatile의 read()와 write()를 사용하기 때문에 컴파일러가 "읽은 값을 바로 다시 쓴다"며 최적화로 제거하지 않고, 실제로 VGA 버퍼를 통해 화면에 변경사항이 반영됩니다. 여러분이 이 코드를 사용하면 무한히 많은 로그를 출력할 수 있습니다.

실무에서의 이점은 첫째, 부팅 과정이나 긴 작업 중에 계속 진행 상황을 추적할 수 있고, 둘째, 화면 크기 제약 없이 출력할 수 있어 디버깅이 편리하며, 셋째, 실제 터미널과 유사한 동작으로 사용자가 자연스럽게 느낄 수 있습니다.

실전 팁

💡 성능 최적화가 필요하다면 행 단위로 복사하는 것을 고려하세요. 현재는 문자 단위로 read/write를 호출하지만, 메모리 블록 복사를 사용하면 훨씬 빠릅니다. 단, Volatile 의미론을 유지해야 합니다.

💡 BUFFER_HEIGHT가 상수이므로 컴파일러가 루프를 언롤링(unrolling)할 수 있습니다. 최적화 레벨에 따라 성능이 크게 향상될 수 있습니다.

💡 줄바꿈 문자 '\n'을 만나면 write_byte에서 new_line()을 호출합니다. column_position이 BUFFER_WIDTH에 도달했을 때도 자동 줄바꿈을 고려하세요. 현재 구현은 화면 밖으로 나가는 것을 허용할 수 있습니다.

💡 색상 정보도 함께 복사되므로 각 줄의 색상이 보존됩니다. 로그 레벨에 따라 다른 색상을 사용하면 디버깅이 더 쉬워집니다.

💡 스크롤 버퍼를 구현하면 과거 출력을 다시 볼 수 있습니다. VGA 버퍼는 25줄만 표시하지만, 별도의 메모리에 더 많은 줄을 저장하고 위아래 화살표로 스크롤하는 기능을 추가할 수 있습니다.


6. 색상 관리 시스템 - ColorCode와 Color 열거형

시작하며

여러분이 모든 텍스트를 같은 색으로 출력한다면 중요한 에러 메시지와 일반 정보를 구분하기 어렵습니다. 빨간색으로 에러를, 노란색으로 경고를, 초록색으로 성공 메시지를 표시하고 싶지 않나요?

VGA 텍스트 모드는 16가지 전경색과 8가지 배경색을 지원합니다. 이런 문제는 디버깅과 사용자 경험에 직접적인 영향을 미칩니다.

수백 줄의 로그 속에서 에러를 찾으려면 시간이 오래 걸리고, 단조로운 화면은 전문적이지 못해 보입니다. 색상을 효과적으로 사용하면 정보를 빠르게 파악하고 시각적으로 구조화할 수 있습니다.

바로 이럴 때 필요한 것이 Color 열거형과 ColorCode 구조체입니다. VGA 하드웨어의 색상 형식을 타입 안전하게 표현하고, 전경색과 배경색을 조합하여 다양한 스타일을 만들 수 있습니다.

개요

간단히 말해서, VGA 텍스트 모드는 각 문자마다 1바이트의 색상 정보를 저장하며, 이는 4비트 전경색과 4비트 배경색으로 구성됩니다. 하위 4비트(0-3)는 전경색(글자 색)을, 상위 4비트(4-7)는 배경색을 나타냅니다.

Color 열거형으로 16가지 색상을 표현하고, ColorCode 구조체로 전경색과 배경색을 하나의 바이트로 결합합니다. #[repr(u8)]과 #[repr(transparent)]를 사용하여 Rust 타입이 정확히 VGA 하드웨어가 기대하는 메모리 레이아웃과 일치하도록 보장합니다.

전통적으로는 매직 넘버(0x0A, 0x4F 등)를 직접 사용했습니다. 이제는 Color::Red, Color::LightBlue 같은 의미 있는 이름을 사용하여 코드 가독성을 높이고 실수를 방지할 수 있습니다.

핵심 특징은 세 가지입니다. 첫째, 열거형으로 타입 안전성을 제공하여 잘못된 색상 값을 방지합니다.

둘째, new() 메서드로 전경색과 배경색을 조합하는 로직을 캡슐화합니다. 셋째, Copy와 Clone 트레이트를 파생하여 색상 값을 복사하기 쉽게 만듭니다.

이러한 특징들이 있어야 유연하고 안전한 색상 시스템을 구축할 수 있습니다.

코드 예제

#[allow(dead_code)]
#[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))
    }
}

설명

이것이 하는 일: VGA 하드웨어의 색상 포맷을 Rust의 타입 시스템으로 안전하게 표현하고 조작할 수 있게 만듭니다. 첫 번째로, Color 열거형은 #[repr(u8)]로 각 variant가 u8 값으로 표현되도록 지정합니다.

Black = 0부터 White = 15까지 VGA 표준 색상 팔레트를 정확히 매핑합니다. #[derive(Copy, Clone)]으로 색상 값을 가볍게 복사할 수 있게 하고, #[allow(dead_code)]로 모든 색상을 정의했지만 일부만 사용해도 컴파일러 경고가 발생하지 않도록 합니다.

그 다음으로, ColorCode 구조체는 #[repr(transparent)]로 단일 u8 필드와 동일한 메모리 레이아웃을 갖습니다. new() 메서드에서 비트 연산을 수행하는데, (background as u8) << 4는 배경색을 상위 4비트로 이동시키고, (foreground as u8)는 하위 4비트에 유지한 후, 비트 OR 연산자 |로 결합합니다.

예를 들어, new(Color::Yellow, Color::Black)는 0x0E가 됩니다. 마지막으로, ScreenChar 구조체에서 ColorCode를 사용하면 각 문자와 함께 저장됩니다.

Writer의 color_code 필드는 현재 사용 중인 색상을 유지하고, 새로운 문자를 쓸 때마다 이 색상이 적용됩니다. 색상을 변경하려면 writer.color_code = ColorCode::new(Color::Red, Color::Black)처럼 간단히 할당하면, 이후 출력되는 모든 텍스트가 빨간색이 됩니다.

여러분이 이 코드를 사용하면 다채로운 출력 시스템을 만들 수 있습니다. 실무에서의 이점은 첫째, 로그 레벨(INFO, WARN, ERROR)마다 다른 색상을 사용하여 가독성을 높일 수 있고, 둘째, 타입 시스템이 잘못된 색상 값(예: 16 이상)을 컴파일 타임에 방지하며, 셋째, ColorCode::new()로 색상 조합 로직을 추상화하여 비트 연산 실수를 줄일 수 있습니다.

실전 팁

💡 배경색은 3비트(0-7)만 사용하고 최상위 비트는 깜빡임(blink) 속성입니다. 일부 VGA 모드에서는 배경색 8-15가 깜빡이는 전경색으로 해석됩니다. 이를 제어하려면 VGA 레지스터를 직접 설정해야 합니다.

💡 Color::LightGray와 Color::White를 구분하세요. LightGray는 0x07, White는 0x0F로 다릅니다. 가독성을 위해 보통 LightGray나 White를 전경색으로 사용합니다.

💡 PartialEq와 Eq를 파생하면 색상을 비교할 수 있습니다. 테스트에서 특정 색상이 사용되었는지 확인하거나, 색상 변경을 추적할 때 유용합니다.

💡 매크로를 확장하여 색상을 지정할 수 있습니다. 예: println_colored!(Color::Red, "Error: {}", msg) 같은 커스텀 매크로를 만들면 더 편리합니다.

💡 색맹 사용자를 고려하세요. 색상만으로 정보를 구분하지 말고, 기호나 텍스트 레이블을 함께 사용하는 것이 접근성 측면에서 좋습니다.


7. 패닉 핸들러에서 Writer 사용하기 - 크래시 정보 출력

시작하며

여러분의 OS가 패닉에 빠졌을 때, 아무런 정보 없이 멈춰버린다면 디버깅이 불가능합니다. "뭔가 잘못되었다"는 것은 알지만 어디서 무엇이 잘못되었는지 전혀 알 수 없습니다.

스택 트레이스나 에러 메시지라도 있어야 문제를 추적할 수 있습니다. 이런 문제는 OS 개발의 가장 고통스러운 부분입니다.

일반 프로그램은 패닉 시 표준 에러 스트림에 정보를 출력하지만, OS는 그런 인프라가 없습니다. println!을 사용할 수 없다면 패닉 메시지를 어떻게 볼까요?

특히 인터럽트가 비활성화된 상태나 초기 부팅 단계에서는 더욱 어렵습니다. 바로 이럴 때 필요한 것이 커스텀 패닉 핸들러입니다.

#[panic_handler] 속성으로 패닉 발생 시 호출될 함수를 정의하고, 우리가 만든 WRITER를 사용하여 에러 정보를 화면에 출력할 수 있습니다.

개요

간단히 말해서, 패닉 핸들러는 프로그램에 회복 불가능한 에러가 발생했을 때 Rust 런타임이 호출하는 함수입니다. no_std 환경에서는 표준 라이브러리의 패닉 핸들러를 사용할 수 없으므로 직접 구현해야 합니다.

#[panic_handler] 속성을 가진 함수는 &PanicInfo를 인자로 받으며, 여기에는 패닉 메시지와 발생 위치 정보가 포함됩니다. 이 함수는 절대 반환하면 안 되므로 반환 타입이 !입니다.

전통적인 C 프로그램에서는 assert 실패 시 abort()를 호출하거나 무한 루프에 빠집니다. Rust에서는 panic_handler를 통해 훨씬 더 많은 정보를 추출하고 사용자에게 제공할 수 있습니다.

핵심 특징은 세 가지입니다. 첫째, PanicInfo에서 메시지와 파일명, 줄 번호를 추출할 수 있습니다.

둘째, WRITER를 사용하여 화면에 포맷팅된 에러를 출력합니다. 셋째, loop {} 무한 루프로 시스템을 정지시켜 추가 손상을 방지합니다.

이러한 특징들이 있어야 패닉 상황에서도 디버깅 정보를 얻을 수 있습니다.

코드 예제

use core::panic::PanicInfo;

#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
    println!("\n========== KERNEL PANIC ==========");

    if let Some(location) = info.location() {
        println!(
            "Panic occurred at {}:{}:{}",
            location.file(),
            location.line(),
            location.column()
        );
    } else {
        println!("Panic occurred at unknown location");
    }

    if let Some(message) = info.message() {
        println!("Message: {}", message);
    }

    println!("==================================\n");

    // 시스템 정지
    loop {
        x86_64::instructions::hlt();
    }
}

설명

이것이 하는 일: 회복 불가능한 에러 발생 시 가능한 한 많은 디버깅 정보를 사용자에게 보여주고 시스템을 안전한 상태로 만듭니다. 첫 번째로, panic 함수는 PanicInfo 참조를 받습니다.

println! 매크로를 사용하여 눈에 띄는 "KERNEL PANIC" 헤더를 출력하여 일반 로그와 구분합니다.

info.location()은 Option<&Location>을 반환하므로 if let으로 패턴 매칭하여 패닉이 발생한 파일명, 줄 번호, 열 번호를 추출합니다. 이 정보는 소스 코드에서 정확한 위치를 찾는 데 필수적입니다.

그 다음으로, info.message()로 패닉 메시지를 가져옵니다. 예를 들어, panic!("Out of memory")를 호출했다면 "Out of memory"가 메시지로 포함됩니다.

unwrap()이 실패한 경우에는 "called Option::unwrap() on a None value" 같은 기본 메시지가 제공됩니다. 모든 정보를 출력한 후 구분선을 그어 패닉 정보가 끝났음을 명확히 합니다.

마지막으로, 함수는 절대 반환하면 안 되므로 loop {} 무한 루프에 들어갑니다. 단순한 빈 루프 대신 x86_64::instructions::hlt()를 호출하여 CPU를 대기 상태로 만듭니다.

hlt 명령은 다음 인터럽트가 발생할 때까지 CPU를 정지시켜 전력을 절약하고 무의미한 CPU 사이클 낭비를 방지합니다. 인터럽트가 발생하면 깨어나지만 다시 loop의 처음으로 돌아가 hlt를 실행합니다.

여러분이 이 코드를 사용하면 패닉 상황을 효과적으로 디버깅할 수 있습니다. 실무에서의 이점은 첫째, 정확한 소스 위치를 알 수 있어 문제를 빠르게 찾을 수 있고, 둘째, 패닉 메시지로 무엇이 잘못되었는지 이해할 수 있으며, 셋째, hlt로 CPU를 정지시켜 불필요한 전력 소비를 막고 시스템을 안정적인 상태로 유지합니다.

실전 팁

💡 패닉 핸들러에서 다시 패닉이 발생하면 무한 재귀에 빠질 수 있습니다. println!이 Mutex를 사용하므로, 이미 락을 보유한 상태에서 패닉이 발생하면 데드락이 될 수 있습니다. 더 안전한 방법은 락 없이 직접 VGA 버퍼에 쓰는 것입니다.

💡 스택 트레이스를 추가하면 더 유용합니다. x86_64 crate의 registers::read_rip()로 명령 포인터를 읽거나, 베이스 포인터를 따라가며 호출 스택을 출력할 수 있습니다.

💡 패닉 메시지를 시리얼 포트로도 출력하세요. QEMU의 -serial 옵션을 사용하면 호스트 시스템에서 패닉 정보를 로그 파일로 저장하거나 자동화된 테스트에 활용할 수 있습니다.

💡 Double Fault가 발생하면 패닉 핸들러가 호출되지 않을 수 있습니다. 별도의 Double Fault 핸들러를 IDT에 등록하여 더 견고한 에러 처리를 구현하세요.

💡 디버그 빌드와 릴리스 빌드에서 다른 동작을 고려하세요. 릴리스에서는 디버그 정보가 제거될 수 있으므로, 심볼 정보나 디버그 심볼을 유지하는 빌드 설정을 사용하세요.


8. 인터럽트 안전 Writer - 스핀락의 한계 극복

시작하며

여러분이 인터럽트 핸들러에서 println!을 사용하려고 할 때, 데드락이 발생할 수 있습니다. 예를 들어, 메인 코드가 WRITER 락을 보유한 채로 println!을 실행하는 중에 타이머 인터럽트가 발생하여 인터럽트 핸들러도 println!을 호출하면 어떻게 될까요?

인터럽트 핸들러는 이미 잠긴 락을 기다리며 무한정 스핀하게 됩니다. 이런 문제는 실시간 OS나 인터럽트가 빈번한 시스템에서 매우 심각합니다.

데드락이 발생하면 시스템 전체가 멈추고, 디버깅조차 어려워집니다. 특히 키보드 인터럽트나 타이머 인터럽트에서 상태를 출력하려는 것은 흔한 요구사항인데, 일반적인 Mutex로는 안전하게 처리할 수 없습니다.

바로 이럴 때 필요한 것이 인터럽트 비활성화 기능을 갖춘 락입니다. 락을 획득할 때 자동으로 인터럽트를 비활성화하고, 해제할 때 복원하면 인터럽트로 인한 재진입 문제를 방지할 수 있습니다.

개요

간단히 말해서, 인터럽트 안전 락은 락을 보유한 동안 인터럽트가 발생하지 않도록 보장하여 재진입 문제를 해결합니다. x86_64 아키텍처에서는 cli 명령으로 인터럽트를 비활성화하고 sti 명령으로 활성화할 수 있습니다.

락을 획득하기 전에 현재 인터럽트 상태를 저장하고 cli를 실행한 후, 락을 해제할 때 저장된 상태를 복원하면 됩니다. spin 크레이트는 이러한 기능을 제공하지 않으므로, lock_irqsave 같은 커스텀 래퍼나 interrupt::without_interrupts 같은 헬퍼 함수를 사용해야 합니다.

전통적인 OS에서는 spinlock_irqsave와 spinlock_irqrestore 함수를 사용하여 이를 구현했습니다. Rust에서는 RAII 패턴을 활용하여 더 안전하게 구현할 수 있습니다.

핵심 특징은 세 가지입니다. 첫째, 인터럽트 상태를 자동으로 저장하고 복원하여 실수를 방지합니다.

둘째, 중첩된 락 획득도 올바르게 처리합니다. 셋째, 인터럽트 비활성화 기간을 최소화하여 시스템 응답성을 유지합니다.

이러한 특징들이 있어야 인터럽트와 메인 코드가 안전하게 공유 자원에 접근할 수 있습니다.

코드 예제

use x86_64::instructions::interrupts;

// 인터럽트 비활성화 상태에서 락 획득
pub fn print_something_safe() {
    interrupts::without_interrupts(|| {
        WRITER.lock().write_str("Interrupt-safe output\n").unwrap();
    });
}

// 또는 print 매크로를 수정
#[doc(hidden)]
pub fn _print(args: fmt::Arguments) {
    use core::fmt::Write;
    // 인터럽트를 비활성화한 채로 락 획득
    interrupts::without_interrupts(|| {
        WRITER.lock().write_fmt(args).unwrap();
    });
}

// 인터럽트 핸들러에서도 안전하게 사용 가능
pub extern "x86-interrupt" fn timer_interrupt_handler(
    _stack_frame: InterruptStackFrame
) {
    println!("Timer tick!");  // 데드락 없음
}

설명

이것이 하는 일: 공유 자원에 접근하는 동안 인터럽트가 실행되지 않도록 보장하여 재진입으로 인한 데드락을 완전히 제거합니다. 첫 번째로, interrupts::without_interrupts는 클로저를 인자로 받는 함수입니다.

함수 내부에서 먼저 현재 인터럽트 상태(RFLAGS 레지스터의 IF 비트)를 읽어 저장합니다. 그 다음 cli 명령으로 인터럽트를 비활성화하고, 전달된 클로저를 실행합니다.

클로저 안에서 WRITER.lock()을 호출하면 인터럽트가 비활성화된 상태이므로 아무도 이 락을 동시에 획득하려 시도하지 않습니다. 그 다음으로, 클로저 실행이 완료되면(정상 반환이든 panic이든) without_interrupts는 저장해둔 인터럽트 상태를 복원합니다.

만약 인터럽트가 원래 비활성화되어 있었다면 여전히 비활성화 상태로 유지하고, 활성화되어 있었다면 sti 명령으로 다시 활성화합니다. 이 RAII 패턴 덕분에 개발자가 수동으로 인터럽트 상태를 관리할 필요가 없습니다.

마지막으로, _print 함수를 수정하면 모든 println! 호출이 자동으로 인터럽트 안전해집니다.

timer_interrupt_handler에서 println!을 호출해도 문제없습니다. 왜냐하면 인터럽트 핸들러가 실행되는 시점에는 메인 코드가 락을 보유하고 있지 않거나, 보유하고 있다면 인터럽트가 비활성화되어 핸들러가 실행될 수 없기 때문입니다.

이렇게 재진입 가능성이 완전히 차단됩니다. 여러분이 이 코드를 사용하면 인터럽트 컨텍스트에서도 안전하게 출력할 수 있습니다.

실무에서의 이점은 첫째, 키보드나 타이머 인터럽트 핸들러에서 디버깅 메시지를 자유롭게 출력할 수 있고, 둘째, 데드락으로 인한 시스템 정지를 방지하여 신뢰성을 높이며, 셋째, 코드 작성자가 매번 인터럽트 상태를 신경 쓰지 않아도 되어 생산성이 향상됩니다.

실전 팁

💡 인터럽트 비활성화는 비용이 있습니다. 락을 보유한 시간을 최소화하세요. 복잡한 연산은 락 밖에서 수행하고, 락 안에서는 VGA 버퍼 쓰기만 수행하도록 최적화하세요.

💡 중첩된 without_interrupts 호출도 안전합니다. 내부 구현이 카운터를 사용하거나 원래 상태를 저장하므로, 이미 비활성화된 상태에서 다시 호출해도 문제없습니다.

💡 인터럽트 비활성화는 단일 CPU에서만 동시성을 방지합니다. 멀티코어 시스템에서는 여전히 Mutex가 필요하며, 인터럽트 비활성화와 스핀락을 함께 사용해야 합니다.

💡 긴 임계 영역은 시스템 응답성을 저하시킵니다. 인터럽트가 비활성화된 동안에는 타이머나 키보드 입력을 놓칠 수 있으므로, 가능한 한 짧게 유지하세요.

💡 디버깅 시 인터럽트 상태를 확인하세요. RFLAGS 레지스터를 읽어 IF 비트를 검사하면 현재 인터럽트가 활성화되어 있는지 알 수 있습니다. 예상치 못한 동작의 원인을 찾을 때 유용합니다.


9. 테스트 프레임워크 통합 - Writer 동작 검증

시작하며

여러분이 VGA Writer를 구현했지만, 정말로 올바르게 작동하는지 어떻게 확인할까요? 수동으로 부팅해서 눈으로 확인하는 것은 시간이 오래 걸리고, 회귀 버그를 놓치기 쉽습니다.

특히 리팩토링이나 새 기능 추가 후에 기존 기능이 깨지지 않았는지 보장하려면 자동화된 테스트가 필수입니다. 이런 문제는 OS 개발의 품질 관리에서 매우 중요합니다.

일반적인 프로그램은 cargo test로 쉽게 테스트할 수 있지만, no_std 환경에서는 표준 테스트 프레임워크를 사용할 수 없습니다. 화면 출력이 정확한지, 스크롤링이 올바르게 동작하는지, 색상이 제대로 적용되는지를 자동으로 검증할 방법이 필요합니다.

바로 이럴 때 필요한 것이 커스텀 테스트 프레임워크입니다. Cargo의 custom_test_frameworks 기능과 bootimage의 test-args를 활용하여 QEMU에서 자동으로 테스트를 실행하고 결과를 검증할 수 있습니다.

개요

간단히 말해서, 커스텀 테스트 프레임워크는 no_std 환경에서 단위 테스트와 통합 테스트를 실행할 수 있게 해주는 인프라입니다. Rust의 #![feature(custom_test_frameworks)]를 활성화하면 #[test_case] 속성으로 테스트 함수를 표시하고, 컴파일러가 모든 테스트를 수집하여 test_runner 함수에 전달합니다.

test_runner는 각 테스트를 실행하고 결과를 시리얼 포트나 특수한 I/O 포트(예: 0xf4)로 출력하여 QEMU가 성공/실패를 판단할 수 있게 합니다. 전통적인 방법은 assert 매크로를 사용하고 실패 시 패닉하는 것이지만, 모든 테스트를 실행하려면 각 테스트가 독립적으로 실행되어야 합니다.

커스텀 프레임워크는 이를 자동화하고 CI/CD 파이프라인에 통합할 수 있습니다. 핵심 특징은 세 가지입니다.

첫째, VGA 버퍼를 직접 읽어 출력 내용을 검증합니다. 둘째, QEMU의 isa-debug-exit 장치로 종료 코드를 전달하여 성공/실패를 구분합니다.

셋째, 각 테스트를 격리하여 한 테스트의 실패가 다른 테스트에 영향을 주지 않게 합니다. 이러한 특징들이 있어야 신뢰할 수 있는 테스트 시스템을 구축할 수 있습니다.

코드 예제

#[cfg(test)]
use crate::{println, serial_print, serial_println};

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

#[test_case]
fn test_println_many() {
    serial_print!("test_println_many... ");
    for _ in 0..200 {
        println!("test_println_many output");
    }
    serial_println!("[ok]");
}

#[test_case]
fn test_println_output() {
    serial_print!("test_println_output... ");
    let s = "Test string that fits on a single line";
    println!("{}", s);

    // VGA 버퍼를 직접 읽어 검증
    for (i, c) in s.chars().enumerate() {
        let screen_char = WRITER.lock().buffer.chars[BUFFER_HEIGHT - 2][i].read();
        assert_eq!(char::from(screen_char.ascii_character), c);
    }
    serial_println!("[ok]");
}

설명

이것이 하는 일: 코드 변경 후에도 Writer가 올바르게 동작하는지 자동으로 확인하여 품질을 보장하고 회귀 버그를 조기에 발견합니다. 첫 번째로, test_println_simple은 가장 기본적인 테스트로 println!이 패닉하지 않고 실행되는지만 확인합니다.

serial_print!는 시리얼 포트로 출력하여 QEMU 콘솔에서 진행 상황을 볼 수 있게 하고, serial_println!("[ok]")로 테스트 성공을 표시합니다. 화면 출력(println!)과 시리얼 출력(serial_println!)을 분리하는 이유는 화면 출력을 검증 대상으로 사용하기 위함입니다.

그 다음으로, test_println_many는 대량 출력과 스크롤링을 테스트합니다. 200번 반복하면 화면 높이(25줄)를 훨씬 초과하므로 new_line()이 여러 번 호출됩니다.

이 과정에서 패닉이나 무한 루프가 발생하지 않으면 스크롤링이 정상 작동한다고 판단할 수 있습니다. 실제 출력 내용의 정확성은 다음 테스트에서 검증합니다.

마지막으로, test_println_output은 가장 엄격한 테스트로 실제로 VGA 버퍼에 올바른 문자가 쓰였는지 확인합니다. 문자열 s를 println!으로 출력하고, WRITER.lock().buffer.chars[BUFFER_HEIGHT - 2]로 마지막에서 두 번째 줄을 읽습니다(println!은 줄바꿈을 추가하므로 실제 텍스트는 한 줄 위에 있습니다).

각 위치의 문자를 read()로 가져와 원본 문자열과 비교하여 정확히 일치하는지 assert_eq!로 검증합니다. 여러분이 이 코드를 사용하면 Writer의 신뢰성을 크게 높일 수 있습니다.

실무에서의 이점은 첫째, 코드 변경 시 자동으로 테스트가 실행되어 빠른 피드백을 얻을 수 있고, 둘째, CI/CD 파이프라인에 통합하여 모든 커밋이 검증되며, 셋째, 새로운 기능을 추가할 때 기존 기능이 깨지지 않았다는 확신을 가질 수 있습니다.

실전 팁

💡 시리얼 포트를 구현하세요. VGA 출력은 자동화하기 어렵지만 시리얼 출력은 파일로 리다이렉트하여 쉽게 파싱할 수 있습니다. uart_16550 크레이트를 사용하면 간단히 구현할 수 있습니다.

💡 QEMU의 -device isa-debug-exit와 -serial 옵션을 함께 사용하세요. 테스트 성공 시 종료 코드 0, 실패 시 1을 반환하도록 설정하면 cargo test가 자동으로 결과를 인식합니다.

💡 각 테스트를 별도의 바이너리로 컴파일하는 방법도 고려하세요. tests/ 디렉토리에 통합 테스트를 배치하면 각 파일이 독립적으로 실행되어 격리가 보장됩니다.

💡 색상 테스트도 추가하세요. 특정 색상으로 출력한 후 VGA 버퍼에서 ColorCode를 읽어 올바른 색상이 적용되었는지 확인할 수 있습니다.

💡 타이밍 테스트에 주의하세요. QEMU는 실제 하드웨어보다 느릴 수 있으므로, 타임아웃 값을 넉넉하게 설정하거나 타이밍에 의존하지 않는 테스트를 작성하세요.


#Rust#OS개발#VGA버퍼#Mutex#전역변수#시스템프로그래밍

댓글 (0)

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