이미지 로딩 중...
AI Generated
2025. 11. 14. · 5 Views
Rust로 만드는 나만의 OS panic handler 구현
OS 커널 개발에서 패닉 상황을 어떻게 처리할까요? Rust의 panic handler를 직접 구현하여 커널 레벨에서 안전하게 에러를 처리하는 방법을 배워봅니다. no_std 환경에서 panic_handler 속성 매크로를 활용하여 커스텀 패닉 핸들러를 만들어봅시다.
목차
- panic_handler란 무엇인가 - no_std 환경의 필수 요소
- PanicInfo 타입 활용 - panic 상세 정보 추출하기
- diverging function (!) - 절대 반환하지 않는 함수
- VGA 텍스트 모드 출력 - panic 메시지 화면에 표시하기
- 시리얼 포트 출력 - QEMU와 디버깅의 핵심 도구
- no_std 환경 설정 - 표준 라이브러리 없이 개발하기
- 커스텀 panic 메시지 포맷팅 - 더 나은 디버깅 정보
- 무한 루프와 hlt 명령 - 효율적인 시스템 정지
- 테스트 프레임워크와 panic 처리 - 자동화된 테스트
- QemuExitCode와 통합 테스트 - 종료 코드 관리
1. panic_handler란 무엇인가 - no_std 환경의 필수 요소
시작하며
여러분이 OS 커널을 개발하면서 예기치 않은 에러가 발생했을 때, 표준 라이브러리 없이 어떻게 처리할까요? 일반적인 Rust 프로그램에서는 panic!이 호출되면 표준 라이브러리가 자동으로 처리해주지만, 커널 개발 환경(no_std)에서는 이런 기본 기능조차 없습니다.
이런 문제는 베어메탈 프로그래밍에서 항상 발생합니다. 표준 라이브러리가 없다는 것은 panic 발생 시 스택 언와인딩, 에러 메시지 출력, 프로그램 종료 등 모든 것을 직접 구현해야 한다는 의미입니다.
이를 처리하지 않으면 컴파일조차 되지 않습니다. 바로 이럴 때 필요한 것이 panic_handler입니다.
이는 Rust 컴파일러에게 "패닉이 발생했을 때 이 함수를 호출하세요"라고 알려주는 특별한 속성입니다.
개요
간단히 말해서, panic_handler는 no_std 환경에서 panic 발생 시 호출될 함수를 지정하는 속성 매크로입니다. 표준 라이브러리가 없는 환경에서 Rust 컴파일러는 panic 처리 방법을 알 수 없기 때문에, 개발자가 직접 panic_handler를 구현해야 합니다.
OS 커널 개발, 임베디드 시스템, 부트로더 제작 등 베어메탈 환경에서 필수적으로 구현해야 하는 요소입니다. 전통적인 방법과의 비교를 해보면, 일반 Rust 프로그램에서는 std 라이브러리가 기본 panic 핸들러를 제공했다면, no_std 환경에서는 개발자가 #[panic_handler] 속성을 가진 함수를 직접 정의해야 합니다.
이 개념의 핵심 특징은 첫째, 프로젝트 내에 정확히 하나만 존재해야 하고, 둘째, PanicInfo 타입의 매개변수를 받아야 하며, 셋째, 절대 반환하지 않는(diverging) 함수여야 한다는 점입니다. 이러한 특징들이 컴파일러가 panic 발생 시 안전하게 제어 흐름을 관리할 수 있게 해줍니다.
코드 예제
use core::panic::PanicInfo;
// panic 발생 시 호출될 함수 정의
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
// panic 메시지와 위치 정보 출력
println!("[PANIC] {}", info.message());
// 파일명과 라인 정보가 있다면 출력
if let Some(location) = info.location() {
println!("at {}:{}", location.file(), location.line());
}
// 무한 루프로 시스템 정지
loop {}
}
설명
이것이 하는 일은 Rust 컴파일러에게 "패닉 상황이 발생하면 이 함수를 실행하세요"라고 알려주는 것입니다. 표준 라이브러리 없이 동작하는 커널에서는 이런 기본적인 에러 처리조차 직접 구현해야 합니다.
첫 번째로, #[panic_handler] 속성을 함수에 부착하면 컴파일러가 이 함수를 특별하게 인식합니다. 컴파일 시점에 이 함수의 주소가 panic 핸들러 테이블에 등록되며, 런타임에 panic!이 호출되면 자동으로 이 함수가 실행됩니다.
PanicInfo 타입의 매개변수를 통해 panic이 발생한 위치, 메시지, 추가 정보 등을 받을 수 있습니다. 두 번째로, 함수 본문에서 info.message()를 호출하여 panic 메시지를 가져오고, info.location()으로 파일명과 라인 번호를 얻을 수 있습니다.
이 정보들을 VGA 버퍼나 시리얼 포트로 출력하여 디버깅에 활용할 수 있습니다. 커널 개발에서는 이런 정보가 매우 중요한데, 표준 디버거를 사용할 수 없는 환경이기 때문입니다.
세 번째로, 반환 타입이 ! (never type)인 이유는 panic 후에는 정상적인 실행 흐름으로 돌아갈 수 없기 때문입니다.
일반적으로 무한 루프(loop {})나 시스템 중지 명령(hlt)을 사용하여 시스템을 정지시킵니다. 이는 불안정한 상태에서 계속 실행되는 것을 방지합니다.
여러분이 이 코드를 사용하면 커널에서 발생하는 모든 panic을 중앙에서 일관되게 처리할 수 있습니다. 디버깅 정보를 수집하고, 시스템 상태를 로깅하며, 안전하게 시스템을 정지시킬 수 있어 커널 개발의 안정성이 크게 향상됩니다.
실전 팁
💡 panic_handler는 프로젝트 내에 정확히 하나만 존재해야 합니다. 여러 개 정의하면 링커 에러가 발생하니, 라이브러리를 만들 때는 cfg 속성으로 조건부 컴파일을 활용하세요.
💡 PanicInfo의 정보를 최대한 활용하세요. location(), message(), payload() 등을 통해 디버깅에 필요한 모든 정보를 얻을 수 있습니다.
💡 panic 후 무한 루프 대신 CPU halt 명령(x86: hlt)을 사용하면 전력 소비를 줄일 수 있습니다. loop { x86_64::instructions::hlt(); } 형태로 사용하세요.
💡 개발 초기에는 간단한 핸들러로 시작하되, 나중에 스택 트레이스, 레지스터 덤프, 메모리 상태 저장 등의 기능을 추가하여 강력한 디버깅 도구로 발전시키세요.
💡 QEMU나 실제 하드웨어에서 테스트할 때, 시리얼 포트로 panic 정보를 출력하면 화면 없이도 디버깅할 수 있어 매우 유용합니다.
2. PanicInfo 타입 활용 - panic 상세 정보 추출하기
시작하며
여러분이 커널 개발 중 panic이 발생했을 때, "어디서 왜 발생했는지" 정확히 알고 싶지 않으신가요? 단순히 "panic 발생!"만 출력하는 것은 디버깅에 전혀 도움이 되지 않습니다.
수백, 수천 줄의 커널 코드에서 문제의 원인을 찾는 것은 마치 건초더미에서 바늘 찾기와 같습니다. 이런 문제는 베어메탈 환경에서 특히 심각합니다.
일반 애플리케이션처럼 gdb를 붙이거나 로그 파일을 확인할 수 없기 때문입니다. panic 발생 순간에 수집할 수 있는 정보가 디버깅의 전부가 되는 경우가 많습니다.
바로 이럴 때 필요한 것이 PanicInfo 타입입니다. 이는 panic 발생 시점의 모든 컨텍스트 정보를 담고 있어, 효과적인 디버깅을 가능하게 합니다.
개요
간단히 말해서, PanicInfo는 panic이 발생했을 때의 상세 정보를 담고 있는 구조체입니다. 이 타입은 core::panic 모듈에 정의되어 있으며, panic 메시지, 발생 위치(파일명, 라인, 컬럼), 그리고 추가 payload 정보를 제공합니다.
panic_handler 함수의 매개변수로 자동으로 전달되며, 개발자는 이 정보를 활용하여 상세한 에러 리포트를 생성할 수 있습니다. 특히 OS 개발에서는 이 정보를 시리얼 포트나 화면에 출력하여 디버깅의 핵심 도구로 활용합니다.
기존에는 단순히 "에러 발생"만 알 수 있었다면, 이제는 정확한 파일명, 라인 번호, 에러 메시지까지 모두 확인할 수 있습니다. 이 타입의 핵심 특징은 첫째, message() 메서드로 panic 메시지에 접근하고, 둘째, location() 메서드로 Option<&Location>을 반환하여 소스 위치 정보를 얻으며, 셋째, payload() 메서드로 Any 타입의 추가 데이터에 접근할 수 있다는 점입니다.
이러한 정보들이 조합되면 panic 원인을 빠르게 파악할 수 있습니다.
코드 예제
use core::panic::PanicInfo;
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
// panic 메시지 추출 및 출력
let message = info.message();
serial_println!("[KERNEL PANIC]");
serial_println!("Message: {}", message);
// 위치 정보 상세 출력
if let Some(location) = info.location() {
serial_println!("Location: {}:{}:{}",
location.file(),
location.line(),
location.column()
);
} else {
serial_println!("Location: unavailable");
}
// 시스템 정지
hlt_loop();
}
설명
이것이 하는 일은 panic 발생 시점의 모든 컨텍스트를 구조화된 형태로 제공하는 것입니다. Rust 컴파일러가 panic!
매크로가 호출될 때 자동으로 이 정보를 수집하여 PanicInfo 인스턴스로 전달합니다. 첫 번째로, message() 메서드는 panic!
매크로에 전달된 포맷팅된 메시지를 반환합니다. 예를 들어 panic!("Invalid page table entry: {:#x}", addr)와 같이 호출했다면, 포맷팅이 완료된 최종 문자열을 얻을 수 있습니다.
이는 Arguments 타입으로 반환되며, Display 트레잇을 구현하고 있어 println!이나 write! 매크로와 함께 사용할 수 있습니다.
두 번째로, location() 메서드는 Option<&Location> 타입을 반환합니다. Location 구조체는 file(), line(), column() 메서드를 제공하여 정확한 소스 코드 위치를 알려줍니다.
일부 최적화 상황에서는 위치 정보가 제거될 수 있어 Option으로 래핑되어 있습니다. 실무에서는 if let이나 unwrap_or로 안전하게 처리하는 것이 좋습니다.
세 번째로, payload() 메서드는 &dyn Any 타입을 반환하여 panic과 함께 전달된 추가 데이터에 접근할 수 있습니다. 일반적으로는 잘 사용되지 않지만, 커스텀 panic 정보를 전달해야 하는 고급 시나리오에서 유용합니다.
downcast_ref를 사용하여 원래 타입으로 캐스팅할 수 있습니다. 여러분이 이 정보를 잘 활용하면 커널 디버깅 시간을 크게 단축할 수 있습니다.
정확한 에러 위치를 즉시 파악할 수 있고, 에러 메시지를 통해 문제의 원인을 빠르게 이해하며, 재현이 어려운 버그도 로그만으로 분석할 수 있습니다. 특히 하드웨어에서 테스트할 때는 시리얼 로그가 유일한 디버깅 수단이 되는 경우가 많아 이런 상세 정보가 매우 중요합니다.
실전 팁
💡 message()를 출력할 때는 write! 매크로를 사용하면 버퍼에 직접 쓸 수 있어 메모리 할당 없이 안전하게 처리할 수 있습니다.
💡 location() 정보를 파싱하여 자동으로 소스 코드를 하이라이팅하는 디버거를 만들 수도 있습니다. 파일 경로와 라인 번호를 추출하면 IDE와 연동도 가능합니다.
💡 payload()는 다운캐스팅이 필요하지만, 커스텀 에러 타입을 정의하면 구조화된 에러 정보를 전달할 수 있어 대규모 프로젝트에서 유용합니다.
💡 시리얼 출력이 너무 느리면 panic 처리 시간이 길어질 수 있으니, 중요한 정보만 선택적으로 출력하는 것이 좋습니다.
💡 panic 정보를 메모리의 고정된 위치에 저장해두면, 재부팅 후에도 마지막 panic 원인을 확인할 수 있어 간헐적 버그 추적에 도움이 됩니다.
3. diverging function (!) - 절대 반환하지 않는 함수
시작하며
여러분이 함수를 정의할 때, "이 함수는 절대로 정상적으로 반환되지 않는다"고 명시적으로 표현하고 싶은 경우가 있을까요? 일반적인 프로그래밍에서는 모든 함수가 결국 호출자에게 돌아가지만, OS 커널이나 시스템 프로그래밍에서는 영원히 실행되거나 시스템을 정지시키는 함수가 필요합니다.
이런 상황은 panic handler, 무한 루프, 시스템 종료 함수 등에서 자주 발생합니다. 컴파일러가 이런 함수의 특성을 이해하지 못하면, 불필요한 "반환값이 없다"는 경고나 에러가 발생할 수 있습니다.
또한 제어 흐름 분석이 부정확해져 최적화에도 영향을 줍니다. 바로 이럴 때 필요한 것이 diverging function type인 !
(never type)입니다. 이는 함수가 절대 정상적으로 반환되지 않음을 타입 시스템 수준에서 표현합니다.
개요
간단히 말해서, ! (never type)은 절대 값을 생성하지 않는 타입으로, 함수가 반환하지 않음을 나타냅니다.
이 타입을 반환 타입으로 사용하는 함수를 diverging function이라고 부르며, 무한 루프, panic, 프로세스 종료, 시스템 halt 등 정상적인 반환이 불가능한 경우에 사용합니다. Rust의 타입 시스템은 이를 이해하고 제어 흐름 분석에 활용하여, 도달 불가능한 코드를 정확히 판단하고 최적화할 수 있습니다.
OS 개발에서 panic_handler, 부트로더의 커널 진입 함수, 인터럽트 핸들러 등에서 필수적으로 사용됩니다. 전통적인 타입 시스템에서는 이런 개념이 명시적이지 않아 void나 unit 타입(())으로 표현했다면, Rust의 never type은 "이 함수는 절대 반환하지 않는다"는 의미를 타입 수준에서 명확히 합니다.
핵심 특징은 첫째, 모든 타입으로 강제 변환(coercion)될 수 있어 타입 추론에서 유연하게 동작하고, 둘째, never type을 반환하는 표현식 이후의 코드는 도달 불가능한 것으로 간주되며, 셋째, 컴파일러가 이를 이용해 더 공격적인 최적화를 수행할 수 있다는 점입니다. 이러한 특징들이 타입 안전성과 성능을 동시에 향상시킵니다.
코드 예제
// panic handler는 never type을 반환
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
// 무한 루프는 절대 반환하지 않음
loop {}
}
// 시스템 halt 함수도 diverging function
fn hlt_loop() -> ! {
loop {
// x86 halt 명령 실행
x86_64::instructions::hlt();
}
}
// 커널 메인 함수도 반환하지 않음
#[no_mangle]
pub extern "C" fn _start() -> ! {
kernel_main();
// 커널은 종료되지 않고 계속 실행
hlt_loop()
}
설명
이것이 하는 일은 함수가 호출자에게 제어권을 절대 돌려주지 않는다는 것을 타입 시스템에 명시적으로 알리는 것입니다. 컴파일러는 이 정보를 바탕으로 제어 흐름을 정확하게 분석하고 최적화할 수 있습니다.
첫 번째로, ! 타입을 반환하는 함수는 반드시 무한 루프, panic, 또는 다른 diverging function 호출로 끝나야 합니다.
만약 함수가 정상적으로 끝날 수 있는 경로가 있다면 컴파일 에러가 발생합니다. 예를 들어 loop {} 대신 for i in 0..10 {}를 사용하면 루프가 종료될 수 있으므로 에러가 납니다.
이런 엄격한 검사가 논리 오류를 컴파일 타임에 잡아냅니다. 두 번째로, never type의 강력한 특성 중 하나는 모든 타입으로 coercion될 수 있다는 점입니다.
예를 들어 let x: u32 = if condition { 42 } else { panic!("error") }; 같은 코드가 가능한 이유는 panic!이 !을 반환하고, !이 u32로 변환될 수 있기 때문입니다. 물론 실제로 변환이 일어나는 것은 아니고, 타입 시스템이 이를 안전한 것으로 인식한다는 의미입니다.
세 번째로, OS 커널 개발에서 _start 함수가 !을 반환하는 것은 매우 중요합니다. 부트로더가 커널로 점프한 후에는 돌아갈 곳이 없기 때문에, 커널은 무한히 실행되거나 시스템을 종료해야 합니다.
! 타입은 이런 의미를 정확히 표현하며, 실수로 함수가 반환되는 버그를 컴파일 타임에 방지합니다.
여러분이 이 타입을 올바르게 사용하면 타입 시스템의 도움을 받아 더 안전한 코드를 작성할 수 있습니다. 제어 흐름의 의도가 명확해지고, 컴파일러가 불가능한 코드 경로를 제거하여 더 작고 빠른 바이너리를 생성하며, 논리적 오류를 조기에 발견할 수 있습니다.
특히 커널처럼 디버깅이 어려운 환경에서는 이런 컴파일 타임 보장이 매우 중요합니다.
실전 팁
💡 함수가 !을 반환한다면, 함수 본문의 모든 코드 경로가 diverge해야 합니다. if-else 문이 있다면 모든 브랜치가 diverging해야 합니다.
💡 loop {}보다 loop { hlt(); }를 사용하면 CPU 사용률을 줄이면서 같은 의미를 유지할 수 있습니다. 특히 실제 하드웨어에서 중요합니다.
💡 unreachable!() 매크로도 !을 반환하므로, "논리적으로 도달 불가능하지만 컴파일러가 알 수 없는" 코드 경로를 표시할 때 유용합니다.
💡 never type은 아직 불안정한 기능이므로, stable Rust에서는 일부 제한이 있을 수 있습니다. nightly를 사용 중이라면 #![feature(never_type)]을 활성화하세요.
💡 Result<T, !>은 "절대 실패하지 않는 연산"을 나타내는 타입으로, unwrap()을 안전하게 사용할 수 있는 경우를 표현할 때 유용합니다.
4. VGA 텍스트 모드 출력 - panic 메시지 화면에 표시하기
시작하며
여러분이 커널에서 panic이 발생했을 때, 에러 메시지를 어떻게 사용자에게 보여줄 수 있을까요? 표준 라이브러리의 println!은 사용할 수 없고, 운영체제의 도움도 받을 수 없는 상황입니다.
화면에 아무것도 표시되지 않으면 사용자는 시스템이 멈춘 것인지, 어떤 문제가 발생한 것인지 전혀 알 수 없습니다. 이런 문제는 OS 부팅 초기 단계에서 특히 중요합니다.
그래픽 드라이버를 초기화하기 전이고, 복잡한 출력 시스템을 구축하기 전에 간단하게 텍스트를 출력할 방법이 필요합니다. 디버깅뿐만 아니라 사용자에게 시스템 상태를 알려주는 데도 필수적입니다.
바로 이럴 때 필요한 것이 VGA 텍스트 모드입니다. 이는 BIOS가 제공하는 가장 기본적인 출력 방법으로, 메모리 주소 0xb8000에 직접 쓰기만 하면 화면에 텍스트가 표시됩니다.
개요
간단히 말해서, VGA 텍스트 모드는 메모리 맵 I/O를 통해 화면에 문자를 출력하는 가장 기본적인 방법입니다. x86 아키텍처에서 메모리 주소 0xb8000부터 80x25 크기의 문자 버퍼가 매핑되어 있으며, 각 문자는 2바이트로 표현됩니다(1바이트 ASCII 코드 + 1바이트 색상 정보).
이 메모리 영역에 값을 쓰면 VGA 하드웨어가 자동으로 화면에 표시해줍니다. 별도의 초기화나 드라이버 없이 부팅 즉시 사용할 수 있어 커널 개발 초기 단계나 panic handler에서 매우 유용합니다.
기존에는 BIOS 인터럽트(int 0x10)를 호출하여 문자를 출력했다면, 보호 모드나 롱 모드에서는 BIOS를 사용할 수 없으므로 직접 메모리에 쓰는 방식을 사용합니다. 핵심 특징은 첫째, 0xb8000 주소에 직접 접근하여 즉시 출력이 가능하고, 둘째, 각 문자의 색상(전경색/배경색)을 바이트 단위로 제어할 수 있으며, 셋째, 별도의 시스템콜이나 드라이버 없이 하드웨어 직접 제어가 가능하다는 점입니다.
이러한 특징들이 커널 초기화 코드와 에러 처리를 단순하고 견고하게 만듭니다.
코드 예제
// VGA 버퍼 주소와 화면 크기 정의
const VGA_BUFFER: *mut u8 = 0xb8000 as *mut u8;
const VGA_WIDTH: usize = 80;
const VGA_HEIGHT: usize = 25;
// VGA 색상 코드 (전경색 | 배경색 << 4)
const COLOR_RED_ON_BLACK: u8 = 0x04; // 빨간 글자, 검은 배경
// panic 메시지를 VGA 버퍼에 출력
fn print_panic(message: &str) {
let color = COLOR_RED_ON_BLACK;
for (i, byte) in message.bytes().enumerate() {
unsafe {
// ASCII 코드 쓰기
*VGA_BUFFER.add(i * 2) = byte;
// 색상 코드 쓰기
*VGA_BUFFER.add(i * 2 + 1) = color;
}
}
}
설명
이것이 하는 일은 VGA 하드웨어가 메모리에 매핑해둔 버퍼에 직접 데이터를 써서 화면 출력을 제어하는 것입니다. 메모리 맵 I/O의 전형적인 예로, CPU가 특정 메모리 주소에 쓰면 하드웨어가 이를 감지하여 동작합니다.
첫 번째로, 0xb8000 주소는 VGA 표준에서 정의된 고정 주소입니다. x86 시스템이 부팅되면 BIOS가 VGA를 텍스트 모드로 설정해두기 때문에 별도의 초기화 없이 바로 사용할 수 있습니다.
이 주소를 *mut u8 포인터로 캐스팅하면 바이트 단위로 접근할 수 있습니다. 80x25 화면이므로 총 4000바이트(2000개 문자 x 2바이트)의 버퍼입니다.
두 번째로, 각 문자는 2바이트로 표현됩니다. 첫 번째 바이트는 표시할 문자의 ASCII 코드이고, 두 번째 바이트는 색상 정보입니다.
색상 바이트의 하위 4비트는 전경색(글자 색), 상위 4비트는 배경색을 나타냅니다. 예를 들어 0x04는 빨간 글자(4)에 검은 배경(0)을 의미합니다.
0x1F라면 흰 글자(15=F)에 파란 배경(1)이 됩니다. 세 번째로, unsafe 블록이 필요한 이유는 임의의 메모리 주소에 직접 쓰는 것이 Rust의 안전성 규칙을 벗어나기 때문입니다.
VGA 버퍼가 유효한 메모리 영역임을 개발자가 보장해야 하며, 멀티스레드 환경에서는 동기화도 고려해야 합니다. add() 메서드로 포인터를 이동시키며 문자와 색상을 교대로 씁니다.
네 번째로, 실무에서는 이 방식을 확장하여 Writer 구조체를 만들고, 현재 커서 위치 추적, 줄바꿈 처리, 스크롤링 등의 기능을 추가합니다. core::fmt::Write 트레잇을 구현하면 write!
매크로와 함께 사용할 수 있어 더 편리합니다. 여러분이 이 기법을 사용하면 커널 개발 초기부터 시각적 피드백을 받을 수 있어 개발 속도가 크게 향상됩니다.
부팅 과정을 화면에 표시하고, panic 메시지를 빨간색으로 강조하며, 간단한 TUI도 구현할 수 있습니다. 특히 QEMU에서 테스트할 때 VGA 출력은 매우 신뢰할 수 있는 디버깅 수단입니다.
실전 팁
💡 VGA 버퍼는 휘발성(volatile) 메모리이므로, 최적화를 방지하려면 core::ptr::write_volatile을 사용하세요. 일반 쓰기는 컴파일러가 최적화로 제거할 수 있습니다.
💡 화면을 지우려면 모든 바이트를 0x20(공백 문자)과 원하는 색상으로 채우면 됩니다. 부팅 시 화면을 깨끗하게 만들 때 유용합니다.
💡 줄바꿈(\n)을 처리하려면 현재 위치를 다음 줄의 시작(80의 배수)으로 이동시키면 됩니다. 스크롤링은 버퍼를 한 줄씩 위로 복사합니다.
💡 색상 코드는 매크로로 정의하면 가독성이 좋습니다: const fn vga_color(fg: u8, bg: u8) -> u8 { fg | (bg << 4) }
💡 spin crate의 Mutex를 사용하면 여러 코어에서 동시에 VGA 버퍼에 접근해도 안전하게 동기화할 수 있습니다.
5. 시리얼 포트 출력 - QEMU와 디버깅의 핵심 도구
시작하며
여러분이 QEMU에서 커널을 테스트할 때, 화면 출력만으로는 부족하다고 느낀 적 있나요? VGA 출력은 시각적이지만 텍스트를 복사할 수 없고, 자동화된 테스트에서 결과를 파싱하기 어렵습니다.
또한 실제 하드웨어에서 모니터 없이 원격으로 디버깅해야 하는 상황도 있습니다. 이런 문제는 전문적인 OS 개발에서 매우 일반적입니다.
CI/CD 파이프라인에서 커널 테스트를 자동화하거나, 서버 하드웨어에서 부팅 문제를 원격으로 진단할 때 시각적 출력은 도움이 되지 않습니다. 기계가 읽을 수 있는 텍스트 스트림이 필요합니다.
바로 이럴 때 필요한 것이 시리얼 포트(UART) 출력입니다. COM1 포트를 통해 텍스트를 출력하면 QEMU는 이를 호스트의 파일이나 stdout으로 리다이렉트할 수 있어 강력한 디버깅 도구가 됩니다.
개요
간단히 말해서, 시리얼 포트는 UART 하드웨어를 통해 바이트 스트림을 전송하는 통신 방식으로, OS 개발에서 표준 디버깅 인터페이스입니다. x86에서는 I/O 포트 0x3F8(COM1)을 통해 시리얼 통신을 제어할 수 있으며, 간단한 초기화 후 데이터 레지스터에 쓰기만 하면 문자가 전송됩니다.
QEMU는 -serial 옵션으로 시리얼 출력을 파일이나 stdout으로 리다이렉트할 수 있어, 커널 로그를 호스트 시스템에서 바로 확인할 수 있습니다. 실제 하드웨어에서도 시리얼 케이블로 연결하면 같은 방식으로 디버깅이 가능합니다.
특히 panic handler나 부트 로그에서 필수적으로 사용됩니다. 기존에는 화면 출력만 사용했다면, 시리얼 출력을 추가하면 로그를 파일로 저장하고, 자동화 스크립트로 분석하며, 원격 디버깅이 가능해집니다.
핵심 특징은 첫째, I/O 포트 명령(in/out)으로 하드웨어를 직접 제어하고, 둘째, QEMU 환경에서 호스트로 데이터를 전송할 수 있으며, 셋째, 실제 하드웨어에서도 동일하게 동작하여 범용성이 높다는 점입니다. 이러한 특징들이 시리얼 포트를 OS 개발의 표준 디버깅 인터페이스로 만듭니다.
코드 예제
use x86_64::instructions::port::Port;
// COM1 시리얼 포트 주소 정의
const SERIAL_PORT: u16 = 0x3F8;
// 시리얼 포트 초기화
pub fn init_serial() {
unsafe {
// 인터럽트 비활성화
Port::new(SERIAL_PORT + 1).write(0x00u8);
// 보드레이트 설정 (38400 baud)
Port::new(SERIAL_PORT + 3).write(0x80u8); // DLAB 활성화
Port::new(SERIAL_PORT + 0).write(0x03u8); // divisor 하위 바이트
Port::new(SERIAL_PORT + 1).write(0x00u8); // divisor 상위 바이트
// 8비트, 패리티 없음, 1 스톱비트
Port::new(SERIAL_PORT + 3).write(0x03u8);
// FIFO 활성화 및 클리어
Port::new(SERIAL_PORT + 2).write(0xC7u8);
}
}
// 한 문자 출력
pub fn serial_print_char(c: u8) {
unsafe {
Port::new(SERIAL_PORT).write(c);
}
}
설명
이것이 하는 일은 UART 하드웨어를 프로그래밍하여 시리얼 통신을 설정하고 데이터를 전송하는 것입니다. I/O 포트를 통해 하드웨어 레지스터를 직접 제어하는 전형적인 저수준 프로그래밍입니다.
첫 번째로, 시리얼 포트 초기화는 여러 단계로 이루어집니다. COM1의 베이스 주소는 0x3F8이며, 이 주소로부터 8개의 레지스터가 오프셋으로 배치되어 있습니다.
+0은 데이터 레지스터, +1은 인터럽트 활성화 레지스터, +2는 FIFO 제어, +3은 라인 제어 등입니다. Port::new()로 각 레지스터에 접근하여 통신 파라미터를 설정합니다.
두 번째로, 보드레이트(baud rate) 설정은 DLAB(Divisor Latch Access Bit)를 활성화해야 합니다. 라인 제어 레지스터(+3)의 최상위 비트를 1로 설정하면, 데이터 레지스터가 임시로 divisor 설정 레지스터로 바뀝니다.
divisor 값 3은 38400 baud를 의미합니다(115200 / 3 = 38400). 설정 후 DLAB를 다시 0으로 만들어 정상 모드로 돌아갑니다.
세 번째로, 데이터 포맷은 8비트 데이터, 패리티 없음, 1 스톱비트(8N1)로 설정합니다. 이는 가장 일반적인 시리얼 통신 설정입니다.
라인 제어 레지스터에 0x03을 쓰면 이 설정이 적용됩니다(비트 0-1: 8비트 데이터, 비트 2: 1 스톱비트, 비트 3-5: 패리티 없음). 네 번째로, FIFO 버퍼를 활성화하면 여러 바이트를 버퍼링하여 효율적으로 전송할 수 있습니다.
0xC7 값은 FIFO 활성화, 수신/송신 버퍼 클리어, 14바이트 트리거 레벨을 의미합니다. 실무에서는 문자열을 출력하기 전에 송신 버퍼가 비었는지 확인하는 함수도 추가합니다.
여러분이 시리얼 포트를 활용하면 개발 효율이 극적으로 향상됩니다. QEMU에서 -serial file:serial.log 옵션으로 모든 로그를 파일로 저장하거나, -serial stdio로 터미널에 직접 출력할 수 있습니다.
자동화 테스트에서는 특정 문자열을 검색하여 성공/실패를 판단할 수 있고, 실제 서버 하드웨어에서는 시리얼 콘솔로 원격 디버깅이 가능합니다. panic handler에서 시리얼과 VGA에 동시 출력하면 모든 환경에서 디버깅 정보를 확인할 수 있습니다.
실전 팁
💡 QEMU에서 -serial mon:stdio를 사용하면 QEMU 모니터와 시리얼 출력을 함께 볼 수 있어 편리합니다. Ctrl+A C로 전환할 수 있습니다.
💡 uart_16550 크레이트를 사용하면 초기화와 출력을 더 안전하게 추상화할 수 있습니다. core::fmt::Write를 구현하여 write! 매크로와 함께 사용하세요.
💡 보드레이트를 115200으로 높이면 대량의 로그를 더 빠르게 전송할 수 있지만, 실제 하드웨어에서는 안정성을 위해 38400을 권장합니다.
💡 송신 전 LSR(Line Status Register, +5)의 비트 5를 확인하여 송신 버퍼가 비었는지 체크하면 데이터 손실을 방지할 수 있습니다.
💡 실제 하드웨어에서 테스트할 때는 USB-to-Serial 어댑터와 minicom, screen 같은 터미널 프로그램을 사용하여 호스트에서 로그를 확인할 수 있습니다.
6. no_std 환경 설정 - 표준 라이브러리 없이 개발하기
시작하며
여러분이 처음 OS 커널을 만들려고 할 때, "표준 라이브러리를 사용할 수 없다"는 사실에 당황한 적 있나요? 일반적인 Rust 프로그램은 Vec, String, println!, File 등 수많은 표준 기능을 당연하게 사용하지만, 커널 개발에서는 이 모든 것이 사라집니다.
이런 제약은 OS 커널의 특성 때문입니다. 표준 라이브러리 자체가 운영체제의 기능(메모리 할당, 파일 시스템, 스레드 등)에 의존하는데, 여러분이 만들려는 것이 바로 그 운영체제이기 때문에 순환 의존 문제가 발생합니다.
운영체제가 운영체제를 필요로 할 수는 없습니다. 바로 이럴 때 필요한 것이 no_std 속성입니다.
이는 표준 라이브러리 대신 core 라이브러리만 사용하도록 Rust 컴파일러에 지시하여, OS 없이도 실행 가능한 코드를 작성할 수 있게 합니다.
개요
간단히 말해서, #![no_std]는 표준 라이브러리 링크를 비활성화하고 core 라이브러리만 사용하도록 하는 크레이트 레벨 속성입니다. core 라이브러리는 std의 서브셋으로, OS 기능에 의존하지 않는 기본적인 타입과 트레잇만 제공합니다(Option, Result, Iterator, 프리미티브 타입 등).
메모리 할당(Box, Vec), I/O(File, println!), 스레딩(thread), 네트워킹 등은 제외됩니다. OS 커널, 임베디드 시스템, 부트로더, UEFI 애플리케이션 등 베어메탈 환경에서 필수적으로 사용됩니다.
필요한 기능은 직접 구현하거나 no_std 호환 크레이트를 사용해야 합니다. 기존에는 운영체제 위에서 실행되는 일반 프로그램을 작성했다면, no_std를 사용하면 하드웨어에서 직접 실행되는 시스템 소프트웨어를 작성할 수 있습니다.
핵심 특징은 첫째, 메모리 할당이 필요 없는 기본 타입과 트레잇 사용 가능, 둘째, alloc 크레이트를 추가하면 커스텀 할당자로 힙 할당 기능 사용 가능, 셋째, no_std 호환 에코시스템(spin, volatile, x86_64 등)이 풍부하다는 점입니다. 이러한 특징들이 베어메탈 프로그래밍을 실용적으로 만듭니다.
코드 예제
// 크레이트 루트(lib.rs 또는 main.rs) 상단
#![no_std] // 표준 라이브러리 비활성화
#![no_main] // main 함수 대신 커스텀 진입점 사용
// core 라이브러리는 자동으로 사용 가능
use core::panic::PanicInfo;
// panic handler 필수 구현
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
// 커스텀 진입점 (_start는 링커가 호출)
#[no_mangle]
pub extern "C" fn _start() -> ! {
// 여기서 커널 초기화
loop {}
}
// core의 기능은 사용 가능
fn example() {
let x = Some(42); // Option은 core에 있음
let y: Result<u32, &str> = Ok(100); // Result도 사용 가능
// 하지만 Vec나 String은 사용 불가!
}
설명
이것이 하는 일은 Rust 컴파일러에게 "이 코드는 운영체제 위에서 실행되지 않으니 표준 라이브러리를 링크하지 말라"고 알리는 것입니다. 컴파일 시 std 대신 core만 자동으로 임포트됩니다.
첫 번째로, #![no_std]를 사용하면 여러 가지가 바뀝니다. 가장 중요한 것은 메모리 할당자가 없다는 점입니다.
Box, Vec, String, HashMap 등 힙 할당이 필요한 타입을 사용할 수 없습니다. 대신 배열이나 &str 같은 스택 기반 타입을 사용하거나, alloc 크레이트를 추가하고 GlobalAlloc 트레잇을 구현한 커스텀 할당자를 제공해야 합니다.
두 번째로, #![no_main] 속성도 함께 사용해야 합니다. 일반 Rust 프로그램은 main 함수를 진입점으로 사용하지만, 이는 std 라이브러리의 런타임 초기화 코드에 의존합니다.
no_std 환경에서는 이런 런타임이 없으므로, #[no_mangle]과 extern "C"로 표시된 _start 함수를 직접 정의해야 합니다. 링커 스크립트에서 이 함수를 진입점으로 지정합니다.
세 번째로, panic_handler를 반드시 구현해야 합니다. std 환경에서는 기본 panic 핸들러가 제공되지만, no_std에서는 없습니다.
프로젝트 내에 정확히 하나의 panic_handler가 있어야 하며, 여러 라이브러리를 사용할 때는 하나만 활성화되도록 feature flag로 관리합니다. 네 번째로, core 라이브러리는 생각보다 많은 기능을 제공합니다.
Option, Result, Iterator, Clone, Copy, Display, Debug 등 대부분의 기본 트레잇과 타입이 있습니다. slice, str 타입과 관련 메서드도 사용할 수 있습니다.
fmt 모듈로 포맷팅도 가능합니다. 다만 파일 I/O, 네트워킹, 멀티스레딩 같은 OS 의존 기능만 없는 것입니다.
여러분이 no_std 환경에 익숙해지면 "정말 필요한 기능이 무엇인지" 명확히 이해하게 됩니다. 많은 고수준 기능들이 실제로는 몇 가지 기본 빌딩 블록으로 구현됨을 알 수 있고, 시스템 프로그래밍의 본질을 배울 수 있습니다.
또한 no_std 크레이트 에코시스템은 매우 잘 발달되어 있어, spin(Mutex), volatile(휘발성 메모리), x86_64(CPU 명령), bootloader(부트로더) 등 필요한 대부분의 도구를 찾을 수 있습니다.
실전 팁
💡 alloc 크레이트를 추가하면 Box, Vec 등을 사용할 수 있지만, GlobalAlloc 트레잇을 구현한 할당자를 제공해야 합니다. 초기에는 linked_list_allocator 크레이트를 추천합니다.
💡 Cargo.toml에서 panic = "abort"를 설정하면 스택 언와인딩 코드를 생성하지 않아 바이너리 크기가 줄어듭니다. 커널에서는 어차피 언와인딩이 불가능하므로 권장됩니다.
💡 build-std = ["core", "compiler_builtins"] Cargo 옵션을 사용하면 core를 직접 컴파일하여 타겟 아키텍처에 최적화할 수 있습니다.
💡 no_std 크레이트를 만들 때는 default-features = false로 의존성을 추가하고, std feature를 제공하여 일반 환경에서도 사용할 수 있게 만드세요.
💡 cfg_attr를 사용하면 테스트 시에만 std를 활성화할 수 있습니다: #![cfg_attr(not(test), no_std)]
7. 커스텀 panic 메시지 포맷팅 - 더 나은 디버깅 정보
시작하며
여러분이 커널 개발 중 여러 곳에서 panic이 발생할 때, 단순히 메시지만 출력하는 것으로는 충분하지 않다고 느낀 적 있나요? "어느 모듈에서 발생했는지", "어떤 조건에서 발생했는지", "시스템 상태는 어땠는지" 같은 추가 정보가 있다면 디버깅이 훨씬 쉬워질 것입니다.
이런 문제는 대규모 커널 프로젝트에서 특히 중요합니다. 수십 개의 모듈이 서로 상호작용하고, 간헐적으로 발생하는 버그를 추적해야 할 때, 풍부한 디버깅 정보는 개발 시간을 크게 단축시킵니다.
또한 사용자가 보고한 panic 로그만으로 문제를 재현할 수 있어야 합니다. 바로 이럴 때 필요한 것이 구조화된 panic 핸들러입니다.
PanicInfo의 정보를 체계적으로 포맷팅하고, 시스템 상태, 레지스터 값, 스택 정보 등을 함께 출력하여 종합적인 에러 리포트를 생성합니다.
개요
간단히 말해서, 커스텀 panic 포맷팅은 PanicInfo와 추가 시스템 정보를 조합하여 읽기 쉽고 정보가 풍부한 에러 리포트를 생성하는 기법입니다. 기본적인 메시지 출력을 넘어, 색상으로 구분된 섹션, ASCII 아트 경고 표시, 타임스탬프, CPU 레지스터 덤프, 스택 트레이스, 메모리 상태 등을 포함할 수 있습니다.
VGA 버퍼와 시리얼 포트에 동시에 출력하되, 각 출력 장치의 특성에 맞게 포맷을 조정합니다(VGA는 색상 강조, 시리얼은 상세 정보). 실무에서는 panic 정보를 구조화된 JSON이나 바이너리 형식으로 메모리에 저장하여, 재부팅 후 분석하거나 원격 서버로 전송할 수도 있습니다.
기존에는 단일 줄의 에러 메시지만 출력했다면, 구조화된 포맷팅은 디버깅에 필요한 모든 정보를 한눈에 볼 수 있게 합니다. 핵심 특징은 첫째, 다중 출력 채널(VGA + 시리얼)로 정보를 중복 저장하고, 둘째, 시각적 구조(헤더, 섹션, 구분선)로 가독성을 높이며, 셋째, 시스템 상태 스냅샷을 자동으로 캡처한다는 점입니다.
이러한 특징들이 전문적인 디버깅 환경을 구축합니다.
코드 예제
use core::fmt::Write;
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
// 양쪽 출력 시작
let _ = serial_println!("\n{'='*60}");
let _ = serial_println!("KERNEL PANIC!");
let _ = serial_println!("{'='*60}\n");
vga_print_color("PANIC", COLOR_RED_ON_BLACK);
// 메시지 출력
if let Some(message) = info.message() {
let _ = serial_println!("Message: {}", message);
vga_println!("Message: {}", message);
}
// 위치 정보
if let Some(location) = info.location() {
let _ = serial_println!("Location: {}:{}:{}",
location.file(), location.line(), location.column());
vga_println!("at {}", location.file());
}
// CPU 상태 출력
let _ = serial_println!("\nCPU State:");
print_registers();
// 스택 정보
let _ = serial_println!("\nStack trace:");
print_stack_trace();
hlt_loop()
}
설명
이것이 하는 일은 panic 발생 시점의 모든 관련 정보를 수집하여 읽기 쉬운 형식으로 표현하는 것입니다. 단순한 로깅을 넘어 디버깅을 위한 완전한 시스템 스냅샷을 제공합니다.
첫 번째로, 다중 출력 전략을 사용합니다. serial_println!으로 시리얼 포트에 상세 정보를 출력하고, vga_println!으로 화면에 요약 정보를 표시합니다.
시리얼 출력은 파일로 저장되거나 자동화 도구가 파싱할 수 있으므로 최대한 상세하게, VGA 출력은 사용자가 즉시 인지할 수 있도록 중요 정보만 강조합니다. 구분선(=== 등)을 사용하여 시각적으로 구조화합니다.
두 번째로, PanicInfo의 정보를 단계별로 추출합니다. message()는 Option<&Arguments>를 반환하므로 if let으로 안전하게 처리합니다.
location()도 마찬가지입니다. Display 트레잇을 구현하는 타입들이므로 write!
매크로 계열과 함께 사용할 수 있습니다. 여러 출력 함수 호출의 Result를 let _로 무시하는 것은 panic 중에 출력 실패가 발생해도 무한 재귀를 방지하기 위함입니다.
세 번째로, 추가 디버깅 정보를 수집합니다. print_registers() 함수는 x86_64 레지스터(rax, rbx, rsp 등)를 읽어 출력합니다.
print_stack_trace()는 rbp(base pointer)를 따라가며 반환 주소를 추출하여 호출 스택을 재구성합니다. 이런 정보들은 "어떻게 이 코드에 도달했는지" 이해하는 데 필수적입니다.
네 번째로, 실무에서는 더 고급 기능을 추가할 수 있습니다. RTC(Real Time Clock)에서 현재 시각을 읽어 타임스탬프를 추가하면 로그 분석이 쉬워집니다.
메모리 관리자에 쿼리하여 현재 힙 사용량, 페이지 테이블 상태를 출력할 수 있습니다. 멀티코어 시스템에서는 어느 CPU 코어에서 panic이 발생했는지도 중요한 정보입니다.
여러분이 이런 구조화된 panic 핸들러를 구현하면 디버깅 경험이 완전히 달라집니다. 에러 메시지만 보고 즉시 문제 영역을 특정할 수 있고, 재현이 어려운 버그도 로그 분석만으로 해결할 수 있으며, 사용자 리포트의 품질이 높아져 버그 수정 속도가 빨라집니다.
특히 CI/CD 파이프라인에서 자동 테스트 중 panic이 발생하면, 상세 로그가 자동으로 수집되어 개발자가 즉시 분석할 수 있습니다.
실전 팁
💡 write! 매크로의 결과를 let _ =로 무시하세요. panic 중 출력 실패로 또 panic이 발생하면 무한 재귀가 될 수 있습니다.
💡 VGA 출력은 80x25로 제한되므로, 가장 중요한 정보(메시지, 파일, 라인)만 표시하고 나머지는 시리얼로만 출력하세요.
💡 panic 정보를 메모리 주소 0x8000 같은 고정 위치에 저장하면, 부트로더가 재부팅 후 이를 읽어 "이전 세션 crash 리포트"를 표시할 수 있습니다.
💡 QEMU에서 -d cpu_reset,int 옵션을 사용하면 panic 시점의 CPU 상태를 자동으로 덤프할 수 있어, 핸들러 코드와 비교하여 정확성을 검증할 수 있습니다.
💡 conditional compilation(#[cfg(debug_assertions)])을 사용하여 디버그 빌드에서만 상세 정보를 출력하고, 릴리스 빌드에서는 간단한 메시지만 표시하여 코드 크기를 줄일 수 있습니다.
8. 무한 루프와 hlt 명령 - 효율적인 시스템 정지
시작하며
여러분이 panic 핸들러를 구현할 때, 함수를 종료시키지 않기 위해 loop {}를 사용하는 것이 일반적입니다. 하지만 이런 빈 무한 루프는 CPU를 100% 사용하면서 아무 일도 하지 않는 비효율적인 방식입니다.
실제 하드웨어에서는 발열과 전력 소비가 문제가 됩니다. 이런 문제는 서버나 임베디드 시스템에서 특히 심각합니다.
데이터센터에서 panic으로 정지한 서버가 계속 전력을 최대로 소비하면 비용이 증가하고, 배터리로 동작하는 임베디드 장치에서는 배터리가 빠르게 소모됩니다. 또한 불필요한 CPU 사이클이 다른 코어의 성능에도 영향을 줄 수 있습니다.
바로 이럴 때 필요한 것이 hlt 명령입니다. 이는 CPU를 저전력 상태로 전환하여 다음 인터럽트가 발생할 때까지 대기하게 합니다.
무한 루프와 결합하면 효율적이고 안전한 시스템 정지를 구현할 수 있습니다.
개요
간단히 말해서, hlt(halt) 명령은 CPU를 유휴 상태로 전환하여 다음 인터럽트까지 전력 소비를 최소화하는 x86 명령입니다. hlt 명령이 실행되면 CPU는 즉시 명령 실행을 멈추고 저전력 모드로 진입합니다.
타이머 인터럽트, 키보드 입력, 네트워크 패킷 등 어떤 인터럽트가 발생하면 CPU가 깨어나 다음 명령을 실행합니다. OS 커널에서는 이를 loop { hlt() } 형태로 사용하여 인터럽트 발생 시 다시 hlt로 돌아가는 안전한 idle 루프를 만듭니다.
인터럽트가 비활성화되어 있으면 영원히 대기하므로 시스템을 완전히 정지시킬 수 있습니다. 전력 관리, panic 처리, idle task 구현 등에서 필수적으로 사용됩니다.
기존에는 loop {}로 CPU를 계속 바쁘게 만들었다면, hlt를 사용하면 에너지 효율적으로 같은 목적을 달성할 수 있습니다. 핵심 특징은 첫째, 인터럽트 발생까지 CPU를 정지시켜 전력 소비를 크게 줄이고, 둘째, 인터럽트가 활성화되어 있으면 일시적 정지이고 비활성화되어 있으면 영구적 정지이며, 셋째, 멀티코어 시스템에서는 개별 코어만 정지시킬 수 있다는 점입니다.
이러한 특징들이 현대적인 전력 관리와 시스템 안정성을 제공합니다.
코드 예제
use x86_64::instructions::interrupts;
use x86_64::instructions::hlt;
// 안전한 halt 루프 구현
pub fn hlt_loop() -> ! {
loop {
// CPU를 저전력 상태로 전환
// 인터럽트 발생 시 깨어나서 다시 hlt 실행
hlt();
}
}
// panic handler에서 사용
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
serial_println!("PANIC: {}", info.message());
// 인터럽트 비활성화하여 시스템 완전 정지
interrupts::disable();
// 효율적인 무한 정지
hlt_loop()
}
// idle task에서도 사용 가능
pub fn idle_task() -> ! {
loop {
// 인터럽트는 활성화 상태
// 타이머나 다른 이벤트 발생 시 스케줄러 호출
hlt();
// 깨어나면 여기로 돌아옴
}
}
설명
이것이 하는 일은 CPU에게 "할 일이 없으니 절전 모드로 들어가서 인터럽트를 기다리라"고 명령하는 것입니다. 단순히 바쁘게 회전하는 대신 실제로 전력을 절약합니다.
첫 번째로, hlt 명령의 동작 원리를 이해해야 합니다. CPU는 명령을 페치-디코드-실행 사이클로 처리하는데, hlt를 만나면 이 사이클을 중단하고 인터럽트 대기 상태로 들어갑니다.
클럭 게이팅(clock gating) 기술로 많은 회로의 클럭을 차단하여 전력 소비를 줄입니다. 현대 CPU는 C-state라는 여러 단계의 저전력 상태를 가지며, hlt는 보통 C1 상태로 진입합니다.
두 번째로, 인터럽트와의 상호작용이 중요합니다. 인터럽트가 활성화(EFLAGS의 IF 비트가 1)되어 있으면, 타이머 인터럽트, I/O 완료, IPI(Inter-Processor Interrupt) 등이 발생할 때 CPU가 깨어납니다.
인터럽트 핸들러가 실행된 후 hlt 다음 명령으로 돌아옵니다. 위 코드의 hlt_loop()에서는 다시 hlt를 호출하여 계속 대기합니다.
세 번째로, panic 시나리오에서는 interrupts::disable()로 모든 인터럽트를 비활성화합니다. 이 상태에서 hlt를 실행하면 CPU는 영원히 깨어나지 않습니다(NMI나 리셋 제외).
이것이 바로 우리가 원하는 동작입니다 - 시스템을 완전히 정지시키되, CPU를 100% 사용하지 않도록 합니다. 멀티코어 시스템에서는 다른 코어는 계속 동작할 수 있습니다.
네 번째로, idle task는 다른 방식으로 사용합니다. OS 스케줄러가 실행할 태스크가 없을 때 idle task를 실행하는데, 여기서는 인터럽트를 활성화한 채로 hlt를 사용합니다.
타이머 인터럽트가 발생하면 스케줄러가 다시 동작하여 실행 가능한 태스크가 있는지 확인하고, 없으면 다시 idle로 돌아옵니다. 이렇게 하면 유휴 시간에 전력을 크게 절약할 수 있습니다.
여러분이 hlt를 올바르게 사용하면 시스템의 품질이 크게 향상됩니다. 노트북에서 배터리 수명이 늘어나고, 서버에서 전력 비용이 줄어들며, 임베디드 시스템에서 발열 문제가 감소합니다.
또한 멀티코어 환경에서 다른 코어의 성능에 영향을 주지 않으면서 안전하게 대기할 수 있습니다. 프로덕션 품질의 OS에서는 hlt 사용이 필수적입니다.
실전 팁
💡 hlt 전에 sti(set interrupt flag)로 인터럽트를 활성화하면 일시적 정지, cli(clear interrupt flag)로 비활성화하면 영구적 정지가 됩니다. 의도를 명확히 하세요.
💡 멀티코어 시스템에서는 IPI(Inter-Processor Interrupt)를 보내 hlt로 대기 중인 다른 코어를 깨울 수 있습니다. 스케줄러 구현에 유용합니다.
💡 QEMU에서 hlt를 사용하면 호스트 CPU 사용률이 크게 줄어드는 것을 확인할 수 있습니다. top 명령으로 비교해보세요.
💡 x86_64::instructions::interrupts::enable_and_hlt()를 사용하면 인터럽트 활성화와 hlt가 원자적으로 실행되어 race condition을 방지할 수 있습니다.
💡 더 깊은 절전 상태(C3, C6 등)에 진입하려면 ACPI의 C-state 레지스터를 직접 조작해야 하지만, 일반적으로 hlt만으로도 충분한 전력 절감 효과가 있습니다.
9. 테스트 프레임워크와 panic 처리 - 자동화된 테스트
시작하며
여러분이 OS 커널에 새 기능을 추가할 때마다 수동으로 부팅하고 테스트하는 것이 번거롭지 않나요? 특히 panic 핸들러나 에러 처리 코드는 의도적으로 에러를 발생시켜야 하는데, 이를 매번 손으로 확인하는 것은 비현실적입니다.
이런 문제는 커널 개발의 생산성을 크게 저하시킵니다. 리그레션 테스트를 자동화하지 않으면 기존 기능이 망가졌는지 알 수 없고, CI/CD 파이프라인을 구축할 수도 없습니다.
특히 panic 핸들러처럼 프로그램이 정상 종료하지 않는 코드는 테스트하기가 까다롭습니다. 바로 이럴 때 필요한 것이 커스텀 테스트 프레임워크입니다.
Rust의 #[test] 속성과 custom_test_frameworks 기능을 활용하여 no_std 환경에서도 자동화된 테스트를 실행하고, QEMU 종료 명령으로 결과를 호스트에 전달할 수 있습니다.
개요
간단히 말해서, 커스텀 테스트 프레임워크는 no_std 환경에서 자동화된 유닛 테스트를 실행하고 결과를 자동으로 수집하는 시스템입니다. Rust의 custom_test_frameworks 기능을 활성화하면 #[test] 함수들을 자동으로 수집하여 개발자가 정의한 test runner에 전달합니다.
test runner는 각 테스트를 실행하고 성공/실패를 시리얼 포트로 출력합니다. QEMU의 isa-debug-exit 디바이스를 사용하면 특정 I/O 포트에 값을 써서 QEMU를 종료시킬 수 있으며, 종료 코드로 테스트 성공/실패를 구분합니다.
호스트의 cargo test는 이 종료 코드를 확인하여 테스트 결과를 판단합니다. panic이 예상되는 테스트(should_panic)도 커스텀 panic 핸들러로 처리할 수 있습니다.
기존에는 수동으로 커널을 부팅하고 결과를 눈으로 확인했다면, 이제는 cargo test 한 번으로 모든 테스트가 자동 실행되고 결과가 리포트됩니다. 핵심 특징은 첫째, #[test] 속성으로 테스트 함수를 표시하고 자동 수집되며, 둘째, QEMU 디바이스를 통해 종료 코드를 호스트에 전달하고, 셋째, should_panic 같은 특수 케이스도 처리할 수 있다는 점입니다.
이러한 특징들이 TDD(Test-Driven Development)를 커널 개발에서도 가능하게 합니다.
코드 예제
// main.rs 또는 lib.rs
#![feature(custom_test_frameworks)]
#![test_runner(crate::test_runner)]
#![reexport_test_harness_main = "test_main"]
// 테스트 러너 구현
pub fn test_runner(tests: &[&dyn Fn()]) {
serial_println!("Running {} tests", tests.len());
for test in tests {
test(); // 각 테스트 실행
}
exit_qemu(QemuExitCode::Success);
}
// QEMU 종료 함수
pub fn exit_qemu(exit_code: QemuExitCode) {
use x86_64::instructions::port::Port;
unsafe {
let mut port = Port::new(0xf4); // isa-debug-exit 포트
port.write(exit_code as u32);
}
}
// 테스트 예제
#[test_case]
fn test_panic_handler() {
serial_print!("test_panic_handler... ");
// 테스트 로직
serial_println!("[ok]");
}
설명
이것이 하는 일은 Rust의 표준 테스트 프레임워크를 no_std 환경에서 사용할 수 있도록 커스터마이징하는 것입니다. std 라이브러리가 제공하는 테스트 인프라를 직접 구현합니다.
첫 번째로, custom_test_frameworks 기능을 활성화하고 test_runner를 지정합니다. 컴파일 시 cargo는 모든 #[test_case] 함수를 수집하여 &[&dyn Fn()] 배열로 만들고, 이를 test_runner에 전달합니다.
reexport_test_harness_main = "test_main"은 이 테스트 진입점을 test_main이라는 이름으로 export하라는 의미입니다. _start 함수에서 test_main()을 호출하여 테스트를 시작합니다.
두 번째로, test_runner는 각 테스트를 순회하며 실행합니다. 각 테스트는 단순히 fn()이므로 test()로 호출합니다.
테스트가 panic을 발생시키면 panic_handler가 처리하고, 정상 완료되면 다음 테스트로 넘어갑니다. 모든 테스트가 성공하면 exit_qemu()를 호출하여 QEMU를 종료시킵니다.
세 번째로, QEMU 종료 메커니즘이 핵심입니다. QEMU에 -device isa-debug-exit,iobase=0xf4,iosize=0x04 옵션을 전달하면, 게스트 OS가 I/O 포트 0xf4에 값을 쓸 때 QEMU가 종료됩니다.
쓰인 값이 종료 코드가 되므로, 테스트 성공 시 0, 실패 시 1 같은 값으로 구분할 수 있습니다. .cargo/config.toml에 설정하여 cargo test 시 자동으로 적용되게 합니다.
네 번째로, should_panic 테스트를 지원하려면 별도의 panic 핸들러를 구현해야 합니다. #[cfg(test)]로 테스트 모드에서만 활성화되는 panic 핸들러에서는 panic을 성공으로 간주하고 exit_qemu(Success)를 호출합니다.
일반 panic 핸들러와 구분하기 위해 조건부 컴파일을 활용합니다. 여러분이 이 테스트 프레임워크를 구축하면 개발 워크플로우가 혁신적으로 개선됩니다.
cargo test 명령 하나로 모든 유닛 테스트가 실행되고, GitHub Actions 같은 CI에서 자동으로 테스트가 돌아가며, 코드 변경이 기존 기능을 망가뜨리는지 즉시 알 수 있습니다. TDD 방식으로 개발할 수 있어 코드 품질이 높아지고, 리팩토링도 자신감 있게 진행할 수 있습니다.
실전 팁
💡 serial_print!와 serial_println!을 구분하여 사용하면 테스트 결과를 "test_name... [ok]" 형식으로 깔끔하게 출력할 수 있습니다.
💡 각 테스트를 별도의 바이너리로 컴파일하면(integration test 스타일), 한 테스트의 panic이 다른 테스트에 영향을 주지 않습니다. tests/ 디렉토리를 활용하세요.
💡 bootimage runner 설정으로 cargo test가 자동으로 부트 이미지를 만들고 QEMU를 실행하도록 할 수 있습니다. Cargo.toml에 [package.metadata.bootimage] 섹션을 추가하세요.
💡 timeout을 설정하여 무한 루프에 빠진 테스트가 CI를 막지 않도록 하세요. QEMU의 -no-reboot 옵션도 유용합니다.
💡 exit_qemu의 종료 코드는 (value << 1) | 1 형태로 변환되므로, 예상 값을 적절히 조정해야 합니다. 예를 들어 0을 쓰면 실제 종료 코드는 1이 됩니다.
10. QemuExitCode와 통합 테스트 - 종료 코드 관리
시작하며
여러분이 테스트 프레임워크를 구축할 때, "성공"과 "실패"를 QEMU 종료 코드로 어떻게 표현할까요? 단순히 0과 1을 사용할 수도 있지만, QEMU의 isa-debug-exit 디바이스는 값을 변환하여 종료 코드를 생성하므로 혼란스러울 수 있습니다.
이런 문제는 테스트 자동화에서 정확한 결과 판단을 어렵게 만듭니다. 종료 코드가 일관되지 않으면 CI 스크립트가 잘못된 판단을 내릴 수 있고, 디버깅도 어려워집니다.
명확하고 예측 가능한 종료 코드 체계가 필요합니다. 바로 이럴 때 필요한 것이 QemuExitCode enum입니다.
성공/실패를 명확히 구분하고, QEMU의 종료 코드 변환 규칙을 캡슐화하여 안전하고 예측 가능한 종료를 제공합니다.
개요
간단히 말해서, QemuExitCode는 QEMU 종료 시 사용할 종료 코드를 정의한 enum으로, 테스트 결과를 호스트에 명확히 전달합니다. QEMU의 isa-debug-exit 디바이스는 게스트가 쓴 값을 (value << 1) | 1 공식으로 변환하여 실제 종료 코드로 사용합니다.
예를 들어 게스트가 0x10을 쓰면 종료 코드는 (0x10 << 1) | 1 = 33이 됩니다. QemuExitCode enum은 이 변환을 고려하여 Success와 Failed에 적절한 값을 할당합니다.
cargo test는 종료 코드 0을 성공으로 간주하므로, Success는 종료 코드가 0이 되도록 (0 - 1) >> 1 같은 계산을 역으로 수행합니다. 실무에서는 .cargo/config.toml에 예상 종료 코드를 설정하여 cargo test가 올바르게 해석하도록 합니다.
기존에는 매직 넘버를 직접 사용했다면, QemuExitCode로 의미를 명확히 하고 타입 안전성을 확보합니다. 핵심 특징은 첫째, enum으로 가능한 종료 코드를 제한하여 실수를 방지하고, 둘째, QEMU의 종료 코드 변환 로직을 캡슐화하며, 셋째, 문서화된 의미(Success/Failed)로 코드 가독성을 높인다는 점입니다.
이러한 특징들이 견고한 테스트 인프라를 만듭니다.
코드 예제
// 종료 코드 정의
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u32)]
pub enum QemuExitCode {
Success = 0x10, // 종료 코드 33 = (0x10 << 1) | 1
Failed = 0x11, // 종료 코드 35 = (0x11 << 1) | 1
}
// QEMU 종료 함수
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);
}
// QEMU가 종료하지 않으면 무한 루프
loop {
x86_64::instructions::hlt();
}
}
// 테스트 러너에서 사용
pub fn test_runner(tests: &[&dyn Fn()]) {
serial_println!("Running {} tests", tests.len());
for test in tests {
test();
}
// 모든 테스트 성공
exit_qemu(QemuExitCode::Success);
}
설명
이것이 하는 일은 QEMU 종료 메커니즘의 복잡성을 추상화하여 단순하고 명확한 인터페이스를 제공하는 것입니다. 개발자는 Success/Failed만 생각하면 되고, 내부적인 변환 로직은 감춰집니다.
첫 번째로, enum의 설계를 살펴봅시다. #[repr(u32)]는 이 enum이 u32 크기를 가지며, 각 variant의 값이 u32로 표현됨을 보장합니다.
Success = 0x10과 Failed = 0x11은 명시적으로 값을 할당한 것으로, isa-debug-exit의 변환 공식 (value << 1) | 1을 고려하여 선택되었습니다. 0x10 << 1 | 1 = 33, 0x11 << 1 | 1 = 35가 됩니다.
두 번째로, .cargo/config.toml에서 이 종료 코드를 처리합니다. [test] 섹션에 success-exit-code = 33을 설정하면, cargo test는 프로세스가 33으로 종료했을 때 테스트 성공으로 간주합니다.
35나 다른 코드로 종료하면 실패입니다. 이렇게 cargo의 테스트 인프라와 완전히 통합됩니다.
세 번째로, exit_qemu 함수는 diverging function(-> !)입니다. Port::write()로 종료 코드를 쓴 후 QEMU는 즉시 종료해야 하지만, 만약 종료가 실패하거나 다른 환경에서 실행되는 경우를 대비하여 무한 hlt 루프를 추가합니다.
이는 절대 반환하지 않는다는 타입 시스템의 약속을 지킵니다. 네 번째로, 실무에서는 더 다양한 종료 코드를 정의할 수 있습니다.
TestFailed, Timeout, UnexpectedPanic 등을 추가하여 실패 원인을 구분하면, CI 로그에서 문제를 빠르게 파악할 수 있습니다. 각각 다른 u32 값을 할당하고, 호스트 스크립트에서 종료 코드를 분석하여 상세한 리포트를 생성할 수 있습니다.
여러분이 이렇게 구조화된 종료 코드 시스템을 사용하면 테스트 결과 해석이 정확해지고 자동화가 쉬워집니다. cargo test의 출력이 명확해지고, CI/CD 파이프라인에서 신뢰할 수 있는 결과를 얻으며, 테스트 실패 시 원인을 빠르게 파악할 수 있습니다.
특히 대규모 프로젝트에서 수백 개의 테스트를 관리할 때 이런 체계적인 접근이 필수적입니다.
실전 팁
💡 종료 코드 값은 0x10부터 시작하는 것을 권장합니다. 낮은 값(0-10)은 일반적인 프로그램 종료 코드와 겹칠 수 있습니다.
💡 Debug와 Display 트레잇을 구현하면 로그에 "Exiting with: Success" 같은 명확한 메시지를 출력할 수 있습니다.
💡 integration 테스트에서 각 테스트 바이너리가 다른 종료 코드를 사용하도록 하면, 여러 테스트를 병렬 실행해도 결과를 구분할 수 있습니다.
💡 QEMU 대신 실제 하드웨어에서 실행할 때는 exit_qemu가 무의미하므로, cfg(test) 등으로 조건부 컴파일하거나 다른 리포팅 메커니즘(네트워크, 시리얼)을 사용하세요.
💡 bootimage의 test-success-exit-code 설정과 Cargo의 success-exit-code가 일치하는지 확인하세요. 불일치하면 테스트가 항상 실패로 보고될 수 있습니다.