🤖

본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.

⚠️

본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.

이미지 로딩 중...

Rust로 만드는 나만의 OS 15:println! 매크로 만들기 - 슬라이드 1/9
A

AI Generated

2026. 3. 31. · 0 Views

Rust로 만드는 나만의 OS 15:println! 매크로 만들기

OS 커널에서 println! 매크로를 직접 구현하는 방법을 다룹니다. VGA 버퍼 출력부터 fmt::Write 트레이트 구현, 매크로 규칙 정의까지 OS 개발자의 필수 지식을 전달합니다.


목차

  1. println! 매크로란 무엇인가
  2. fmt::Write 트레이트 이해하기
  3. Writer 구조체 설계하기
  4. 문자 출력과 줄바꿈 구현
  5. 전역 Writer와 lazy_static
  6. print!와 println! 매크로 정의하기
  7. 컬러 출력과 커스텀 서식 구현
  8. 전체 파이프라인과 패닉 핸들러 연동

1. println! 매크로란 무엇인가

김개발 씨는 "Rust로 만드는 나만의 OS" 프로젝트에서 VGA 텍스트 버퍼와 FAT32 파일시스템까지 구현한 뒤, 커널에서 디버그 메시지를 출력하고 싶어졌습니다. 하지만 no_std 환경에서는 표준 println!을 사용할 수 없습니다.

"커널에서 어떻게 화면에 글자를 출력하지?" 김개발 씨의 고민이 시작되었습니다.

println! 매크로는 Rust에서 화면에 텍스트를 출력하는 가장 기본적인 수단입니다.

하지만 OS 커널처럼 표준 라이브러리를 사용할 수 없는 no_std 환경에서는 직접 구현해야 합니다. 마치 자동차 엔진을 직접 만드는 것과 같아서, 동작 원리를 이해하면 출력 시스템 전체를 제어할 수 있습니다.

다음 코드를 살펴봅시다.

// 일반적인 Rust 환경에서의 println!
println!("Hello, World!");
println!("x = {}, y = {}", 10, 20);

// no_std 환경에서는 이 매크로를 직접 만들어야 합니다
// println!("커널 부팅 완료!");  <-- 컴파일 에러!
// error: `println!` macro is not available in `no_std`

김개발 씨는 입사 3개월 차 주니어 개발자로, 요즘 밀린 일정 때문에 야근이 잦습니다. 오늘도 퇴근 후 개인 프로젝트인 OS 개발에 매진하던 중, 커널 코드에서 디버그 메시지를 출력할 방법이 필요해졌습니다.

선배 개발자 박시니어 씨가 옆에서 모니터를 보며 말했습니다. "println!이 안 되는 거지?

당연해. no_std 환경이잖아.

표준 라이브러리가 없으니까." 그렇다면 println! 매크로란 정확히 무엇일까요?

쉽게 비유하자면, println!은 마치 택배 배송 시스템의 최종 배달부와 같습니다. 화면이라는 목적지에 데이터를 안전하게 전달하는 역할을 합니다.

포장(포맷팅)부터 운송(버퍼 기록)까지 전체 과정을 책임집니다. println!이 없던 시절에는 어땠을까요?

개발자들은 시스템 콜을 직접 호출하거나, 하드웨어 레지스터를 수동으로 조작해야 했습니다. 코드가 길어지고, 실수하기도 쉬웠습니다.

더 큰 문제는 포맷 문자열 파싱이었습니다. "{}" 같은 플레이스홀더를 직접 처리하려면 복잡한 문자열 조작이 필요했죠.

바로 이런 문제를 해결하기 위해 Rust의 포맷팅 매크로 시스템이 등장했습니다. println!을 사용하면 타입 안전한 포맷팅이 가능해집니다.

또한 컴파일 타임 검사도 얻을 수 있습니다. 무엇보다 일관된 인터페이스라는 큰 이점이 있습니다.

print!, eprintln!, write! 모두 같은 포맷팅 시스템을 공유하니까요.

OS 커널에서는 이 시스템을 직접 재구현해야 합니다. 다행히 Rust는 포맷팅 인프라를 core 라이브러리에서 제공하므로, fmt::Write 트레이트만 구현하면 됩니다.

표준 라이브러리 없이도 강력한 포맷팅을 사용할 수 있는 것이 Rust의 장점입니다.

실전 팁

💡 - println!은 매크로입니다. 함수가 아니므로 컴파일 타임에 코드가 변환됩니다.

  • no_std 환경에서는 core::fmt 모듈을 사용할 수 있습니다. 포맷팅 인프라는 표준 라이브러리에 의존하지 않습니다.

2. fmt::Write 트레이트 이해하기

김개발 씨는 박시니어 씨의 조언을 듣고 Rust의 포맷팅 시스템을 파헤치기 시작했습니다. "core::fmt::Write 트레이트를 구현하면 된다고요?" 김개발 씨는 공식 문서를 열어보았지만, 문서만으로는 감이 잘 오지 않았습니다.

직접 구현해보면서 이해하는 것이 최선이라고 판단했습니다.

fmt::Write 트레이트는 Rust의 포맷팅 시스템과 사용자 정의 출력 대상을 연결하는 다리입니다. 이 트레이트를 구현하면 write!, format!

같은 모든 포맷팅 매크로를 자신의 출력 장치에 사용할 수 있습니다. 마치 만능 어댑터처럼, 어떤 출력 대상이든 동일한 인터페이스로 제어할 수 있습니다.

다음 코드를 살펴봅시다.

use core::fmt;

// fmt::Write 트레이트 구현
struct ScreenWriter;

impl fmt::Write for ScreenWriter {
    fn write_str(&mut self, s: &str) -> fmt::Result {
        // 각 문자를 VGA 버퍼에 직접 기록
        for byte in s.bytes() {
            // VGA 버퍼에 문자 쓰기 (구현 생략)
            vga_buffer_write_char(byte);
        }
        Ok(())
    }
}

// 이제 포맷팅 매크로를 사용할 수 있습니다!
write!(ScreenWriter, "값: {}", 42).unwrap();

박시니어 씨가 화이트보드에 다이어그램을 그리기 시작했습니다. "핵심은 write_str 메서드 하나야.

이걸 구현하기만 하면 Rust의 포맷팅 엔진이 알아서 나머지를 처리해줘." 김개발 씨는 눈을 크게 떴습니다. "단 하나의 메서드로 끝나요?" 그렇다면 fmt::Write 트레이트란 정확히 무엇일까요?

쉽게 비유하자면, fmt::Write 트레이트는 마치 벽에 걸어둔 인터폰과 같습니다. 인터폰(트레이트)은 단 하나의 버튼(write_str)만 있습니다.

하지만 이 버튼 하나로 건물 내 모든 방(포맷팅 매크로)과 통신할 수 있습니다. fmt::Write가 없던 시절, 즉 직접 출력을 구현하던 시절에는 어땠을까요?

개발자들은 정수를 문자열로 변환하고, 각 자릿수를 개별적으로 화면에 출력해야 했습니다. 부동소수점은 더 복잡했습니다.

16진수, 2진수 변환까지 직접 구현하려면 코드가 수백 줄로 늘어났습니다. "아니, 이미 누군가 만들어놓은 걸 왜 다시 만들어야 하나요?" 바로 이런 문제를 해결하기 위해 Rust 팀은 core::fmt라는 포맷팅 프레임워크를 설계했습니다.

fmt::Write를 구현하면 Display 트레이트를 가진 모든 타입을 자동으로 포맷팅할 수 있습니다. 또한 컴파일 타임에 포맷 문자열 검증도 자동으로 이루어집니다.

무엇보다 제네릭 출력이라는 큰 이점이 있습니다. VGA 버퍼, 시리얼 포트, 네트워크, 어떤 출력 대상이든 같은 코드로 작동하죠.

위의 코드를 한 줄씩 살펴보겠습니다. 먼저 impl fmt::Write for ScreenWriter를 보면 트레이트 구현이 시작됨을 알 수 있습니다.

이 부분이 핵심입니다. 다음으로 fn write_str(&mut self, s: &str)에서는 문자열 슬라이스를 받아 처리합니다.

마지막으로 Ok(())를 반환하면 성공을 알립니다. 실제 현업에서는 어떻게 활용할까요?

예를 들어 임베디드 디바이스 펌웨어를 개발한다고 가정해봅시다. LCD 화면에 디버그 정보를 출력할 때 fmt::Write를 구현하면, 그대로 write!

매크로를 사용할 수 있습니다. 마치 일반 Rust 프로그램처럼 말이죠.

많은 임베디드 Rust 프로젝트에서 이 패턴을 적극적으로 사용하고 있습니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수 중 하나는 std::io::Write와 core::fmt::Write를 혼동하는 것입니다. std 버전은 byte 기반이고, core 버전은 str 기반입니다.

따라서 용도에 맞는 트레이트를 선택해야 합니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.

박시니어 씨의 설명을 들은 김개발 씨는 고개를 끄덕였습니다. "아, write_str만 구현하면 되는 거군요!" fmt::Write 트레이트를 제대로 이해하면 OS 커널뿐만 아니라 임베디드 시스템 전반에서 강력한 출력 도구를 얻을 수 있습니다.

여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.

실전 팁

💡 - std::io::Write(byte 기반)과 core::fmt::Write(str 기반)를 구분하세요. OS 커널에서는 core::fmt::Write를 사용합니다.

  • write_str에서는 반드시 Ok(())를 반환해야 합니다. Err를 반환하면 포맷팅이 중단됩니다.
  • 이 카드뉴스는 "Rust로 만드는 나만의 OS" 코스의 15/59편입니다

3. Writer 구조체 설계하기

이전 카드에서 fmt::Write 트레이트의 개념을 이해한 김개발 씨는 이제 실제로 동작하는 Writer 구조체를 설계하려 합니다. "화면에 글자를 쓰려면 현재 커서 위치도 알아야 하고, 색상도 관리해야 하고..." 요구사항이 점점 늘어납니다.

박시니어 씨는 "상태를 가진 구조체로 만들어야 해"라고 조언했습니다.

Writer 구조체는 화면 출력에 필요한 모든 상태를 관리하는 중앙 제어실입니다. 커서 위치, 현재 색상, 열 위치 등 화면 출력에 필요한 정보를 하나로 묶어 관리합니다.

마치 비행기 조종석처럼, 하나의 구조체에서 출력의 모든 측면을 제어할 수 있습니다.

다음 코드를 살펴봅시다.

#[derive(Debug)]
struct Writer {
    column_position: usize,  // 현재 커서 열 위치
    color_code: ColorCode,    // 글자색 + 배경색
    buffer: &'static mut Buffer,  // VGA 버퍼에 대한 가변 참조
}

// VGA 버퍼 구조 (80열 x 25행)
#[repr(transparent)]
struct Buffer {
    chars: [[Volatile<ScreenChar>; BUFFER_WIDTH]; BUFFER_HEIGHT],
}

// 화면에 출력할 한 문자의 구조
#[repr(C)]
#[derive(Clone, Copy)]
struct ScreenChar {
    ascii_character: u8,
    color_code: ColorCode,
}

김개발 씨는 노트에 구조체 설계를 스케치하기 시작했습니다. "커서 위치, 색상, 버퍼...

이 세 가지면 기본적으로 충분하겠죠?" 박시니어 씨가 끼어들었습니다. "잘 생각했어.

하지만 버퍼 참조 수명이 중요해. VGA 버퍼는 프로그램 전체 수명 동안 존재하니까 'static 수명을 사용해야 해." 그렇다면 Writer 구조체를 왜 설계해야 할까요?

쉽게 비유하자면, Writer 구조체는 마치 편집실의 작업 데스크와 같습니다. 편집자(Writer)는 자신의 데스크 위에 모든 도구를 올려놓고 작업합니다.

현재 편집 중인 페이지(커서 위치), 사용할 잉크 색상(색상 코드), 원본 문서(버퍼)가 모두 한곳에 있습니다. Writer가 없던 시절, 즉 전역 변수로만 관리하던 시절에는 어땠을까요?

커서 위치와 색상이 전역 변수로 흩어져 있었습니다. 함수마다 전역 상태를 직접 읽고 수정해야 했죠.

코드를 읽기 어려웠고, 버그를 추적하기도 힘들었습니다. "이 변수, 어디서 변경한 거지?"라며 코드를 뒤지는 일이 다반사였습니다.

바로 이런 문제를 해결하기 위해 상태를 캡슐화한 구조체를 사용합니다. Writer 구조체를 사용하면 관련 상태를 하나로 묶어 관리할 수 있습니다.

또한 메서드를 통해 상태 변경을 통제할 수도 있습니다. 무엇보다 테스트 가능한 구조라는 큰 이점이 있습니다.

의존성을 주입해서 가짜 버퍼로 테스트할 수 있으니까요. 위의 코드를 한 줄씩 살펴보겠습니다.

먼저 column_position: usize는 현재 커서가 어느 열에 있는지 추적합니다. 줄바꿈 시 이 값이 0으로 리셋됩니다.

다음으로 buffer: &'static mut Buffer는 VGA 텍스트 버퍼 메모리 주소 0xb8000에 대한 가변 참조입니다. 마지막으로 Buffer 구조체는 80x25 크기의 2차원 배열로, 실제 화면 픽셀에 대응합니다.

실제 현업에서는 어떻게 활용할까요? 예를 들어 게임 엔진의 디버그 콘솔을 개발한다고 가정해봅시다.

화면에 로그를 출력할 때 Writer 패턴을 사용하면, 색상별로 로그 레벨을 구분할 수 있습니다. 에러는 빨간색, 경고는 노란색으로 출력하는 식으로 말이죠.

많은 게임 엔진과 OS 프로젝트에서 이 구조를 사용합니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수 중 하나는 'static 수명의 의미를 간과하는 것입니다. VGA 버퍼는 하드웨어가 제공하는 고정 메모리 영역이므로, 프로그램이 종료되지 않는 한 유효합니다.

따라서 'static 수명이 안전한 것이죠. 다시 김개발 씨의 이야기로 돌아가 봅시다.

구조체 설계를 마친 김개발 씨는 만족스러운 표정을 지었습니다. "이제 실제로 글자를 쓰는 메서드를 만들면 되겠군요!" Writer 구조체를 잘 설계하면 출력 시스템의 기반을 탄탄하게 구축할 수 있습니다.

다음으로는 실제 문자 출력 메서드를 구현해 보겠습니다.

실전 팁

💡 - VGA 텍스트 버퍼는 주소 0xb8000에 위치한 물리 메모리입니다. 이 주소를 가변 참조로 변환하여 사용합니다.

  • Volatile 타입으로 버퍼를 감싸야 컴파일러가 쓰기 최적화를 하지 않습니다. 하드웨어 레지스터 접근 시 필수입니다.
  • 이 카드뉴스는 "Rust로 만드는 나만의 OS" 코스의 15/59편입니다

4. 문자 출력과 줄바꿈 구현

Writer 구조체를 설계한 김개발 씨는 이제 실제로 화면에 글자를 쓰는 write_byte 메서드를 구현할 차례입니다. "글자 하나 쓰는 게 이렇게 복잡할 줄 몰랐어요." 커서 이동, 줄바꿈, 화면 스크롤까지 고려해야 할 게 한두 개가 아닙니다.

박시니어 씨는 "한 글자 쓰는 데도 규칙이 필요해"라고 말했습니다.

write_byte 메서드는 Writer의 핵심 동작으로, 한 바이트를 VGA 버퍼에 기록하고 커서를 이동시킵니다. 개행 문자 처리, 커서 자동 이동, 화면 스크롤 등 화면 출력의 모든 기본 동작을 담당합니다.

마치 타자기처럼, 글자를 치면 자동으로 다음 위치로 커서가 넘어가는 원리입니다.

다음 코드를 살펴봅시다.

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) {
        // 모든 행을 위로 한 줄씩 이동 (스크롤)
        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.column_position = 0;
    }
}

김개발 씨는 write_byte 메서드의 구현을 마치고 테스트를 실행했습니다. 화면에 "Hello"가 출력되는 것을 확인했습니다.

하지만 긴 문장을 출력하자 화면이 깨지기 시작했습니다. 박시니어 씨가 다가와서 말했습니다.

"아, 스크롤 구현이 빠졌네. 화면이 꽉 차면 위로 밀어 올려야 해." 그렇다면 write_byte의 핵심 동작 원리는 무엇일까요?

쉽게 비유하자면, write_byte는 마치 수동 타자기와 같습니다. 글자를 치면 커서가 오른쪽으로 한 칸 이동합니다.

행 끝에 도달하면 캐리지 리턴과 라인 피드가 일어나죠. 종이가 끝나면 롤러를 돌려 새 줄을 준비합니다.

화면 스크롤이 바로 이 롤러 돌리기에 해당합니다. write_byte가 없던 시절에는 어땠을까요?

개발자들은 VGA 버퍼의 메모리 주소를 직접 계산하고, 오프셋을 수동으로 관리해야 했습니다. 줄바꿈 처리도 개별적으로 구현해야 했죠.

한 줄을 출력하는 데도 수십 줄의 코드가 필요했습니다. "문자 하나 출력하는데 왜 이렇게 복잡하지?"라는 불만이 쏟아졌습니다.

바로 이런 문제를 해결하기 위해 write_byte라는 단일 진입점을 설계했습니다. write_byte를 사용하면 모든 문자 출력이 하나의 함수로 통합됩니다.

또한 커서 관리가 자동화됩니다. 무엇보다 스크롤 처리가 투명하게 이루어진다는 큰 이점이 있습니다.

호출자는 그냥 문자를 넘기기만 하면 됩니다. 위의 코드를 한 줄씩 살펴보겠습니다.

먼저 match byte에서 개행 문자와 일반 문자를 분기 처리합니다. 다음으로 if self.column_position >= BUFFER_WIDTH에서 화면 가장자리에 도달했는지 확인합니다.

그리고 self.buffer.chars[row][col].write(...)에서 Volatile 쓰기를 통해 실제 메모리에 문자를 기록합니다. 마지막으로 new_line 메서드에서는 모든 행을 위로 복사하여 스크롤 효과를 만듭니다.

실제 현업에서는 어떻게 활용할까요? 예를 들어 터미널 에뮬레이터를 개발한다고 가정해봅시다.

write_byte와 유사한 패턴으로 출력을 처리하면, ANSI 이스케이프 시퀀스까지 자연스럽게 확장할 수 있습니다. 색상 변경, 커서 이동, 화면 지우기 같은 기능도 같은 구조에서 추가할 수 있습니다.

리눅스 터미널이 바로 이런 방식으로 동작합니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수 중 하나는 UTF-8 다중 바이트 문자를 무시하는 것입니다. write_byte는 한 바이트만 처리하므로, 한글 같은 다중 바이트 문자는 별도로 처리해야 합니다.

따라서 write_str에서 UTF-8 디코딩을 선행해야 합니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.

스크롤을 추가한 뒤 다시 테스트를 실행하자, 긴 문장도 화면에 깔끔하게 출력되었습니다. "드디어 제대로 되네요!" 문자 출력과 줄바꿈을 제대로 구현하면 화면 출력의 기반을 완성할 수 있습니다.

이제 이 Writer를 전역에서 접근할 수 있도록 만들어 보겠습니다.

실전 팁

💡 - new_line에서 한 줄씩 위로 복사하는 대신, 시작 행 인덱스를 관리하는 방식으로 스크롤을 구현하면 성능이 향상됩니다.

  • Volatile::write와 Volatile::read를 사용해야 컴파일러가 메모리 접근을 최적화하지 않습니다. 하드웨어 메모리 매핑 시 필수입니다.
  • 이 카드뉴스는 "Rust로 만드는 나만의 OS" 코스의 15/59편입니다

5. 전역 Writer와 lazy static

문자 출력이 정상적으로 동작하는 것을 확인한 김개발 씨는 이제 어디서든 println!을 사용할 수 있도록 전역 Writer를 만들고 싶습니다. "main 함수에서도, 인터럽트 핸들러에서도 출력을 쓰고 싶어요." 하지만 Rust에서 전역 가변 상태를 다루는 것은 쉽지 않습니다.

박시니어 씨는 "lazy_static과 스핀락을 조합해야 해"라고 말했습니다.

전역 Writer는 프로그램의 어디서든 화면 출력에 접근할 수 있게 만드는 글로벌 인스턴스입니다. lazy_static 매크로로 초기화를 지연시키고, Spinlock Mutex로 동시성을 보장합니다.

마치 건물의 비상 인터폰처럼, 어느 층에서든 하나의 출력 채널에 접근할 수 있습니다.

다음 코드를 살펴봅시다.

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

lazy_static! {
    /// 전역 Writer 인스턴스
    /// lazy_static으로 초기화를 지연시키고,
    /// Mutex로 동시 접근을 보호합니다
    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) },
    });
}

// 어디서든 사용 가능합니다!
use crate::vga_buffer::WRITER;
use core::fmt::Write;

WRITER.lock().write_str("전역에서 출력!").unwrap();

김개발 씨는 전역 변수 선언을 시도했다가 컴파일 에러를 만났습니다. "상수 식에서는 unsafe 블록을 사용할 수 없다"는 에러였습니다.

const fn으로 초기화할 수도 없었습니다. 왜냐하면 0xb8000 주소를 가변 참조로 변환하는 작업이 컴파일 타임에 불가능했기 때문입니다.

박시니어 씨가 웃으며 말했습니다. "전역 변수를 const로 만들면 컴파일 타임에 평가돼야 해.

하지만 우리가 필요한 건 런타임 초기화야. lazy_static을 써봐." 그렇다면 lazy_static과 전역 상태 관리란 무엇일까요?

쉽게 비유하자면, lazy_static은 마치 호텔 로비의 자동문과 같습니다. 손님이 처음 문 앞에 도착할 때까지 문은 닫혀 있습니다.

첫 번째 손님이 오면 문이 열리고 객실(Writer)이 준비됩니다. 그 이후에는 모든 손님이 준비된 객실을 이용할 수 있죠.

전역 상태를 직접 관리하던 시절에는 어땠을까요? 개발자들은 main 함수에서 Writer를 생성한 뒤, 모든 함수에 참조를 전달해야 했습니다.

인터럽트 핸들러 같은 특수한 컨텍스트에서는 접근조차 불가능했습니다. 코드가 지저분해지고, "또 참조를 추가해야 하나"라며 한숨을 쉬는 일이 많았습니다.

바로 이런 문제를 해결하기 위해 lazy_static과 Mutex의 조합이 등장했습니다. lazy_static을 사용하면 런타임 초기화가 가능해집니다.

또한 한 번만 초기화되는 것도 보장됩니다. 무엇보다 어디서든 접근 가능하다는 큰 이점이 있습니다.

패닉 핸들러에서도, 인터럽트 핸들러에서도 WRITER를 사용할 수 있죠. 위의 코드를 한 줄씩 살펴보겠습니다.

먼저 lazy_static! 매크로로 정적 변수를 감쌉니다. 다음으로 Mutex::new(...)로 Writer를 스핀락으로 보호합니다.

OS 커널에서는 운영체제의 Mutex를 사용할 수 없으므로, CPU 명령어 기반의 스핀락을 사용합니다. 그리고 0xb8000 as *mut Buffer로 VGA 버퍼 주소를 가변 포인터로 변환합니다.

실제 현업에서는 어떻게 활용할까요? 예를 들어 대규모 서비스의 로깅 시스템을 개발한다고 가정해봅시다.

전역 로거를 lazy_static으로 초기화하면, 애플리케이션의 어떤 모듈에서든 일관된 방식으로 로그를 남길 수 있습니다. env_logger, log4rs 같은 크레이트도 이 패턴을 기반으로 동작합니다.

하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 데드락 가능성을 무시하는 것입니다.

인터럽트 핸들러에서 WRITER.lock()을 호출하면, 이미 락을 잡고 있는 상태에서 또다시 락을 요청하게 되어 교착 상태가 발생할 수 있습니다. 따라서 인터럽트 컨텍스트에서는 락 없이 직접 접근하는 별도의 경로를 고려해야 합니다.

다시 김개발 씨의 이야기로 돌아가 봅시다. lazy_static을 적용하고 나니, 어디서든 WRITER.lock().write_str(...)으로 화면에 출력할 수 있게 되었습니다.

"이제 남은 건 println! 매크로만 만들면 되겠네요!" 전역 Writer를 안전하게 관리하면 OS 커널 전체에서 일관된 출력을 보장할 수 있습니다.

이제 마지막 단계인 println! 매크로를 만들어 보겠습니다.

실전 팁

💡 - OS 커널에서는 std::sync::Mutex 대신 spin::Mutex를 사용합니다. 운영체제의 스케줄링이 없기 때문입니다.

  • lazy_static 대신 Rust 1.80 이상에서는 LazyLock을 사용할 수도 있습니다. 표준 라이브러리만으로 동일한 기능을 제공합니다.
  • 이 카드뉴스는 "Rust로 만드는 나만의 OS" 코스의 15/59편입니다

드디어 김개발 씨가 가장 기다리던 순간이 왔습니다. 전역 Writer도 준비되었고, fmt::Write도 구현했습니다.

이제 표준 라이브러리의 println!처럼 동작하는 자체 매크로를 정의할 차례입니다. "이걸 만들면 진짜 OS 개발자가 된 기분이에요!" 김개발 씨의 설렘이 커졌습니다.

print!와 println! 매크로는 사용자가 정의한 전역 Writer와 fmt 포맷팅 시스템을 연결하는 최종 인터페이스입니다.

macro_rules!로 선언적 매크로를 정의하여, 인자를 전역 Writer에 전달하고 포맷팅을 수행합니다. 마치 식당의 메뉴판처럼, 사용자는 익숙한 인터페이스로 주문할 수 있습니다.

다음 코드를 살펴봅시다.

/// 커널 전용 print! 매크로
/// 표준 println!과 동일한 인터페이스를 제공합니다
macro_rules! print {
    ($($arg:tt)*) => ({
        $crate::vga_buffer::WRITER.lock().write_fmt(
            format_args!($($arg)*)
        ).unwrap();
    });
}

/// 커널 전용 println! 매크로
/// print! 뒤에 개행 문자를 추가합니다
macro_rules! println {
    () => (print!("\n"));
    ($($arg:tt)*) => (print!("{}\n", format_args!($($arg)*)));
}

// 사용 예시 - 일반 Rust 코드와 동일합니다!
print!("커널 부팅 중...");
println!("완료! 메모리: {} MB", 128);
println!("[OK] FAT32 파일시스템 로드됨");

김개발 씨는 macro_rules! 문법을 처음 접했습니다.

"매크로가 이렇게 생겼어요? 함수와 많이 다르네요." 하지만 박시니어 씨의 설명을 듣고 나니, 매크로의 구조가 의외로 단순하다는 것을 알게 되었습니다.

박시니어 씨가 설명했습니다. "핵심은 format_args!

야. 이 매크로가 컴파일 타임에 포맷 문자열을 분석해서, fmt::Write의 write_fmt 메서드에 전달할 수 있는 형태로 만들어줘." 그렇다면 print!

매크로의 동작 원리는 무엇일까요? 쉽게 비유하자면, print!

매크로는 마치 번역기와 같습니다. 우리가 "값: {}"이라고 쓰면, 번역기가 이를 "값: 42"라는 최종 문자열로 변환해서 출력 장치에 전달합니다.

포맷 문자열을 해석하고, 인자를 변환하고, 결과를 전달하는 모든 과정이 매크로 안에서 일어납니다. 매크로가 없던 시절에는 어땠을까요?

개발자들은 직접 write_str을 호출하고, 수동으로 문자열을 조합해야 했습니다. 숫자를 출력하려면 to_string()을 호출하고, 문자열 연결을 위해 format!을 중첩 사용해야 했죠.

코드가 읽기 어려워지고, 실수도 잦았습니다. "이거 진짜 println!

쓰고 싶다"는 바람이 커졌습니다. 바로 이런 문제를 해결하기 위해 macro_rules!로 커스텀 매크로를 정의합니다.

커스텀 매크로를 사용하면 표준 라이브러리와 동일한 인터페이스를 유지할 수 있습니다. 또한 컴파일 타임에 포맷 문자열 검증도 자동으로 이루어집니다.

무엇보다 기존 Rust 코드와의 호환성이라는 큰 이점이 있습니다. 다른 Rust 프로젝트의 코드를 OS에 그대로 붙여넣을 수 있죠.

위의 코드를 한 줄씩 살펴보겠습니다. 먼저 `macro_rules!

print로 매크로 이름을 정의합니다. 다음으로 ($($arg:tt)*)`는 모든 토큰 트리를 임의의 개수로 받는 패턴입니다.

그리고 $crate::vga_buffer::WRITER.lock()으로 전역 Writer의 Mutex 락을 획득합니다. 마지막으로 format_args!($($arg)*)가 컴파일 타임에 포맷 인자를 처리합니다.

실제 현업에서는 어떻게 활용할까요? 예를 들어 C++ 프로젝트에서 자체 로깅 매크로를 정의하는 것과 같은 원리입니다.

LOG_INFO, LOG_ERROR 같은 매크로를 만들어 파일명과 줄 번호를 자동으로 삽입하는 식으로요. Rust에서도 커스텀 로깅 매크로를 통해 개발자 경험을 크게 향상시킬 수 있습니다.

하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 매크로 내에서 패닉이 발생할 수 있다는 점을 간과하는 것입니다.

.unwrap()을 호출하므로, Writer가 에러를 반환하면 커널이 패닉합니다. 따라서 write_fmt의 에러 처리 방식을 신중하게 결정해야 합니다.

다시 김개발 씨의 이야기로 돌아가 봅시다. 매크로를 정의하고 테스트를 실행하자, 화면에 "커널 부팅 완료!"라는 메시지가 깔끔하게 출력되었습니다.

김개발 씨는 환호했습니다. "드디어 진짜 OS 개발자가 된 것 같아요!" print!와 println!

매크로를 직접 구현하면 Rust의 매크로 시스템에 대한 이해도 높아집니다. OS 커널 개발에서 이보다 더 보람찬 순간은 없을 것입니다.

실전 팁

💡 - format_args!는 컴파일 타임에 포맷 문자열을 검증하므로, 잘못된 포맷은 빌드 시에 잡힙니다. 이것이 C의 printf보다 안전한 이유입니다.

  • 매크로 내에서 $crate를 사용하면 크레이트 경로를 올바르게 처리할 수 있습니다. 다른 크레이트에서도 매크로가 정상 동작합니다.
  • 이 카드뉴스는 "Rust로 만드는 나만의 OS" 코스의 15/59편입니다

7. 컬러 출력과 커스텀 서식 구현

println! 매크로가 완성된 김개발 씨는 한 단계 더 나아가고 싶었습니다.

"에러 메시지는 빨간색으로, 성공 메시지는 초록색으로 출력하고 싶어요." 박시니어 씨가 고개를 끄덕였습니다. "좋은 생각이야.

커널에서 색상으로 로그 레벨을 구분하면 디버깅이 훨씬 쉬워지거든." 컬러 출력 시스템을 설계하기 시작했습니다.

컬러 출력 시스템은 Color 열거형과 ColorCode 구조체로 구성되어, 글자색과 배경색을 조합하여 화면에 컬러 텍스트를 출력합니다. VGA 텍스트 모드는 16가지 전경색과 8가지 배경색을 지원합니다.

마치 그림 물감 팔레트처럼, 원하는 색상을 자유롭게 조합할 수 있습니다.

다음 코드를 살펴봅시다.

#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum Color {
    Black = 0,
    Blue = 1,
    Green = 2,
    Red = 4,
    Yellow = 14,
    White = 15,
}

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

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

// 사용 예시 - 색상을 바꿔가며 출력
WRITER.lock().color_code = ColorCode::new(Color::Red, Color::Black);
print!("[ERROR] 디스크 읽기 실패");
WRITER.lock().color_code = ColorCode::new(Color::Green, Color::Black);
println!("[OK] 시스템 초기화 완료");

김개발 씨는 VGA 텍스트 모드의 색상 체계를 조사했습니다. 4비트로 전경색을, 4비트로 배경색을 표현하여 총 8비트(1바이트)로 색상 정보를 관리한다는 사실을 알게 되었습니다.

"아, 비트 연산으로 한 바이트에 두 가지 색상을 넣는 거군요!" 박시니어 씨가 덧붙였습니다. "맞아.

그리고 repr(u8)을 붙이면 C 언어의 enum처럼 정수 값을 직접 지정할 수 있어. 하드웨어와 직접 통신할 때 필수야." 그렇다면 컬러 시스템의 설계 원리는 무엇일까요?

쉽게 비유하자면, ColorCode는 마치 물감 팔레트와 같습니다. 팔레트는 전경색(글자색)과 배경색을 각각 선택할 수 있는 두 개의 슬롯을 가집니다.

최종 색상은 이 두 슬롯의 조합으로 결정됩니다. VGA 하드웨어는 이 조합을 읽어서 화면에 컬러로 표시합니다.

컬러 시스템이 없던 시절에는 어땠을까요? 모든 텍스트가 흰색 바탕에 검은 글자로 출력되었습니다.

에러 메시지와 일반 로그를 구분하기 어려웠죠. 디버깅할 때 로그를 처음부터 다시 읽어야 했습니다.

"이거 에러인지, 그냥 정보인지 구분이 안 되네"라며 혼란스러워하는 일이 잦았습니다. 바로 이런 문제를 해결하기 위해 컬러 출력 시스템을 도입합니다.

컬러를 사용하면 로그 레벨을 시각적으로 구분할 수 있습니다. 또한 중요한 정보를 즉각적으로 인식할 수도 있습니다.

무엇보다 사용자 경험의 큰 향상이라는 이점이 있습니다. 리눅스 커널도 dmesg에서 색상 출력을 지원하죠.

위의 코드를 한 줄씩 살펴보겠습니다. 먼저 #[repr(u8)] 속성으로 각 색상이 실제 하드웨어 값과 일치하도록 합니다.

다음으로 ColorCode(u8) 구조체는 repr(transparent)로 선언하여 메모리 레이아웃을 u8과 동일하게 만듭니다. 그리고 (background as u8) << 4 | (foreground as u8)에서 배경색을 상위 4비트, 전경색을 하위 4비트에 배치합니다.

실제 현업에서는 어떻게 활용할까요? 예를 들어 CLI 도구를 개발한다고 가정해봅시다.

colored, termcolor 같은 크레이트를 사용하면 터미널에서 ANSI 색상 코드로 컬러 출력을 구현할 수 있습니다. 에러는 빨간색, 경고는 노란색, 성공은 초록색으로 구분하는 것은 CLI 도구의 기본입니다.

하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 너무 많은 색상을 사용하는 것입니다.

화면이 화려해지지만, 오히려 가독성이 떨어집니다. "무지개 같은 터미널"은 보기에만 좋을 뿐 실용적이지 않습니다.

따라서 3-4가지 색상으로 로그 레벨을 구분하는 정도가 적절합니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.

컬러 출력을 추가한 뒤 커널 부팅 로그를 확인하니, 에러가 빨간색, 성공이 초록색으로 뚜렷하게 구분되었습니다. "이렇게 보니까 버그가 한눈에 보이네요!" 컬러 출력 시스템을 구현하면 OS 커널의 디버깅 경험이 크게 향상됩니다.

다음에는 이 모든 것을 하나로 묶는 최종 정리를 해보겠습니다.

실전 팁

💡 - VGA 텍스트 모드는 전경색 16가지(밝기 비트 포함)와 배경색 8가지를 지원합니다. 밝기 비트(비트 7)를 배경색에 사용하면 화면이 깜빡입니다.

  • 컬러를 동적으로 변경하려면 Writer 구조체에 set_color 메서드를 추가하세요. 색상 변경 후에도 이전 출력 색상은 유지됩니다.
  • 이 카드뉴스는 "Rust로 만드는 나만의 OS" 코스의 15/59편입니다

8. 전체 파이프라인과 패닉 핸들러 연동

모든 구성 요소가 준비되었습니다. Writer 구조체, fmt::Write 트레이트, 전역 WRITER, print!/println!

매크로, 컬러 시스템까지. 이제 이 모든 것을 하나로 연결하고, 패닉 발생 시에도 크래시 정보를 화면에 출력할 수 있도록 패닉 핸들러와 연동할 차례입니다.

"패닉이 나도 화면에 무슨 일이 일어났는지 보여주면 디버깅이 쉬울 텐데..."

패닉 핸들러 연동은 커널이 크래시했을 때, 스택 트레이스와 에러 메시지를 화면에 출력하여 디버깅을 가능하게 하는 최종 단계입니다. 우리가 만든 println!

매크로를 패닉 핸들러에서 사용하면, 커널 패닉의 원인을 즉시 파악할 수 있습니다. 마치 비행기의 블랙박스처럼, 마지막 순간의 정보를 기록합니다.

다음 코드를 살펴봅시다.

use core::panic::PanicInfo;

/// 커널 패닉 핸들러
/// 패닉 발생 시 화면에 상세 정보를 출력합니다
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
    // 에러 메시지를 빨간색으로 출력
    println!("[PANIC] {}", info);

    // 스택 프레임 정보 출력 (있는 경우)
    if let Some(location) = info.location() {
        println!(
            "  파일: {}\n  줄: {}\n  열: {}",
            location.file(),
            location.line(),
            location.column()
        );
    }

    // 무한 루프로 CPU를 정지 (커널은 복구 불가)
    loop {
        // HLT 명령으로 CPU를 절전 모드로 전환
        // 인터럽트가 발생하면 깨어납니다
        unsafe { core::arch::asm!("hlt"); }
    }
}

김개발 씨는 패닉 핸들러를 작성하면서 깨달았습니다. "이렇게 보니까, 우리가 만든 println!이 정말 중요하네요.

패닉이 났을 때 유일한 출력 수단이니까." 박시니어 씨도 동의했습니다. "맞아.

패닉 핸들러가 제대로 작동하려면 출력 시스템이 먼저 완성되어야 해." 패닉 핸들러의 핵심 역할은 무엇일까요? 쉽게 비유하자면, 패닉 핸들러는 마치 비행기의 블랙박스와 같습니다.

비행기가 추락하기 직전까지의 모든 기록을 남깁니다. 파일명, 줄 번호, 에러 메시지 등 복구에 필요한 모든 정보를 화면에 출력합니다.

개발자는 이 정보를 바탕으로 버그를 추적할 수 있습니다. 패닉 핸들러가 없던 시절에는 어땠을까요?

커널이 패닉하면 화면이 멈추거나 재부팅되었습니다. 왜 패닉했는지 알 수 없었죠.

QEMU를 실행한 채로 화면을 응시하며, 수동으로 메모리 덤프를 분석해야 했습니다. "이거 도대체 어디서 터진 거야?"라며 며칠씩 원인을 찾는 일도 있었습니다.

바로 이런 문제를 해결하기 위해 상세한 패닉 핸들러를 구현합니다. 패닉 핸들러를 연동하면 크래시 원인을 즉시 파악할 수 있습니다.

또한 소스 코드 위치를 정확히 추적할 수도 있습니다. 무엇보다 디버깅 시간의 획기적 단축이라는 큰 이점이 있습니다.

파일명과 줄 번호가 화면에 출력되니까요. 위의 코드를 한 줄씩 살펴보겠습니다.

먼저 #[panic_handler] 속성으로 이 함수가 패닉 핸들러임을 컴파일러에 알립니다. 다음으로 info: &PanicInfo에서 패닉에 관한 모든 정보를 받습니다.

그리고 info.location()에서 패닉이 발생한 소스 코드 위치를 추출합니다. 마지막으로 loop { asm!("hlt"); }에서 CPU를 안전하게 정지시킵니다.

실제 현업에서는 어떻게 활용할까요? 예를 들어 자동차용 임베디드 시스템을 개발한다고 가정해봅시다.

패닉 핸들러에서 CAN 버스를 통해 에러 정보를 외부 장치로 전송하면, 사고 발생 후에도 원인을 분석할 수 있습니다. 많은 자율주행 기업에서 이런 방식으로 크래시 로그를 수집합니다.

하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 패닉 핸들러에서 또다시 패닉을 유발하는 것입니다.

패닉 핸들러 내에서 fallible한 연산을 수행하면, 이중 패닉이 발생하여 CPU가 즉시 정지합니다. 따라서 패닉 핸들러는 반드시 infallible하게 작성해야 합니다.

다시 김개발 씨의 이야기로 돌아가 봅시다. 의도적으로 panic!("테스트")를 호출해보니, 화면에 빨간색으로 에러 메시지와 파일 위치가 출력되었습니다.

"이걸로 버그를 잡는 재미가 생기겠네요!" println! 매크로를 패닉 핸들러와 연동하면 OS 개발의 안전망이 완성됩니다.

다음 장에서는 이 출력 시스템의 성능을 최적화하고, 테스트 프레임워크를 통합하는 방법을 다룰 예정입니다.

실전 팁

💡 - 패닉 핸들러에서는 절대 패닉을 유발하는 연산을 하지 마세요. unwrap(), expect() 같은 메서드 사용을 피하세요.

  • hlt 명령은 CPU를 절전 모드로 전환합니다. 인터럽트가 없는 상태에서는 영원히 대기하므로, 패닉 후 정지에 적합합니다.
  • 다음 카드뉴스에서는 Executor와 Waker에 대해 다룹니다. 비동기 태스크 실행의 핵심 메커니즘을 살펴보세요.
  • 이 카드뉴스는 "Rust로 만드는 나만의 OS" 코스의 15/59편입니다

이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!

#Rust#println#macro#fmt#VGA#시스템프로그래밍

댓글 (0)

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