이미지 로딩 중...

Rust로 만드는 나만의 OS 시리얼 포트 출력 완벽 가이드 - 슬라이드 1/11
A

AI Generated

2025. 11. 14. · 3 Views

Rust로 만드는 나만의 OS 시리얼 포트 출력 완벽 가이드

베어메탈 환경에서 시리얼 포트를 통해 디버깅 메시지를 출력하는 방법을 배웁니다. UART 프로토콜의 핵심 원리부터 Rust로 구현하는 드라이버 코드까지, OS 개발의 가장 기본적이면서도 중요한 출력 시스템을 마스터합니다.


목차

  1. 시리얼 포트의 개념과 필요성 - OS 개발자의 첫 번째 도구
  2. UART 초기화하기 - 시리얼 포트를 사용 가능한 상태로 만들기
  3. 문자열 출력 구현하기 - 실용적인 print 함수 만들기
  4. 스레드 안전한 출력 만들기 - Mutex로 동시성 문제 해결
  5. serial_print와 serial_println 매크로 만들기 - println!처럼 편리하게
  6. 패닉 핸들러에 시리얼 출력 통합하기 - 크래시 디버깅의 핵심
  7. 테스트 프레임워크와 통합하기 - 자동화된 커널 테스팅
  8. 로그 레벨과 포맷팅 고도화 - 프로덕션 수준의 로깅
  9. 시리얼 입력 구현하기 - 양방향 통신으로 확장
  10. 실전 디버깅 워크플로우 - 시리얼 포트 기반 개발 환경

1. 시리얼 포트의 개념과 필요성 - OS 개발자의 첫 번째 도구

시작하며

여러분이 처음 OS 커널을 작성할 때 가장 먼저 직면하는 문제가 무엇인지 아시나요? 바로 "내 코드가 제대로 동작하는지 어떻게 확인하지?"입니다.

일반 애플리케이션 개발과 달리 OS 개발에서는 printf나 console.log 같은 출력 함수를 사용할 수 없습니다. 화면에 뭔가를 표시하려면 VGA 버퍼나 프레임버퍼를 직접 제어해야 하는데, 이것도 복잡한 드라이버가 필요합니다.

디버거를 연결하는 것도 초기 단계에서는 제한적입니다. 바로 이런 상황에서 시리얼 포트가 구세주가 됩니다.

시리얼 포트는 하드웨어 레벨에서 가장 단순하면서도 강력한 출력 수단입니다. 복잡한 설정 없이 몇 바이트만 특정 I/O 포트에 쓰면 즉시 데이터를 전송할 수 있어, OS 개발의 첫 걸음으로 완벽합니다.

개요

간단히 말해서, 시리얼 포트는 데이터를 한 비트씩 순차적으로 전송하는 하드웨어 인터페이스입니다. PC에서 가장 일반적으로 사용되는 시리얼 포트는 COM1(I/O 포트 주소 0x3F8)입니다.

예를 들어, QEMU나 VirtualBox 같은 가상 머신에서는 시리얼 포트 출력을 호스트 머신의 파일이나 터미널로 리디렉션할 수 있어 즉각적인 디버깅이 가능합니다. 기존에는 화면 출력을 위해 복잡한 VGA 드라이버를 먼저 작성해야 했다면, 이제는 시리얼 포트를 통해 단 몇 줄의 코드로 출력 시스템을 구축할 수 있습니다.

시리얼 포트의 핵심 특징은 첫째, 간단한 레지스터 기반 인터페이스로 최소한의 초기화만 필요하다는 점입니다. 둘째, 부트로더 단계부터 사용 가능해 매우 초기 단계의 디버깅도 가능합니다.

셋째, UART(Universal Asynchronous Receiver/Transmitter) 칩이 대부분의 x86 시스템에 기본 탑재되어 있어 호환성이 뛰어납니다. 이러한 특징들이 시리얼 포트를 OS 개발의 필수 도구로 만듭니다.

코드 예제

// UART 레지스터 주소 정의
const SERIAL_PORT: u16 = 0x3F8; // COM1
const DATA_PORT: u16 = SERIAL_PORT;
const LINE_STATUS: u16 = SERIAL_PORT + 5;

// 시리얼 포트로 한 바이트 출력
fn serial_write_byte(byte: u8) {
    unsafe {
        // 전송 버퍼가 비어있을 때까지 대기 (비트 5 체크)
        while (x86_64::instructions::port::Port::<u8>::new(LINE_STATUS).read() & 0x20) == 0 {}
        // 데이터 전송
        x86_64::instructions::port::Port::<u8>::new(DATA_PORT).write(byte);
    }
}

설명

이것이 하는 일: 시리얼 포트의 하드웨어 레지스터에 직접 접근하여 한 바이트씩 데이터를 전송하는 저수준 출력 함수를 구현합니다. 첫 번째로, UART 칩의 레지스터 주소를 정의합니다.

COM1 포트는 I/O 주소 0x3F8부터 시작하며, DATA_PORT(0x3F8)는 실제 데이터를 쓰는 곳이고, LINE_STATUS(0x3FD)는 전송 상태를 확인하는 레지스터입니다. 이렇게 하드웨어 주소를 상수로 정의하면 코드 가독성이 높아지고 나중에 다른 COM 포트로 변경하기도 쉽습니다.

그 다음으로, serial_write_byte 함수의 while 루프가 실행되면서 LINE_STATUS 레지스터를 반복적으로 읽습니다. 비트 5가 1이면 전송 버퍼가 비어있다는 의미로, 새로운 데이터를 쓸 수 있습니다.

이 busy-wait 방식은 간단하지만 CPU를 계속 사용한다는 단점이 있습니다. 하지만 초기 OS 개발 단계에서는 인터럽트 기반 방식보다 구현이 훨씬 간단하고 안정적입니다.

마지막으로, 버퍼가 준비되면 Port::write를 통해 DATA_PORT에 바이트를 씁니다. UART 칩은 이 데이터를 자동으로 직렬 신호로 변환하여 전송합니다.

unsafe 블록이 필요한 이유는 I/O 포트 접근이 하드웨어를 직접 제어하는 위험한 작업이기 때문입니다. 여러분이 이 코드를 사용하면 커널의 어느 시점에서든 디버깅 메시지를 출력할 수 있습니다.

페이지 테이블 설정 전에도, 인터럽트 핸들러 내부에서도, 패닉 핸들러에서도 작동합니다. 또한 QEMU에서 -serial stdio 옵션을 사용하면 호스트 터미널에 즉시 출력이 나타나 개발 생산성이 크게 향상됩니다.

실전 팁

💡 QEMU 실행 시 -serial file:serial.log 옵션을 사용하면 모든 시리얼 출력이 파일로 저장되어, 커널 패닉 후에도 로그를 분석할 수 있습니다.

💡 실제 하드웨어에서 테스트할 때는 USB-to-Serial 어댑터와 minicom 같은 터미널 프로그램을 사용하면 되지만, 보드레이트 설정(보통 115200)을 정확히 맞춰야 합니다.

💡 멀티코어 시스템에서는 여러 CPU가 동시에 시리얼 포트에 쓸 수 있으므로, 나중에 스핀락을 추가해 동기화해야 출력이 섞이지 않습니다.

💡 성능이 중요한 경우 링 버퍼를 구현하고 인터럽트 방식으로 전환하면 busy-wait의 CPU 낭비를 줄일 수 있습니다.


2. UART 초기화하기 - 시리얼 포트를 사용 가능한 상태로 만들기

시작하며

여러분이 방금 작성한 serial_write_byte 함수를 실행했는데, 아무것도 출력되지 않거나 깨진 문자가 나타난다면 어떻게 하시겠습니까? 이는 UART 칩이 아직 초기화되지 않았기 때문입니다.

대부분의 하드웨어는 전원이 켜진 직후 불확실한 상태에 있습니다. UART도 마찬가지로 보드레이트, 데이터 비트, 패리티 등의 설정이 필요합니다.

잘못 설정하면 데이터가 제대로 전송되지 않거나 수신측에서 해석할 수 없는 신호가 됩니다. 바로 이럴 때 필요한 것이 UART 초기화 루틴입니다.

한 번만 정확히 설정하면 이후 모든 출력이 안정적으로 작동합니다.

개요

간단히 말해서, UART 초기화는 시리얼 포트의 통신 파라미터와 내부 설정을 구성하는 과정입니다. 초기화 과정에서는 보드레이트(초당 전송 비트 수), 데이터 형식(8비트, 패리티 없음 등), FIFO 버퍼 활성화 등을 설정합니다.

예를 들어, QEMU와 통신할 때는 일반적으로 38400 bps 보드레이트를 사용하며, 실제 하드웨어에서는 115200 bps가 표준입니다. 기존에는 BIOS가 제공하는 인터럽트를 통해 시리얼 통신을 했다면, 이제는 하드웨어를 직접 제어하여 BIOS에 의존하지 않고 독립적으로 작동할 수 있습니다.

UART 초기화의 핵심 특징은 첫째, DLAB(Divisor Latch Access Bit)을 사용한 보드레이트 설정 방식입니다. 둘째, 레지스터 기반 제어로 단 몇 번의 I/O 명령으로 완료됩니다.

셋째, 한 번 설정하면 시스템이 종료될 때까지 유효합니다. 이러한 특징들이 초기화를 간단하면서도 강력하게 만듭니다.

코드 예제

// UART 초기화 함수
pub fn serial_init() {
    unsafe {
        let mut port = x86_64::instructions::port::Port::<u8>::new;

        // 인터럽트 비활성화
        port(SERIAL_PORT + 1).write(0x00);

        // DLAB 활성화하여 보드레이트 설정 모드로 전환
        port(SERIAL_PORT + 3).write(0x80);

        // 보드레이트 설정: 38400 bps (divisor = 3)
        port(SERIAL_PORT + 0).write(0x03); // Low byte
        port(SERIAL_PORT + 1).write(0x00); // High byte

        // 8비트, 패리티 없음, 1 스톱 비트 (0x03)
        port(SERIAL_PORT + 3).write(0x03);

        // FIFO 활성화 및 버퍼 클리어 (14바이트 임계값)
        port(SERIAL_PORT + 2).write(0xC7);

        // 모뎀 제어: RTS, DTR 활성화
        port(SERIAL_PORT + 4).write(0x0B);
    }
}

설명

이것이 하는 일: UART 칩의 여러 레지스터에 특정 값을 써서 통신 파라미터를 구성하고, 안정적인 데이터 전송을 위한 환경을 준비합니다. 첫 번째로, 인터럽트 활성화 레지스터(IER, 오프셋 +1)에 0x00을 써서 모든 인터럽트를 비활성화합니다.

초기 단계에서는 폴링 방식으로 작동하므로 인터럽트가 불필요하며, 나중에 필요하면 활성화할 수 있습니다. 그 다음 라인 제어 레지스터(LCR, 오프셋 +3)에 0x80을 써서 DLAB 비트를 설정합니다.

DLAB이 활성화되면 오프셋 +0과 +1이 일반 데이터 레지스터에서 보드레이트 디바이저 레지스터로 용도가 변경됩니다. 그 다음으로, 보드레이트를 설정합니다.

디바이저 값은 115200을 원하는 보드레이트로 나눈 값입니다. 38400 bps를 원하면 115200 / 38400 = 3이므로 하위 바이트에 0x03, 상위 바이트에 0x00을 씁니다.

이후 LCR에 0x03을 써서 DLAB을 비활성화하고 동시에 데이터 형식을 설정합니다. 0x03은 8비트 데이터, 패리티 없음, 1 스톱 비트를 의미하며, 이는 가장 일반적인 설정입니다.

마지막으로, FIFO 제어 레지스터(FCR, 오프셋 +2)에 0xC7을 씁니다. 이는 FIFO를 활성화하고(비트 0), 수신/송신 버퍼를 클리어하며(비트 1, 2), 14바이트 인터럽트 임계값을 설정합니다(비트 6, 7).

FIFO가 있으면 여러 바이트를 버퍼링할 수 있어 효율이 높아집니다. 모뎀 제어 레지스터(MCR, 오프셋 +4)에 0x0B을 써서 RTS(Request to Send)와 DTR(Data Terminal Ready) 신호를 활성화합니다.

여러분이 이 초기화를 커널의 가장 초기 단계, 예를 들어 _start 함수의 시작 부분에서 호출하면, 이후 모든 시리얼 출력이 정상적으로 작동합니다. 또한 이 설정은 표준 PC 하드웨어와 대부분의 에뮬레이터에서 호환되므로, 한 번 작성하면 여러 환경에서 재사용할 수 있습니다.

실전 팁

💡 실제 하드웨어에서 보드레이트를 115200 bps로 하려면 디바이저를 1로 설정하세요 (115200 / 115200 = 1). 빠른 전송 속도가 필요할 때 유용합니다.

💡 초기화 후 루프백 테스트를 수행하면 UART가 제대로 작동하는지 확인할 수 있습니다. MCR의 비트 4를 설정하여 루프백 모드로 전환하고, 데이터를 쓴 후 읽어서 같은 값이 나오는지 체크하세요.

💡 COM2, COM3, COM4를 사용하려면 각각 0x2F8, 0x3E8, 0x2E8 주소로 변경하면 됩니다. 멀티 포트 환경에서 여러 디버깅 채널을 운영할 때 유용합니다.

💡 임베디드 시스템에서는 UART 레지스터 주소가 다를 수 있으므로, 플랫폼의 데이터시트를 확인하고 주소를 매크로나 상수로 추상화하세요.


3. 문자열 출력 구현하기 - 실용적인 print 함수 만들기

시작하며

여러분이 한 바이트씩 출력하는 함수를 만들었지만, 매번 "Hello, World!"를 출력하려고 각 문자마다 함수를 호출하는 것은 매우 번거롭습니다. 실제 개발에서는 문자열을 한 번에 출력하고 싶은 경우가 대부분이죠.

더 나아가, Rust의 표준 라이브러리에 있는 print!나 println! 같은 매크로처럼 포맷팅된 문자열을 출력하고 싶을 것입니다.

"Value: {}" 같은 형식으로 변수 값을 쉽게 출력할 수 있다면 디버깅이 훨씬 편리해집니다. 바로 이럴 때 필요한 것이 Rust의 core::fmt 트레이트를 구현한 Writer 구조체입니다.

이를 통해 표준 출력처럼 사용할 수 있는 시리얼 포트 출력 시스템을 구축할 수 있습니다.

개요

간단히 말해서, core::fmt::Write 트레이트를 구현하면 write! 매크로를 사용해 포맷팅된 출력을 할 수 있습니다.

Rust의 no_std 환경에서는 std::fmt 대신 core::fmt를 사용하며, Write 트레이트는 write_str 메서드 하나만 구현하면 됩니다. 예를 들어, write!(serial, "x = {}, y = {}", 10, 20) 같은 편리한 문법을 사용할 수 있게 됩니다.

기존에는 각 바이트를 수동으로 루프 돌면서 출력해야 했다면, 이제는 Rust의 표준 포맷팅 시스템을 활용하여 정수, 부동소수점, 포인터 주소 등을 자동으로 변환해 출력할 수 있습니다. 이 접근법의 핵심 특징은 첫째, Rust의 타입 시스템과 완벽히 통합되어 컴파일 타임에 포맷 문자열을 검증한다는 점입니다.

둘째, zero-cost abstraction으로 런타임 오버헤드가 거의 없습니다. 셋째, 스레드 안전성을 위한 Mutex를 쉽게 추가할 수 있는 구조입니다.

이러한 특징들이 실용적인 OS 개발을 가능하게 합니다.

코드 예제

use core::fmt;

pub struct SerialPort;

impl SerialPort {
    pub fn write_byte(&self, byte: u8) {
        unsafe {
            let mut port = x86_64::instructions::port::Port::<u8>::new;
            // 전송 준비 대기
            while (port(SERIAL_PORT + 5).read() & 0x20) == 0 {}
            port(SERIAL_PORT).write(byte);
        }
    }
}

// core::fmt::Write 트레이트 구현
impl fmt::Write for SerialPort {
    fn write_str(&mut self, s: &str) -> fmt::Result {
        for byte in s.bytes() {
            self.write_byte(byte);
        }
        Ok(())
    }
}

// 전역 인스턴스 (나중에 Mutex로 감싸야 함)
pub static mut SERIAL: SerialPort = SerialPort;

설명

이것이 하는 일: Rust의 포맷팅 시스템과 통합되는 Writer를 만들어, 복잡한 데이터 타입도 쉽게 시리얼 포트로 출력할 수 있게 합니다. 첫 번째로, SerialPort 구조체를 정의합니다.

이 구조체는 시리얼 포트의 상태를 관리하는 역할을 하며, 현재는 필드가 없지만 나중에 포트 주소나 락을 추가할 수 있습니다. write_byte 메서드는 앞에서 만든 함수를 구조체의 메서드로 래핑한 것입니다.

self 파라미터를 받아 인스턴스 메서드로 작동하며, 이렇게 하면 나중에 여러 개의 시리얼 포트를 다룰 때 각각의 상태를 관리할 수 있습니다. 그 다음으로, fmt::Write 트레이트를 구현합니다.

write_str 메서드는 &str을 받아서 각 바이트를 순회하며 write_byte를 호출합니다. bytes() 이터레이터는 UTF-8 문자열을 바이트 시퀀스로 변환하는데, ASCII 범위 문자는 그대로 전송되고 한글 같은 멀티바이트 문자는 여러 바이트로 나뉘어 전송됩니다.

반환 타입 fmt::Result는 에러 처리를 위한 것이지만, 시리얼 포트는 실패하지 않는다고 가정하고 항상 Ok(())를 반환합니다. 마지막으로, 전역 static mut 변수로 SERIAL 인스턴스를 선언합니다.

static mut는 프로그램 전체에서 접근 가능하지만 unsafe 블록 안에서만 사용할 수 있습니다. 더 안전한 방법은 lazy_static이나 spin::Mutex로 감싸는 것인데, 이는 다음 섹션에서 다룹니다.

여러분이 이 코드를 사용하면 use core::fmt::Write; write!(unsafe { &mut SERIAL }, "Value: {}", 42).unwrap(); 같은 방식으로 출력할 수 있습니다. 포맷 문자열의 {} 플레이스홀더는 자동으로 해당 타입의 Display 트레이트를 호출하여 문자열로 변환하므로, 수동으로 숫자를 문자열로 바꿀 필요가 없습니다.

또한 {:x}로 16진수, {:p}로 포인터 주소 등 다양한 포맷 옵션을 사용할 수 있어 디버깅이 훨씬 편리해집니다.

실전 팁

💡 한글이나 이모지 같은 멀티바이트 문자를 출력할 때는 UART가 UTF-8 바이트 스트림을 그대로 전송하므로, 수신측 터미널이 UTF-8을 지원해야 제대로 보입니다.

💡 fmt::Result를 unwrap() 대신 expect("Serial write failed")로 처리하면, 만약 에러가 발생했을 때 더 명확한 메시지를 볼 수 있습니다.

💡 write_byte에서 busy-wait 대신 타임아웃을 추가하면 하드웨어 문제로 무한 대기하는 것을 방지할 수 있습니다. 예를 들어, 1000번 루프 후에도 준비되지 않으면 에러를 반환하도록 수정하세요.

💡 디버그 빌드에서는 출력이 많아질 수 있으므로, #[cfg(debug_assertions)]를 사용해 릴리스 빌드에서는 일부 출력을 제거하면 성능이 향상됩니다.


4. 스레드 안전한 출력 만들기 - Mutex로 동시성 문제 해결

시작하며

여러분의 OS가 멀티태스킹을 지원하게 되면, 여러 스레드가 동시에 시리얼 포트에 출력을 시도하는 상황이 발생합니다. 예를 들어, 스레드 A가 "Hello"를 출력하는 중에 스레드 B가 "World"를 출력하면 "HeWloorldlo" 같은 뒤섞인 결과가 나타날 수 있습니다.

더 심각한 문제는 write_byte 함수 내부의 busy-wait 루프에서 레이스 컨디션이 발생할 수 있다는 것입니다. 두 스레드가 동시에 "버퍼가 비었다"고 판단하고 데이터를 쓰면, 하나의 바이트가 손실될 수 있습니다.

바로 이럴 때 필요한 것이 Mutex(Mutual Exclusion)입니다. spin 크레이트의 Mutex를 사용하면 OS의 스케줄러 없이도 스핀락 기반의 동기화를 구현할 수 있습니다.

개요

간단히 말해서, Mutex는 한 번에 하나의 스레드만 특정 자원에 접근할 수 있도록 보장하는 동기화 기법입니다. spin::Mutex는 커널 수준에서 사용할 수 있는 경량 Mutex로, 락을 획득할 때까지 CPU를 반복 사용(스핀)합니다.

예를 들어, 스레드가 락을 시도할 때 이미 다른 스레드가 보유 중이면, 락이 해제될 때까지 계속 확인합니다. 기존에는 unsafe static mut로 전역 변수에 접근했다면, 이제는 Mutex를 통해 안전하게 접근하고 Rust의 타입 시스템이 컴파일 타임에 동시성 버그를 방지합니다.

Mutex의 핵심 특징은 첫째, lock() 메서드가 MutexGuard를 반환하는데, 이 가드가 스코프를 벗어나면 자동으로 락이 해제된다는 점입니다(RAII 패턴). 둘째, 스핀락은 대기 시간이 짧을 때 효율적이지만, 긴 작업에서는 CPU를 낭비하므로 주의가 필요합니다.

셋째, 인터럽트와 함께 사용할 때는 인터럽트를 비활성화하지 않으면 데드락이 발생할 수 있습니다. 이러한 특징들을 이해하고 사용해야 안전한 커널을 만들 수 있습니다.

코드 예제

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

// 스레드 안전한 전역 시리얼 포트
lazy_static! {
    pub static ref SERIAL: Mutex<SerialPort> = {
        let mut serial = SerialPort;
        // 초기화 수행
        serial.init();
        Mutex::new(serial)
    };
}

impl SerialPort {
    pub fn init(&mut self) {
        // 앞에서 작성한 serial_init 코드
        unsafe {
            let mut port = x86_64::instructions::port::Port::<u8>::new;
            port(SERIAL_PORT + 1).write(0x00);
            port(SERIAL_PORT + 3).write(0x80);
            port(SERIAL_PORT + 0).write(0x03);
            port(SERIAL_PORT + 1).write(0x00);
            port(SERIAL_PORT + 3).write(0x03);
            port(SERIAL_PORT + 2).write(0xC7);
            port(SERIAL_PORT + 4).write(0x0B);
        }
    }
}

// 사용 예시
use core::fmt::Write;
let mut serial = SERIAL.lock();
write!(serial, "Thread-safe output!\n").unwrap();

설명

이것이 하는 일: Mutex를 사용해 시리얼 포트 접근을 직렬화하고, lazy_static으로 런타임 초기화를 지연시켜 컴파일 타임에 초기화할 수 없는 자원을 안전하게 관리합니다. 첫 번째로, lazy_static!

매크로를 사용해 SERIAL 변수를 선언합니다. 일반 static 변수는 컴파일 타임 상수만 초기화할 수 있지만, lazy_static은 첫 접근 시점에 초기화 코드를 실행합니다.

이는 함수 호출이나 복잡한 객체 생성이 필요한 경우에 필수적입니다. 블록 내부에서 SerialPort를 생성하고 init() 메서드로 UART를 초기화한 후, Mutex::new()로 감쌉니다.

이렇게 하면 프로그램이 시작되고 처음으로 SERIAL에 접근할 때 자동으로 초기화됩니다. 그 다음으로, SerialPort에 init 메서드를 추가합니다.

이는 앞에서 작성한 초기화 코드를 구조체의 메서드로 옮긴 것으로, &mut self를 받아 인스턴스를 수정할 수 있습니다. 이렇게 분리하면 코드 구조가 명확해지고, 나중에 여러 포트를 관리할 때도 각각 초기화할 수 있습니다.

마지막으로, 사용할 때는 SERIAL.lock()을 호출하여 MutexGuard를 얻습니다. 이 가드가 존재하는 동안 다른 스레드는 락을 획득할 수 없으므로, 현재 스레드만 시리얼 포트에 접근할 수 있습니다.

write! 매크로를 사용해 출력한 후, 가드가 스코프를 벗어나면 자동으로 Drop 트레이트가 호출되어 락이 해제됩니다.

이는 명시적으로 unlock()을 호출할 필요가 없어 실수로 락을 해제하지 않는 버그를 방지합니다. 여러분이 이 패턴을 사용하면 멀티코어 시스템에서도 안전하게 로그를 출력할 수 있습니다.

각 코어에서 실행되는 스레드가 동시에 출력을 시도해도 Mutex가 순서를 보장하여 출력이 섞이지 않습니다. 또한 Rust의 소유권 시스템 덕분에 락을 보유하지 않은 상태에서 시리얼 포트에 접근하려고 하면 컴파일 에러가 발생하므로, 런타임 버그를 사전에 방지할 수 있습니다.

실전 팁

💡 인터럽트 핸들러에서 시리얼 출력을 사용한다면, lock() 전에 인터럽트를 비활성화해야 데드락을 방지할 수 있습니다. spin::Mutex 대신 spin::Mutex::new()와 함께 interrupt::without_interrupts를 사용하세요.

💡 스핀락은 락 대기 시간이 짧을 때만 효율적이므로, 시리얼 출력처럼 빠른 작업에 적합합니다. 만약 오래 걸리는 작업이라면 슬립 가능한 Mutex를 고려하세요.

💡 디버깅 목적으로 락 경합(contention)을 측정하려면, 커스텀 Mutex 구현에 시도 횟수 카운터를 추가하여 스핀 횟수를 로그로 남길 수 있습니다.

💡 성능이 중요한 경우, try_lock()을 사용해 락을 획득할 수 없으면 즉시 실패하도록 하고, 나중에 재시도하는 방식으로 구현하면 다른 작업을 계속할 수 있습니다.


5. serial_print와 serial_println 매크로 만들기 - println!처럼 편리하게

시작하며

여러분이 매번 SERIAL.lock()write!를 조합해서 사용하는 것은 여전히 번거롭습니다. Rust의 표준 라이브러리처럼 println!("Hello, {}", name) 형태로 간단하게 출력하고 싶지 않으신가요?

매크로를 사용하면 반복적인 보일러플레이트 코드를 숨기고, 마치 내장 기능처럼 자연스럽게 사용할 수 있습니다. 또한 자동으로 줄바꿈을 추가하거나, 에러 처리를 숨기는 등의 편의 기능도 제공할 수 있습니다.

바로 이럴 때 필요한 것이 Rust의 선언적 매크로(declarative macros)입니다. println!과 거의 동일한 인터페이스로 시리얼 포트 전용 출력 매크로를 만들어 봅시다.

개요

간단히 말해서, 매크로는 컴파일 타임에 코드를 생성하는 메타프로그래밍 도구로, 반복적인 패턴을 추상화할 수 있습니다. Rust의 macro_rules!를 사용하면 가변 인자를 받아 임의의 코드로 확장할 수 있습니다.

예를 들어, serial_println!("x = {}", 10)은 컴파일 타임에 적절한 lock()과 write!() 호출로 변환됩니다. 기존에는 매번 수동으로 락을 획득하고 포맷팅 코드를 작성했다면, 이제는 단 한 줄로 동일한 작업을 수행할 수 있습니다.

매크로의 핵심 특징은 첫째, 위생적(hygienic)이어서 매크로 내부의 변수가 외부와 충돌하지 않는다는 점입니다. 둘째, 컴파일 타임에 확장되므로 런타임 오버헤드가 전혀 없습니다.

셋째, 패턴 매칭을 통해 여러 형태의 인자를 받을 수 있어 유연합니다. 이러한 특징들이 매크로를 강력하면서도 안전하게 만듭니다.

코드 예제

#[macro_export]
macro_rules! serial_print {
    ($($arg:tt)*) => {
        {
            use core::fmt::Write;
            $crate::serial::SERIAL.lock().write_fmt(format_args!($($arg)*)).unwrap();
        }
    };
}

#[macro_export]
macro_rules! serial_println {
    () => ($crate::serial_print!("\n"));
    ($fmt:expr) => ($crate::serial_print!(concat!($fmt, "\n")));
    ($fmt:expr, $($arg:tt)*) => ($crate::serial_print!(concat!($fmt, "\n"), $($arg)*));
}

// 사용 예시
serial_print!("Booting kernel... ");
serial_println!("done");
serial_println!("Memory: {} MB", memory_size);

설명

이것이 하는 일: 반복적인 락 획득과 포맷팅 코드를 매크로로 캡슐화하여, 사용자가 간단한 문법으로 복잡한 작업을 수행할 수 있게 합니다. 첫 번째로, serial_print!

매크로를 정의합니다. $($arg:tt)*는 "0개 이상의 토큰 트리를 받는다"는 의미로, 어떤 형태의 인자든 받을 수 있는 가장 유연한 패턴입니다.

tt는 token tree의 약자로, 괄호로 묶인 모든 것을 하나의 단위로 취급합니다. 매크로 본문에서 블록 { }으로 감싸는 이유는 use 선언을 매크로 내부로 한정시켜 외부 코드와 충돌을 방지하기 위함입니다.

$crate는 이 매크로가 정의된 크레이트를 가리키는 특수 변수로, 다른 크레이트에서 이 매크로를 사용할 때도 정확한 경로를 보장합니다. 그 다음으로, SERIAL.lock()으로 뮤텍스 가드를 획득하고, write_fmt 메서드에 format_args!

매크로를 전달합니다. format_args!는 컴파일 타임에 포맷 문자열을 분석하여 fmt::Arguments 타입을 생성하는데, 이는 write!

매크로의 핵심 구현입니다. $($arg)*는 매크로에 전달된 모든 인자를 그대로 format_args!에 전달하는 것으로, 이를 통해 "format string" 및 임의의 개수의 인자를 처리합니다.

unwrap()은 에러를 패닉으로 변환하는데, 시리얼 출력은 실패하지 않는다고 가정하므로 안전합니다. 마지막으로, serial_println!

매크로는 세 가지 패턴을 매칭합니다. 첫 번째 () => 패턴은 인자가 없을 때 단순히 줄바꿈만 출력합니다.

두 번째 ($fmt:expr) => 패턴은 포맷 문자열만 있을 때 concat! 매크로로 "\n"을 추가합니다.

concat!은 컴파일 타임에 문자열 리터럴을 연결하므로 런타임 오버헤드가 없습니다. 세 번째 ($fmt:expr, $($arg:tt)*) => 패턴은 포맷 문자열과 추가 인자가 있을 때를 처리하며, 마찬가지로 줄바꿈을 추가합니다.

여러분이 이 매크로를 사용하면 커널 코드가 훨씬 깔끔해집니다. serial_println!("IRQ {}: {}", irq_num, description) 같은 한 줄로 복잡한 출력을 처리할 수 있으며, 락 관리나 에러 처리를 신경 쓸 필요가 없습니다.

또한 #[macro_export] 속성 덕분에 이 매크로를 다른 모듈이나 크레이트에서도 사용할 수 있어, OS의 여러 부분에서 일관된 로깅 인터페이스를 제공합니다.

실전 팁

💡 디버그 정보를 더 풍부하게 하려면 serial_dbg! 매크로를 만들어 파일명과 줄 번호를 자동으로 포함시킬 수 있습니다. file!(), line!(), column!() 매크로를 활용하세요.

💡 조건부 컴파일을 활용해 릴리스 빌드에서는 일부 로그를 제거할 수 있습니다. #[cfg(debug_assertions)] 속성을 매크로 정의에 추가하거나, 매크로 내부에서 조건부로 출력하세요.

💡 로그 레벨(INFO, WARN, ERROR)을 구분하려면 각 레벨별 매크로를 만들고, 색상 코드(ANSI escape sequences)를 포함시켜 터미널에서 시각적으로 구분할 수 있습니다.

💡 성능 측정을 위해 serial_time! 매크로를 만들어 코드 블록 실행 시간을 자동으로 측정하고 출력할 수 있습니다. RDTSC 명령으로 CPU 사이클을 측정하세요.


6. 패닉 핸들러에 시리얼 출력 통합하기 - 크래시 디버깅의 핵심

시작하며

여러분의 커널이 패닉(panic)을 일으키면, 화면에 아무것도 표시되지 않거나 무한 리부트에 빠지는 경험을 해보셨을 것입니다. 이럴 때 정확히 어디서 무엇이 잘못되었는지 알 수 없다면, 디버깅은 거의 불가능합니다.

no_std 환경에서는 표준 라이브러리의 패닉 핸들러를 사용할 수 없으므로, 직접 #[panic_handler] 함수를 구현해야 합니다. 이 함수가 시리얼 포트로 패닉 메시지, 파일명, 줄 번호 등을 출력하면, 크래시 원인을 즉시 파악할 수 있습니다.

바로 이럴 때 필요한 것이 시리얼 출력을 활용한 패닉 핸들러입니다. 이는 OS 개발에서 가장 중요한 디버깅 인프라 중 하나입니다.

개요

간단히 말해서, 패닉 핸들러는 Rust 프로그램이 복구 불가능한 에러를 만났을 때 호출되는 특수 함수입니다. #[panic_handler] 속성을 가진 함수는 PanicInfo 구조체를 받아 패닉 메시지, 발생 위치 등의 정보에 접근할 수 있습니다.

예를 들어, 배열 인덱스 범위 초과나 unwrap() 실패 같은 상황에서 자동으로 호출됩니다. 기존에는 패닉이 발생하면 시스템이 멈추거나 리부트되어 원인을 알 수 없었다면, 이제는 상세한 에러 정보를 시리얼 포트로 전송하여 호스트 머신에서 확인할 수 있습니다.

패닉 핸들러의 핵심 특징은 첫째, 무한 루프나 시스템 정지 전에 마지막으로 실행되는 코드라는 점입니다. 둘째, 이 함수는 반환하지 않아야 하므로 !

(never type) 반환 타입을 가집니다. 셋째, 가능한 한 간단한 작업만 수행해야 추가 패닉을 방지할 수 있습니다.

이러한 특징들을 고려하여 신중하게 구현해야 합니다.

코드 예제

use core::panic::PanicInfo;

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

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

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

    serial_println!("================================");

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

설명

이것이 하는 일: 복구 불가능한 에러 발생 시 상세한 디버그 정보를 시리얼 포트로 출력하여, 개발자가 문제를 신속하게 진단할 수 있도록 합니다. 첫 번째로, #[panic_handler] 속성으로 panic 함수를 패닉 핸들러로 지정합니다.

이 함수는 &PanicInfo를 파라미터로 받으며, ! 반환 타입은 "이 함수는 절대 정상적으로 반환하지 않는다"는 의미입니다.

함수 시작 시 구분선과 함께 "[KERNEL PANIC]"을 출력하여 로그에서 패닉을 쉽게 식별할 수 있게 합니다. 구분선은 다른 일반 로그와 시각적으로 구분하는 데 도움이 됩니다.

그 다음으로, info.message()로 패닉 메시지를 추출합니다. 이는 Option<&fmt::Arguments>를 반환하므로 if let으로 값이 있을 때만 처리합니다.

예를 들어, panic!("Out of memory") 같은 명시적 패닉이나, unwrap() 실패 시의 자동 메시지가 여기에 포함됩니다. 메시지를 serial_println!으로 출력하여 무엇이 잘못되었는지 즉시 알 수 있습니다.

그리고 info.location()으로 패닉이 발생한 소스 코드 위치를 얻습니다. 이는 Option<&Location>을 반환하며, 파일명(file()), 줄 번호(line()), 컬럼 번호(column())를 제공합니다.

이 정보는 디버깅에 가장 중요한 부분으로, 정확히 어느 코드 라인에서 문제가 발생했는지 알려줍니다. 예를 들어 "Location: src/memory.rs:142:18"처럼 출력되면, memory.rs 파일의 142번째 줄, 18번째 컬럼에서 패닉이 발생했음을 알 수 있습니다.

마지막으로, 시스템을 안전하게 정지시킵니다. 무한 루프 내에서 hlt() 명령을 실행하여 CPU를 절전 모드로 전환합니다.

hlt는 다음 인터럽트가 발생할 때까지 CPU를 대기 상태로 만들어 전력 소비를 줄입니다. 단순한 무한 루프(loop {})는 CPU를 100% 사용하지만, hlt를 사용하면 거의 사용하지 않습니다.

여러분이 이 패닉 핸들러를 사용하면 개발 속도가 크게 향상됩니다. QEMU를 실행하다가 화면이 멈추면, 시리얼 로그 파일을 확인하여 즉시 문제를 파악할 수 있습니다.

또한 자동화된 테스트 환경에서는 시리얼 출력을 파싱하여 테스트 실패 여부를 판단할 수 있어, CI/CD 파이프라인에 통합하기도 쉽습니다.

실전 팁

💡 스택 트레이스를 출력하려면 스택 포인터를 따라가며 반환 주소를 추출하는 stack unwinding을 구현해야 하는데, 이는 복잡하지만 addr2line 같은 도구와 함께 사용하면 함수 호출 체인을 볼 수 있습니다.

💡 패닉 핸들러 내에서 추가 패닉이 발생하면 무한 재귀에 빠질 수 있으므로, static PANICKED 플래그를 두어 "double panic" 상황을 감지하고 즉시 정지하세요.

💡 색상 코드(ANSI escape sequences)를 사용해 패닉 메시지를 빨간색으로 표시하면 터미널에서 더욱 눈에 띕니다. "\x1b[31m"로 빨간색 시작, "\x1b[0m"로 리셋하세요.

💡 CPU 레지스터 상태(RAX, RBX, RSP 등)를 출력하면 더욱 상세한 디버깅이 가능합니다. 인라인 어셈블리로 레지스터 값을 읽어 출력하세요.


7. 테스트 프레임워크와 통합하기 - 자동화된 커널 테스팅

시작하며

여러분이 커널 코드를 수정할 때마다 수동으로 QEMU를 실행하고, 화면을 보며 제대로 작동하는지 확인하는 것은 시간이 많이 걸립니다. 특히 복잡한 기능일수록 모든 경로를 테스트하기 어렵습니다.

Rust의 테스트 프레임워크(#[test])는 no_std 환경에서도 사용할 수 있지만, 테스트 결과를 화면이 아닌 시리얼 포트로 출력해야 자동화된 테스트가 가능합니다. 또한 테스트 성공 시에는 QEMU를 자동으로 종료해야 CI 파이프라인에서 사용할 수 있습니다.

바로 이럴 때 필요한 것이 custom test framework와 시리얼 포트 기반 테스트 리포터입니다. 이를 통해 "cargo test"만 실행하면 모든 커널 테스트가 자동으로 실행되고 결과를 확인할 수 있습니다.

개요

간단히 말해서, custom test framework는 #[test] 함수들을 수집하고 실행하는 방식을 직접 제어할 수 있게 해줍니다. Cargo.toml에 test 프로필을 설정하고, 테스트 러너 함수를 지정하면, 모든 테스트 함수가 이 러너로 전달됩니다.

예를 들어, 각 테스트 실행 전후에 시리얼 포트로 상태를 보고하고, 모든 테스트가 끝나면 QEMU exit 장치로 종료할 수 있습니다. 기존에는 표준 테스트 프레임워크가 stdout에 출력했다면, 이제는 시리얼 포트로 출력하여 호스트 머신이 결과를 파싱할 수 있습니다.

테스트 프레임워크의 핵심 특징은 첫째, 테스트 성공/실패를 명확한 exit code로 구분하여 자동화 스크립트가 판단할 수 있다는 점입니다. 둘째, QEMU의 isa-debug-exit 장치를 사용하면 커널에서 직접 에뮬레이터를 종료할 수 있습니다.

셋째, 각 테스트가 독립적으로 실행되어 한 테스트의 실패가 다른 테스트에 영향을 주지 않습니다. 이러한 특징들이 신뢰할 수 있는 테스트 환경을 만듭니다.

코드 예제

// Cargo.toml에 추가
// [package.metadata.bootimage]
// test-args = ["-device", "isa-debug-exit,iobase=0xf4,iosize=0x04", "-serial", "stdio"]
// test-success-exit-code = 33

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

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u32)]
pub enum QemuExitCode {
    Success = 0x10, // QEMU에서 33으로 변환됨 (0x10 << 1 | 1)
    Failed = 0x11,
}

pub fn exit_qemu(exit_code: QemuExitCode) {
    unsafe {
        let mut port = x86_64::instructions::port::Port::new(0xf4);
        port.write(exit_code as u32);
    }
}

#[test_case]
fn test_serial_output() {
    serial_print!("test");
    assert_eq!(1 + 1, 2);
}

설명

이것이 하는 일: 모든 테스트 함수를 실행하고 결과를 시리얼 포트로 보고한 후, QEMU exit 장치로 에뮬레이터를 종료하여 호스트가 성공/실패를 판단할 수 있게 합니다. 첫 번째로, Cargo.toml의 [package.metadata.bootimage] 섹션에서 QEMU 실행 옵션을 지정합니다.

-device isa-debug-exit,iobase=0xf4,iosize=0x04는 I/O 포트 0xf4에 쓰면 QEMU가 종료되는 특수 장치를 추가합니다. iobase는 장치의 주소, iosize는 레지스터 크기입니다.

-serial stdio는 시리얼 출력을 표준 출력으로 리디렉션하여 터미널에서 바로 볼 수 있게 합니다. test-success-exit-code = 33은 QEMU exit 장치가 반환하는 값을 성공으로 해석하도록 지정하는데, 이 장치는 (value << 1) | 1을 반환하므로 0x10을 쓰면 33이 됩니다.

그 다음으로, test_runner 함수를 구현합니다. 이 함수는 &[&dyn Fn()] 슬라이스를 받는데, 이는 모든 #[test_case] 함수들의 참조 배열입니다.

dyn Fn()은 트레이트 객체로, 서로 다른 타입의 테스트 함수들을 같은 배열에 담을 수 있게 합니다. 먼저 총 테스트 개수를 출력하고, 반복문으로 각 테스트를 실행합니다.

type_name_of_val로 테스트 함수의 이름을 얻어 출력하는데, 이는 어느 테스트가 실행 중인지 보여줍니다. 테스트 함수를 호출한 후 "[ok]"를 출력합니다.

만약 테스트 내에서 assert 실패로 패닉이 발생하면, 패닉 핸들러가 호출되어 에러를 출력하고 시스템이 정지합니다. 마지막으로, 모든 테스트가 성공하면 exit_qemu 함수로 QEMU를 종료합니다.

QemuExitCode enum은 #[repr(u32)]로 메모리 표현을 지정하여 u32 값으로 직접 변환할 수 있게 합니다. exit_qemu 함수는 I/O 포트 0xf4에 exit code를 씁니다.

QEMU의 isa-debug-exit 장치가 이 값을 받아 (exit_code << 1) | 1을 호스트 프로세스의 exit code로 반환합니다. 따라서 Success(0x10)를 쓰면 33이 반환되고, Cargo는 이를 성공으로 판단합니다.

여러분이 이 테스트 프레임워크를 사용하면 "cargo test" 명령 하나로 모든 커널 유닛 테스트를 실행할 수 있습니다. CI 서버에서는 이 명령의 exit code를 확인하여 빌드 성공/실패를 판단합니다.

또한 시리얼 출력을 파일로 저장하면 어느 테스트가 얼마나 걸렸는지, 어디서 실패했는지 상세히 분석할 수 있습니다.

실전 팁

💡 통합 테스트(integration tests)를 위해 tests/ 디렉토리를 만들고 각 파일마다 독립적인 커널 바이너리를 빌드하면, 서로 다른 시나리오를 격리하여 테스트할 수 있습니다.

💡 테스트 실행 시간을 측정하려면 RDTSC 명령으로 각 테스트 전후의 CPU 사이클을 측정하고 차이를 출력하세요. 성능 회귀를 조기에 발견할 수 있습니다.

💡 should_panic 같은 동작을 구현하려면 custom test runner에서 패닉을 catch하는 메커니즘이 필요합니다. 각 테스트를 별도 태스크로 실행하고 패닉을 감지하는 방식으로 구현하세요.

💡 커버리지 측정을 위해 Rust의 instrument-coverage 기능을 사용하면 어느 코드가 테스트되었는지 확인할 수 있지만, OS 환경에서는 추가 설정이 필요합니다.


8. 로그 레벨과 포맷팅 고도화 - 프로덕션 수준의 로깅

시작하며

여러분의 OS가 복잡해지면서 출력되는 로그가 수백 줄이 되면, 중요한 에러 메시지가 일반 디버그 메시지에 묻혀 버립니다. 예를 들어, 페이지 폴트가 발생했는데 수많은 "Page allocated" 메시지 사이에서 찾기 어려운 경우가 생깁니다.

실제 운영체제나 대규모 소프트웨어에서는 로그 레벨(TRACE, DEBUG, INFO, WARN, ERROR)을 구분하여, 필요한 정보만 필터링할 수 있습니다. 또한 타임스탬프, CPU 코어 번호, 컨텍스트 정보를 함께 출력하면 디버깅이 훨씬 효율적입니다.

바로 이럴 때 필요한 것이 구조화된 로깅 시스템입니다. 로그 레벨별 매크로와 포맷팅 옵션을 추가하여 프로덕션 환경에서도 사용 가능한 수준으로 발전시켜 봅시다.

개요

간단히 말해서, 로그 레벨 시스템은 메시지의 중요도를 분류하고, 런타임에 필터링할 수 있게 하는 구조입니다. 일반적으로 TRACE(매우 상세한 추적), DEBUG(개발용 정보), INFO(일반 정보), WARN(경고), ERROR(에러) 5단계로 나눕니다.

예를 들어, 릴리스 빌드에서는 INFO 이상만 출력하고, 디버그 빌드에서는 모든 레벨을 출력하도록 설정할 수 있습니다. 기존에는 모든 메시지가 동일한 우선순위로 출력되었다면, 이제는 중요한 메시지를 강조하고 불필요한 메시지를 억제할 수 있습니다.

로그 레벨 시스템의 핵심 특징은 첫째, 컴파일 타임에 조건부로 코드를 제거하여 릴리스 빌드의 성능을 유지할 수 있다는 점입니다. 둘째, 런타임에 동적으로 레벨을 조정하여 상황에 맞게 상세도를 제어할 수 있습니다.

셋째, 일관된 포맷으로 로그를 파싱하여 자동화된 분석 도구와 통합할 수 있습니다. 이러한 특징들이 전문적인 로깅 인프라를 만듭니다.

코드 예제

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
#[repr(u8)]
pub enum LogLevel {
    Trace = 0,
    Debug = 1,
    Info = 2,
    Warn = 3,
    Error = 4,
}

impl LogLevel {
    pub fn as_str(&self) -> &'static str {
        match self {
            LogLevel::Trace => "TRACE",
            LogLevel::Debug => "DEBUG",
            LogLevel::Info => "INFO ",
            LogLevel::Warn => "WARN ",
            LogLevel::Error => "ERROR",
        }
    }

    pub fn color_code(&self) -> &'static str {
        match self {
            LogLevel::Trace => "\x1b[90m", // 회색
            LogLevel::Debug => "\x1b[36m", // 청록색
            LogLevel::Info => "\x1b[32m",  // 녹색
            LogLevel::Warn => "\x1b[33m",  // 노란색
            LogLevel::Error => "\x1b[31m", // 빨간색
        }
    }
}

#[macro_export]
macro_rules! log {
    ($level:expr, $($arg:tt)*) => {
        if $level >= $crate::log::MIN_LOG_LEVEL {
            use core::fmt::Write;
            let level = $level;
            $crate::serial::SERIAL.lock().write_fmt(format_args!(
                "{}[{}]\x1b[0m {}\n",
                level.color_code(),
                level.as_str(),
                format_args!($($arg)*)
            )).unwrap();
        }
    };
}

#[macro_export]
macro_rules! trace { ($($arg:tt)*) => (log!(LogLevel::Trace, $($arg)*)) }
#[macro_export]
macro_rules! debug { ($($arg:tt)*) => (log!(LogLevel::Debug, $($arg)*)) }
#[macro_export]
macro_rules! info { ($($arg:tt)*) => (log!(LogLevel::Info, $($arg)*)) }
#[macro_export]
macro_rules! warn { ($($arg:tt)*) => (log!(LogLevel::Warn, $($arg)*)) }
#[macro_export]
macro_rules! error { ($($arg:tt)*) => (log!(LogLevel::Error, $($arg)*)) }

// 사용 예시
info!("Kernel initialized");
warn!("Low memory: {} KB remaining", free_memory);
error!("Page fault at address {:#x}", fault_addr);

설명

이것이 하는 일: 메시지를 5단계 레벨로 분류하고, 최소 레벨 이상만 출력하며, ANSI 색상 코드로 터미널에서 시각적으로 구분하여 중요한 정보를 빠르게 파악할 수 있게 합니다. 첫 번째로, LogLevel enum을 정의합니다.

#[repr(u8)]로 각 레벨을 숫자로 표현하고, PartialOrd와 Ord를 derive하여 레벨 간 비교가 가능하게 합니다. 이를 통해 level >= MIN_LOG_LEVEL 같은 조건문을 사용할 수 있습니다.

as_str 메서드는 레벨을 문자열로 변환하는데, 공백을 추가하여 5글자로 맞춰 로그가 정렬되어 보이게 합니다. color_code 메서드는 ANSI escape sequence를 반환하여, 터미널에서 각 레벨을 다른 색으로 표시합니다.

그 다음으로, log! 매크로를 구현합니다.

첫 번째 인자로 LogLevel을 받고, 나머지는 포맷 인자입니다. if 조건문으로 MIN_LOG_LEVEL과 비교하여, 설정된 최소 레벨보다 낮은 메시지는 컴파일되지만 실행되지 않습니다.

이는 런타임 필터링입니다. 만약 컴파일 타임 필터링을 원하면 #[cfg] 속성을 사용하세요.

format_args!를 중첩하여 색상 코드, 레벨 문자열, 실제 메시지를 조합합니다. "\x1b[0m"는 색상 리셋 코드로, 이후 텍스트가 영향받지 않도록 합니다.

마지막으로, 각 레벨별 편의 매크로를 정의합니다. trace!, debug!, info!, warn!, error!는 모두 log!

매크로를 호출하되 첫 번째 인자로 해당 레벨을 전달합니다. 이렇게 하면 사용자는 간단히 error!("message")처럼 호출할 수 있습니다.

매크로 확장은 컴파일 타임에 일어나므로 런타임 오버헤드가 없습니다. 여러분이 이 로깅 시스템을 사용하면 터미널에서 에러는 빨간색, 경고는 노란색으로 즉시 눈에 띄어 문제를 빠르게 발견할 수 있습니다.

또한 MIN_LOG_LEVEL을 조정하여 개발 중에는 모든 로그를 보고, 릴리스에서는 경고와 에러만 보도록 설정할 수 있습니다. 로그 파일을 분석 도구로 파싱할 때도 "[ERROR]" 같은 일관된 형식 덕분에 grep이나 정규식으로 쉽게 필터링할 수 있습니다.

실전 팁

💡 타임스탬프를 추가하려면 TSC(Time Stamp Counter)나 PIT(Programmable Interval Timer)를 사용해 부팅 후 경과 시간을 측정하고 로그 앞에 출력하세요. "[12.345] [INFO]" 형식으로 시간 흐름을 파악할 수 있습니다.

💡 멀티코어 환경에서는 현재 CPU ID를 로그에 포함시키면 어느 코어에서 실행 중인지 알 수 있습니다. CPUID 명령이나 LAPIC ID를 사용하세요.

💡 로그를 메모리 링 버퍼에 저장하고 나중에 일괄 출력하면 시리얼 I/O의 성능 영향을 줄일 수 있습니다. 특히 인터럽트 핸들러에서 유용합니다.

💡 파일명과 줄 번호를 자동으로 포함하려면 log! 매크로 내에서 file!(), line!() 매크로를 사용하세요. 디버그 빌드에서만 포함시키면 릴리스 빌드의 바이너리 크기를 줄일 수 있습니다.


9. 시리얼 입력 구현하기 - 양방향 통신으로 확장

시작하며

여러분이 지금까지 시리얼 포트로 출력만 했다면, 이제 입력도 받아보는 것은 어떨까요? 예를 들어, 커널 디버거에서 브레이크포인트를 설정하거나, 셸에서 명령어를 입력받거나, 네트워크 드라이버가 준비되기 전에 간단한 제어 명령을 받을 수 있습니다.

시리얼 입력은 출력보다 조금 더 복잡한데, 인터럽트 기반으로 구현하는 것이 효율적입니다. 폴링 방식으로 계속 확인하는 것은 CPU를 낭비하므로, 데이터가 도착했을 때만 인터럽트 핸들러가 호출되도록 설정해야 합니다.

바로 이럴 때 필요한 것이 UART 수신 인터럽트와 입력 버퍼입니다. 인터럽트 핸들러에서 데이터를 읽어 버퍼에 저장하고, 커널의 다른 부분에서 이 버퍼를 읽어 처리하는 구조를 만들어 봅시다.

개요

간단히 말해서, 시리얼 입력은 UART 수신 레지스터에서 바이트를 읽어 링 버퍼에 저장하는 과정입니다. UART의 RDR(Receive Data Register, 오프셋 +0)에서 읽으면 수신된 바이트를 얻을 수 있으며, LSR(Line Status Register, 오프셋 +5)의 비트 0으로 데이터가 준비되었는지 확인합니다.

예를 들어, 호스트에서 "debug\n"을 입력하면, 각 문자가 순차적으로 UART를 통해 전송되어 커널이 읽을 수 있습니다. 기존에는 출력만 가능했다면, 이제는 양방향 통신으로 커널과 상호작용할 수 있습니다.

시리얼 입력의 핵심 특징은 첫째, 인터럽트 기반으로 구현하면 데이터가 도착할 때만 처리하여 CPU 효율이 높다는 점입니다. 둘째, 링 버퍼를 사용하면 인터럽트 핸들러와 소비자 코드 간 동기화를 간단하게 할 수 있습니다.

셋째, 입력 속도가 처리 속도보다 빠르면 버퍼 오버플로우가 발생할 수 있으므로 적절한 크기 설정이 필요합니다. 이러한 특징들을 고려하여 안정적인 입력 시스템을 구축해야 합니다.

코드 예제

use x86_64::structures::idt::InterruptStackFrame;

// 링 버퍼 (간단한 구현)
static INPUT_BUFFER: Mutex<[u8; 256]> = Mutex::new([0; 256]);
static mut BUFFER_HEAD: usize = 0;
static mut BUFFER_TAIL: usize = 0;

// 시리얼 수신 인터럽트 활성화
pub fn enable_serial_interrupt() {
    unsafe {
        let mut port = x86_64::instructions::port::Port::<u8>::new;
        // IER: 수신 인터럽트 활성화 (비트 0)
        port(SERIAL_PORT + 1).write(0x01);
    }
}

// 인터럽트 핸들러
pub extern "x86-interrupt" fn serial_interrupt_handler(
    _stack_frame: InterruptStackFrame
) {
    unsafe {
        let mut port = x86_64::instructions::port::Port::<u8>::new;
        // LSR 비트 0: 데이터 준비됨
        while (port(SERIAL_PORT + 5).read() & 0x01) != 0 {
            let byte = port(SERIAL_PORT).read();
            // 버퍼에 저장
            INPUT_BUFFER.lock()[BUFFER_HEAD] = byte;
            BUFFER_HEAD = (BUFFER_HEAD + 1) % 256;
        }
    }
    // EOI 전송
    unsafe {
        x86_64::instructions::port::Port::<u8>::new(0x20).write(0x20);
    }
}

// 입력 읽기 함수
pub fn read_byte() -> Option<u8> {
    unsafe {
        if BUFFER_TAIL != BUFFER_HEAD {
            let byte = INPUT_BUFFER.lock()[BUFFER_TAIL];
            BUFFER_TAIL = (BUFFER_TAIL + 1) % 256;
            Some(byte)
        } else {
            None
        }
    }
}

설명

이것이 하는 일: 시리얼 포트에 데이터가 도착하면 자동으로 인터럽트가 발생하고, 핸들러가 데이터를 읽어 버퍼에 저장하며, 커널의 다른 부분에서 이 버퍼를 읽어 처리할 수 있게 합니다. 첫 번째로, 256바이트 링 버퍼와 head/tail 포인터를 정의합니다.

링 버퍼는 고정 크기 배열을 원형으로 사용하는 구조로, head는 다음 쓰기 위치, tail은 다음 읽기 위치를 가리킵니다. Mutex로 버퍼를 감싸 멀티스레드 환경에서도 안전하게 접근할 수 있습니다.

static mut는 unsafe하지만, head/tail은 인터럽트 핸들러에서만 쓰고 read_byte에서만 읽으므로 주의해서 사용하면 안전합니다. 더 안전한 방법은 AtomicUsize를 사용하는 것입니다.

그 다음으로, enable_serial_interrupt 함수에서 IER(Interrupt Enable Register, 오프셋 +1)에 0x01을 씁니다. 비트 0은 "수신 데이터 가능 인터럽트"를 활성화하는 것으로, 이후 UART에 데이터가 도착할 때마다 IRQ 4(COM1의 경우)가 발생합니다.

이 IRQ를 IDT(Interrupt Descriptor Table)에 등록하여 serial_interrupt_handler가 호출되도록 설정해야 합니다. 인터럽트 핸들러에서는 LSR 레지스터를 확인하여 데이터가 준비되었는지 체크합니다(비트 0).

while 루프로 버퍼에 여러 바이트가 쌓여 있을 수 있으므로 모두 읽습니다. 각 바이트를 RDR에서 읽어 링 버퍼의 head 위치에 저장하고, head를 1 증가시킵니다.

% 256으로 0-255 범위로 순환하게 합니다. 모든 데이터를 처리한 후 0x20 포트(PIC의 명령 레지스터)에 0x20(EOI, End Of Interrupt)를 써서 인터럽트 컨트롤러에게 처리 완료를 알립니다.

마지막으로, read_byte 함수는 버퍼에서 데이터를 읽습니다. tail과 head가 같으면 버퍼가 비어있으므로 None을 반환합니다.

그렇지 않으면 tail 위치의 바이트를 읽고 tail을 증가시킨 후 Some(byte)를 반환합니다. 이 함수는 논블로킹(non-blocking)으로 작동하여, 데이터가 없으면 즉시 반환하고 호출자가 나중에 재시도할 수 있습니다.

여러분이 이 시스템을 사용하면 QEMU에서 키보드 입력을 커널로 전달할 수 있습니다. -serial mon:stdio 옵션을 사용하면 QEMU 모니터 명령과 시리얼 입력을 모두 처리할 수 있습니다.

간단한 셸이나 디버거를 만들어 명령어를 입력받고 처리하는 대화형 커널을 구축하는 첫 단계가 됩니다.

실전 팁

💡 Ctrl+C 같은 특수 키 조합을 처리하려면 특정 바이트 값(0x03)을 감지하여 특별한 동작을 수행하도록 인터럽트 핸들러를 수정하세요.

💡 버퍼 오버플로우를 방지하려면 head가 tail을 따라잡았는지 확인하고, 그렇다면 새 데이터를 버리거나 경고를 출력하세요. (BUFFER_HEAD + 1) % 256 == BUFFER_TAIL 조건으로 체크합니다.

💡 더 효율적인 링 버퍼 구현을 위해 crossbeam-queue의 ArrayQueue 같은 lock-free 자료구조를 사용하면 멀티코어 환경에서 성능이 향상됩니다.

💡 입력을 줄 단위로 처리하려면 '\n' 또는 '\r' 문자를 감지하여 줄 버퍼에 모은 후, 완성된 줄을 커널에 전달하는 라인 디시플린(line discipline) 레이어를 추가하세요.


10. 실전 디버깅 워크플로우 - 시리얼 포트 기반 개발 환경

시작하며

여러분이 지금까지 배운 모든 기술을 실제 OS 개발 워크플로우에 어떻게 통합할까요? 코드를 작성하고, 빌드하고, 실행하고, 로그를 분석하는 전체 과정을 효율적으로 만들어야 생산성이 높아집니다.

예를 들어, 메모리 할당 버그를 추적할 때 관련 로그만 필터링하거나, 특정 함수 호출 시점의 스택 상태를 출력하거나, 반복 테스트를 자동화하는 등의 작업이 필요합니다. 시리얼 포트 로그를 효과적으로 활용하는 방법을 알아야 합니다.

바로 이럴 때 필요한 것이 체계적인 디버깅 워크플로우입니다. QEMU 설정, 로그 관리, 디버거 통합 등의 모범 사례를 익혀 전문가 수준의 개발 환경을 구축해 봅시다.

개요

간단히 말해서, 디버깅 워크플로우는 코드 작성부터 버그 발견, 분석, 수정까지의 반복 과정을 최적화하는 방법론입니다. 시리얼 포트 기반 워크플로우에서는 QEMU의 시리얼 출력을 파일로 저장하고, grep이나 awk로 특정 패턴을 검색하며, GDB를 통해 중단점을 설정하고, 스크립트로 반복 테스트를 자동화합니다.

예를 들어, "cargo run > serial.log 2>&1"로 실행하고, "grep ERROR serial.log"로 에러만 추출할 수 있습니다. 기존에는 매번 수동으로 QEMU를 실행하고 터미널을 확인했다면, 이제는 자동화된 스크립트와 도구로 빠르게 반복할 수 있습니다.

디버깅 워크플로우의 핵심 특징은 첫째, 재현 가능한 환경을 만들어 버그를 일관되게 재현할 수 있다는 점입니다. 둘째, 로그 중심 접근으로 사후 분석이 가능하여 일시적인 버그도 추적할 수 있습니다.

셋째, 자동화를 통해 회귀 테스트를 빠르게 수행하여 새로운 버그 유입을 방지합니다. 이러한 특징들이 안정적인 OS 개발을 가능하게 합니다.

코드 예제

# Makefile 예시 - 반복 작업 자동화
.PHONY: run debug test clean

# 일반 실행 (시리얼 출력을 파일로 저장)
run:
	cargo bootimage
	qemu-system-x86_64 -drive format=raw,file=target/x86_64-os/debug/bootimage-os.bin \
		-serial file:serial.log \
		-display none

# 디버깅 모드 (GDB 대기)
debug:
	cargo bootimage
	qemu-system-x86_64 -drive format=raw,file=target/x86_64-os/debug/bootimage-os.bin \
		-serial stdio \
		-s -S

# 테스트 실행
test:
	cargo test --no-fail-fast 2>&1 | tee test-results.log

# 로그 분석 스크립트
analyze-log:
	@echo "=== Errors ==="
	@grep -i error serial.log || echo "No errors found"
	@echo "\n=== Warnings ==="
	@grep -i warn serial.log || echo "No warnings found"
	@echo "\n=== Panics ==="
	@grep -i panic serial.log || echo "No panics found"

# 반복 테스트 (10번 실행하여 불안정한 버그 찾기)
stress-test:
	@for i in {1..10}; do \
		echo "Run $$i/10..."; \
		cargo test > test-$$i.log 2>&1 || exit 1; \
	done
	@echo "All runs passed!"

clean:
	cargo clean
	rm -f serial.log test-*.log

설명

이것이 하는 일: 반복적인 개발 작업을 명령어 하나로 실행할 수 있게 하고, 로그를 구조적으로 분석하며, 회귀 테스트를 자동화하여 버그를 조기에 발견합니다. 첫 번째로, run 타겟은 커널을 빌드하고 QEMU를 실행하되, 시리얼 출력을 serial.log 파일로 리디렉션합니다.

-display none으로 그래픽 창을 띄우지 않아 헤드리스(headless) 환경에서도 실행할 수 있습니다. 이는 CI 서버에서 유용합니다.

실행이 끝나면 serial.log를 열어 전체 로그를 분석할 수 있습니다. debug 타겟은 QEMU를 GDB 대기 모드로 실행합니다.

-s 옵션은 TCP 포트 1234에서 GDB 연결을 대기하고, -S 옵션은 CPU를 일시 정지 상태로 시작합니다. 별도 터미널에서 gdb target/x86_64-os/debug/os를 실행하고 target remote :1234로 연결한 후, 중단점을 설정하고 단계별로 실행할 수 있습니다.

시리얼 출력은 -serial stdio로 터미널에 직접 표시됩니다. test 타겟은 모든 테스트를 실행하고 결과를 test-results.log에 저장합니다.

--no-fail-fast 옵션으로 한 테스트가 실패해도 계속 진행하여 전체 테스트 상황을 파악할 수 있습니다. tee 명령으로 터미널에도 출력하면서 파일에도 저장합니다.

analyze-log 타겟은 grep으로 로그에서 error, warn, panic 키워드를 검색합니다. -i 옵션으로 대소문자를 구분하지 않으며, || echo로 결과가 없을 때 메시지를 출력합니다.

이 스크립트를 실행하면 수백 줄의 로그에서 중요한 메시지만 빠르게 추출할 수 있습니다. stress-test 타겟은 for 루프로 테스트를 10번 반복 실행합니다.

타이밍에 따라 간헐적으로 발생하는 레이스 컨디션이나 초기화 버그를 찾는 데 유용합니다. 각 실행의 로그를 별도 파일로 저장하여 실패 시 비교 분석할 수 있습니다.

|| exit 1로 실패 시 즉시 중단합니다. 여러분이 이 워크플로우를 사용하면 "make run && make analyze-log" 한 줄로 빌드, 실행, 분석을 모두 수행할 수 있습니다.

버그 수정 후 "make test"로 전체 테스트를 돌려 회귀를 확인하고, 의심스러운 부분은 "make debug"로 GDB를 연결하여 심층 분석할 수 있습니다. 이러한 자동화는 개발 속도를 몇 배 향상시키고, 버그 수정의 확신을 높여줍니다.

실전 팁

💡 CI/CD 통합을 위해 GitHub Actions나 GitLab CI에서 "make test"를 실행하고, 테스트 로그를 아티팩트로 업로드하면 모든 커밋이 자동으로 검증됩니다.

💡 addr2line 도구를 사용해 패닉 메시지의 주소를 소스 코드 위치로 변환하는 스크립트를 만들면, 최적화된 빌드에서도 정확한 에러 위치를 찾을 수 있습니다.

💡 tmux나 screen으로 터미널을 분할하여 한쪽에서 QEMU를 실행하고 다른 쪽에서 실시간으로 로그를 tail -f로 모니터링하면 즉각적인 피드백을 받을 수 있습니다.

💡 Rust의 cargo-watch 도구를 사용하면 파일 변경 시 자동으로 재빌드하고 테스트를 실행하여, 수정 후 즉시 결과를 확인할 수 있는 TDD(Test-Driven Development) 환경을 구축할 수 있습니다.


#Rust#OS개발#시리얼포트#UART#베어메탈#시스템프로그래밍

댓글 (0)

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