이미지 로딩 중...
AI Generated
2025. 11. 13. · 3 Views
Rust로 만드는 나만의 OS - QEMU 에뮬레이터 설치 완벽 가이드
OS 개발을 위한 필수 도구인 QEMU 에뮬레이터를 설치하고 설정하는 방법을 배웁니다. 실제 하드웨어 없이도 커스텀 OS를 테스트하고 디버깅할 수 있는 환경을 구축합니다.
목차
- QEMU란 무엇인가 - OS 개발자의 필수 도구
- QEMU 기본 실행 옵션 - 가상 머신 설정하기
- 부팅 가능한 이미지 만들기 - Rust OS와 QEMU 연결
- 시리얼 포트 출력 설정 - 디버깅의 핵심
- GDB 디버깅 연결 - 단계별 실행과 브레이크포인트
- VGA 텍스트 버퍼 - 화면에 출력하기
- 테스트 프레임워크 구축 - 자동화된 OS 테스팅
- QEMU 모니터 콘솔 - 런타임 디버깅과 제어
- 네트워크 설정 - 가상 네트워크로 OS 통신 테스트
- QEMU 스냅샷과 상태 관리 - 빠른 반복 개발
- KVM 가속 - 실행 속도 극대화
- 멀티부팅과 GRUB 통합 - 실제 하드웨어 테스트 준비
1. QEMU란 무엇인가 - OS 개발자의 필수 도구
시작하며
여러분이 Rust로 운영체제를 만들고 있다고 상상해보세요. 코드를 작성하고 컴파일했는데, 이걸 어디서 실행해야 할까요?
실제 하드웨어에 매번 설치하기엔 너무 위험하고 번거롭습니다. 잘못된 코드 하나가 시스템 전체를 다운시킬 수도 있죠.
전문 OS 개발자들도 같은 고민을 했습니다. 안전하고 빠르게 테스트할 수 있는 환경이 필요했죠.
실제 하드웨어를 망가뜨릴 걱정 없이, 무한히 재시작하고 디버깅할 수 있는 그런 환경 말입니다. 바로 이럴 때 필요한 것이 QEMU입니다.
QEMU는 가상의 컴퓨터를 소프트웨어로 만들어주는 강력한 에뮬레이터로, 여러분의 OS 코드를 안전하게 테스트할 수 있게 해줍니다.
개요
간단히 말해서, QEMU는 실제 하드웨어를 소프트웨어로 시뮬레이션하는 오픈소스 에뮬레이터입니다. 왜 QEMU가 필요할까요?
OS 개발은 일반 애플리케이션 개발과 다릅니다. 버그 하나가 시스템 전체를 멈출 수 있고, 잘못된 메모리 접근은 하드웨어를 손상시킬 수도 있습니다.
QEMU는 이런 위험을 완전히 제거해줍니다. 예를 들어, 부팅 프로세스를 테스트하다가 무한 루프에 빠졌다면, 그냥 QEMU를 종료하고 다시 시작하면 됩니다.
기존에는 OS를 테스트하려면 별도의 테스트 머신이 필요했고, USB나 CD로 부팅해야 했습니다. 이제는 한 줄의 명령어로 여러분의 OS를 즉시 실행할 수 있습니다.
QEMU의 핵심 특징은 다양한 아키텍처 지원(x86, ARM, RISC-V 등), 빠른 실행 속도, 그리고 GDB와의 완벽한 통합입니다. 이러한 특징들이 OS 개발의 개발-테스트 사이클을 몇 분에서 몇 초로 단축시켜줍니다.
코드 예제
# Ubuntu/Debian에서 QEMU 설치
sudo apt-get update
sudo apt-get install qemu-system-x86
# macOS에서 Homebrew로 설치
brew install qemu
# 설치 확인 - 버전 정보 출력
qemu-system-x86_64 --version
# 간단한 테스트 실행 (부팅 가능한 이미지가 있다면)
qemu-system-x86_64 -drive format=raw,file=bootimage.bin
설명
이것이 하는 일: QEMU는 여러분의 컴퓨터 안에 가상의 컴퓨터를 만들어줍니다. 마치 컴퓨터 속에 또 다른 컴퓨터가 있는 것처럼 동작하죠.
첫 번째로, 패키지 매니저를 통한 설치 과정은 QEMU의 모든 필수 구성요소를 자동으로 다운로드하고 설정합니다. qemu-system-x86은 x86_64 아키텍처를 에뮬레이션하는 버전인데, 대부분의 PC와 호환되는 가장 일반적인 선택입니다.
왜 이렇게 하는지? OS 개발은 보통 x86_64 아키텍처를 타겟으로 하기 때문입니다.
그 다음으로, 설치가 완료되면 qemu-system-x86_64 명령어를 사용할 수 있게 됩니다. 이 명령어는 단순히 프로그램을 실행하는 게 아니라, 가상 CPU, 가상 메모리, 가상 디스크를 모두 초기화합니다.
내부에서는 바이너리 번역(binary translation)과 JIT 컴파일을 통해 여러분의 OS 코드를 실제 하드웨어에서 실행 가능한 형태로 변환합니다. 마지막으로, -drive 옵션을 통해 부팅 이미지를 지정하면, QEMU는 이 파일을 가상 하드 드라이브처럼 취급하여 BIOS/UEFI 부팅 프로세스를 시작합니다.
최종적으로 여러분의 커스텀 OS가 창에 띄워지며 실행됩니다. 여러분이 이 명령어들을 사용하면 몇 초 만에 완전히 격리된 테스트 환경을 얻을 수 있습니다.
실제 하드웨어를 건드릴 필요 없이 무한히 실험하고, 크래시해도 걱정 없으며, 심지어 여러 가상 머신을 동시에 실행할 수도 있습니다.
실전 팁
💡 QEMU 설치 시 qemu-system-x86만이 아니라 qemu-utils도 함께 설치하세요. 이미지 변환, 스냅샷 관리 등 유용한 도구들이 포함되어 있습니다.
💡 처음 설치 후엔 반드시 버전을 확인하세요. QEMU 5.0 이상을 권장하며, 이전 버전은 일부 최신 기능이 누락될 수 있습니다.
💡 macOS 사용자라면 Homebrew 설치 후 brew install qemu가 가장 간단하지만, 최신 버전이 필요하면 소스에서 직접 컴파일하는 것도 고려하세요.
💡 Windows에서는 공식 바이너리를 다운로드하거나 WSL2 내에서 Linux 버전을 사용하는 것이 더 안정적입니다.
2. QEMU 기본 실행 옵션 - 가상 머신 설정하기
시작하며
QEMU를 설치했다면, 이제 실행해야겠죠? 그런데 막상 명령어를 입력하려니 어디서부터 시작해야 할지 막막합니다.
메모리는 얼마나 할당하지? CPU 코어는?
화면 해상도는? 실제로 많은 초보 OS 개발자들이 이 단계에서 시행착오를 겪습니다.
너무 적은 메모리를 할당하면 OS가 제대로 동작하지 않고, 불필요한 옵션을 너무 많이 주면 복잡해집니다. 바로 이럴 때 필요한 것이 QEMU의 기본 실행 옵션들을 이해하는 것입니다.
딱 필요한 만큼만, 그리고 효율적으로 가상 머신을 구성하는 방법을 알아봅시다.
개요
간단히 말해서, QEMU 실행 옵션은 가상 하드웨어의 사양을 결정하는 명령줄 파라미터들입니다. 왜 이 옵션들이 필요한지?
OS는 하드웨어와 직접 대화합니다. 메모리 크기, CPU 종류, 디스크 타입 등이 모두 OS의 동작에 영향을 줍니다.
잘못 설정하면 부팅조차 안 될 수 있죠. 예를 들어, 32MB 메모리로는 현대적인 OS 커널조차 로드되지 않을 수 있습니다.
기존에는 복잡한 설정 파일을 작성해야 했다면, QEMU는 명령줄에서 직관적으로 모든 것을 제어할 수 있게 해줍니다. QEMU의 핵심 옵션은 -m(메모리), -smp(CPU 코어 수), -drive(디스크 설정), -serial(시리얼 출력) 등입니다.
이러한 옵션들이 여러분의 OS가 실행될 가상 환경을 완전히 커스터마이즈할 수 있게 해줍니다.
코드 예제
# 기본 실행 - 128MB RAM, 1 CPU 코어
qemu-system-x86_64 \
-m 128M \
-drive format=raw,file=os.bin
# 더 현실적인 설정 - 512MB RAM, 4 CPU 코어
qemu-system-x86_64 \
-m 512M \
-smp 4 \
-drive format=raw,file=os.bin \
-serial stdio
# 디버깅 모드 - GDB 서버 활성화
qemu-system-x86_64 \
-m 512M \
-drive format=raw,file=os.bin \
-s -S
설명
이것이 하는 일: 이 명령어들은 QEMU에게 "이런 사양의 가상 컴퓨터를 만들어라"고 지시합니다. 첫 번째로, -m 128M 옵션은 가상 머신에 128MB의 RAM을 할당합니다.
이는 물리적 메모리가 아니라 여러분 컴퓨터의 메모리 중 일부를 가상 머신이 사용하도록 예약하는 것입니다. 왜 128MB인가?
간단한 OS 커널을 실행하기에 충분한 최소한의 크기입니다. 더 복잡한 기능을 구현할수록 이 값을 늘려야 합니다.
그 다음으로, -smp 4 옵션이 실행되면서 4개의 가상 CPU 코어를 생성합니다. 내부에서 QEMU는 멀티스레딩을 사용해 각 코어를 시뮬레이션하며, 여러분의 OS에서 멀티프로세싱을 테스트할 수 있게 해줍니다.
-serial stdio 옵션은 가상 머신의 시리얼 포트 출력을 여러분의 터미널로 리다이렉트합니다. 마지막으로, -s -S 옵션들이 디버깅 모드를 활성화합니다.
-s는 포트 1234에 GDB 서버를 열고, -S는 실행을 일시정지 상태로 시작합니다. 최종적으로 GDB를 연결해서 브레이크포인트를 설정하고 단계별로 실행할 수 있게 됩니다.
여러분이 이 옵션들을 조합하면 개발 단계에 맞는 최적의 테스트 환경을 만들 수 있습니다. 초기 개발엔 적은 리소스로, 성능 테스트엔 많은 코어로, 디버깅엔 GDB와 함께 사용하는 식으로 유연하게 대응할 수 있죠.
실전 팁
💡 개발 초기엔 -m 128M로 시작하되, 메모리 관리 기능을 구현하면서 점진적으로 늘리세요. 갑자기 큰 메모리를 주면 버그를 놓칠 수 있습니다.
💡 -nographic 옵션을 추가하면 GUI 창 없이 터미널에서만 실행됩니다. CI/CD 파이프라인에서 유용합니다.
💡 -d int,cpu_reset 옵션으로 인터럽트와 CPU 리셋 로그를 볼 수 있어 부팅 문제 디버깅에 매우 유용합니다.
💡 여러 OS 이미지를 테스트할 때는 셸 스크립트나 Makefile에 자주 쓰는 QEMU 명령어를 저장해두세요.
💡 -enable-kvm 옵션(Linux)이나 -accel hvf(macOS)로 하드웨어 가속을 활성화하면 실행 속도가 10배 이상 빨라집니다.
3. 부팅 가능한 이미지 만들기 - Rust OS와 QEMU 연결
시작하며
여러분이 Rust로 간단한 "Hello, OS!" 커널을 작성했다고 가정해봅시다. 코드는 완벽하고 컴파일도 성공했는데, QEMU에서 실행하니 아무것도 안 보입니다.
검은 화면만 깜박이죠. 이런 상황은 OS 개발에서 가장 흔한 장벽 중 하나입니다.
문제는 코드가 아니라 부팅 가능한 형식으로 만들지 않았기 때문입니다. 일반 프로그램은 OS가 로드해주지만, OS 자체는 BIOS나 부트로더가 인식할 수 있는 특별한 형식이어야 합니다.
바로 이럴 때 필요한 것이 부팅 가능한 이미지를 만드는 과정입니다. Rust 바이너리를 QEMU가 부팅할 수 있는 형식으로 변환하는 방법을 알아봅시다.
개요
간단히 말해서, 부팅 가능한 이미지는 BIOS/UEFI가 인식하고 실행할 수 있는 특별한 형식의 바이너리 파일입니다. 왜 이 과정이 필요한지?
일반 실행 파일(.exe, ELF 등)은 OS의 로더가 필요합니다. 하지만 OS 자체는 아직 로더가 없죠.
그래서 하드웨어(BIOS)가 직접 읽을 수 있는 "raw 바이너리" 형식이 필요합니다. 예를 들어, 부트섹터의 정확히 512번째 바이트에 매직 넘버 0xAA55가 있어야 BIOS가 이걸 부팅 가능하다고 인식합니다.
기존에는 어셈블리로 부트로더를 직접 작성해야 했다면, 이제는 bootimage 같은 Rust 도구가 이 과정을 자동화해줍니다. 핵심 단계는 Rust 커널 컴파일, 부트로더 연결, 그리고 부팅 가능한 디스크 이미지 생성입니다.
이러한 단계들이 여러분의 고수준 Rust 코드를 저수준 부팅 가능한 형식으로 변환해줍니다.
코드 예제
# Cargo.toml에 bootimage 의존성 추가
# [dependencies]
# bootloader = "0.9"
# 부팅 이미지 빌드 도구 설치
cargo install bootimage
# 커널 빌드 (베어메탈 타겟)
cargo build --target x86_64-unknown-none
# 부팅 가능한 이미지 생성
cargo bootimage
# 생성된 이미지를 QEMU로 실행
qemu-system-x86_64 -drive format=raw,file=target/x86_64-unknown-none/debug/bootimage-my_os.bin
설명
이것이 하는 일: 이 과정은 Rust 코드를 BIOS가 부팅할 수 있는 완전한 디스크 이미지로 패키징합니다. 첫 번째로, bootloader 크레이트를 의존성에 추가하면, 이것이 BIOS 부팅 프로토콜을 처리하는 작은 부트로더를 제공합니다.
이 부트로더는 어셈블리로 작성되어 있으며, BIOS에서 실행 가능한 16비트 리얼 모드 코드부터 시작해서 32비트 보호 모드를 거쳐 최종적으로 64비트 롱 모드로 전환합니다. 왜 이렇게 복잡한가?
x86 아키텍처의 하위 호환성 때문입니다. 그 다음으로, cargo bootimage 명령이 실행되면서 여러분의 커널과 부트로더를 결합합니다.
내부에서는 먼저 커널을 컴파일하고, 부트로더를 컴파일한 후, 둘을 정확한 메모리 레이아웃으로 배치합니다. 부트로더가 먼저 실행되고, 커널을 메모리에 로드한 후 제어권을 넘깁니다.
마지막으로, 생성된 .bin 파일은 완전한 디스크 이미지입니다. BIOS는 이 파일의 첫 섹터(512바이트)를 읽어서 부트 시그니처를 확인하고, 발견하면 메모리에 로드해서 실행합니다.
최종적으로 여러분의 Rust 코드가 "Hello, OS!" 메시지를 화면에 출력하게 됩니다. 여러분이 이 워크플로우를 사용하면 코드 수정 후 단 몇 초 만에 QEMU에서 테스트할 수 있습니다.
cargo bootimage 한 번으로 모든 빌드와 패키징이 자동화되며, 복잡한 부팅 프로토콜을 신경 쓸 필요가 없습니다.
실전 팁
💡 .cargo/config.toml에 [target.x86_64-unknown-none] 섹션을 추가하고 runner = "bootimage runner"를 설정하면 cargo run만으로 빌드+실행이 한 번에 됩니다.
💡 부팅이 안 된다면 objdump -d로 생성된 바이너리를 확인해보세요. 부트 시그니처 0xAA55가 정확히 510-511번째 바이트에 있는지 확인할 수 있습니다.
💡 UEFI 부팅을 원한다면 bootloader 대신 uefi-rs 크레이트를 고려하세요. 더 현대적이지만 초기 설정이 복잡합니다.
💡 빌드 속도를 높이려면 cargo.toml에 [profile.dev]에서 opt-level = 1을 설정하세요. 약간의 최적화로도 부팅 속도가 크게 개선됩니다.
4. 시리얼 포트 출력 설정 - 디버깅의 핵심
시작하며
여러분의 OS가 부팅되긴 하는데, 뭔가 잘못되었는지 확인할 방법이 없습니다. 화면에는 아무것도 안 나오고, 커널 패닉이 발생했는지도 모르겠죠.
VGA 버퍼로 출력하기엔 아직 초기화가 안 됐을 수도 있습니다. 이런 상황은 OS 개발에서 가장 답답한 순간입니다.
일반 프로그램은 println!으로 디버깅하지만, OS는 표준 출력조차 직접 구현해야 하니까요. 그런데 표준 출력을 구현하는 중에 버그가 생기면?
바로 이럴 때 필요한 것이 시리얼 포트 출력입니다. 가장 원시적이지만 가장 신뢰할 수 있는 디버깅 방법으로, 하드웨어 초기화 없이도 바로 사용할 수 있습니다.
개요
간단히 말해서, 시리얼 포트는 OS가 외부와 통신할 수 있는 가장 단순한 하드웨어 인터페이스입니다. 왜 시리얼 포트가 필요한지?
VGA, USB, 네트워크 같은 현대적 인터페이스는 복잡한 드라이버와 초기화가 필요합니다. 하지만 시리얼 포트(COM1, COM2 등)는 단순한 I/O 포트 읽기/쓰기만으로 동작합니다.
예를 들어, 부팅 초기에 "Starting kernel..."을 출력하고 싶다면, 시리얼 포트가 유일한 선택일 수 있습니다. 기존에는 실제 시리얼 케이블을 연결해야 했다면, QEMU는 시리얼 출력을 터미널로 리다이렉트해주므로 즉시 사용할 수 있습니다.
핵심 요소는 UART(범용 비동기 송수신기) 프로그래밍과 QEMU의 -serial 옵션입니다. 이들이 여러분의 OS 내부에서 일어나는 모든 일을 실시간으로 볼 수 있게 해줍니다.
코드 예제
// Rust에서 시리얼 포트로 출력하기
use uart_16550::SerialPort;
use spin::Mutex;
// COM1 포트 (주소 0x3F8) 초기화
static SERIAL1: Mutex<SerialPort> = {
let mut serial_port = unsafe { SerialPort::new(0x3F8) };
serial_port.init();
Mutex::new(serial_port)
};
// 시리얼 출력 매크로
#[macro_export]
macro_rules! serial_println {
($($arg:tt)*) => {
$crate::serial_print!("{}\n", format_args!($($arg)*));
};
}
// 사용 예시
serial_println!("Kernel started successfully!");
serial_println!("Memory: {} MB", memory_size);
설명
이것이 하는 일: 이 코드는 시리얼 포트를 통해 OS 내부 상태를 외부로 전송합니다. 첫 번째로, SerialPort::new(0x3F8) 호출은 COM1 포트를 나타내는 객체를 생성합니다.
0x3F8은 PC 아키텍처에서 전통적으로 COM1에 할당된 I/O 포트 주소입니다. init() 메서드는 보드레이트(전송 속도), 데이터 비트, 패리티 등을 설정합니다.
왜 unsafe인가? I/O 포트 접근은 잘못하면 시스템을 불안정하게 만들 수 있기 때문입니다.
그 다음으로, Mutex로 감싸는 부분이 실행됩니다. 멀티코어 환경에서 여러 CPU가 동시에 시리얼 포트에 쓰려고 하면 출력이 섞일 수 있습니다.
내부에서 스핀락이 동작하며, 한 번에 하나의 코어만 시리얼 포트에 접근할 수 있게 보장합니다. 마지막으로, serial_println! 매크로는 Rust의 표준 println!과 유사한 인터페이스를 제공하지만, 출력이 시리얼 포트로 갑니다.
최종적으로 QEMU가 -serial stdio 옵션으로 실행 중이라면, 이 출력이 여러분의 터미널에 즉시 표시됩니다. 여러분이 이 시리얼 출력을 설정하면 OS 개발이 비교할 수 없이 편해집니다.
커널 패닉 메시지, 메모리 할당 로그, 인터럽트 핸들러 진입점 등 모든 중요한 이벤트를 실시간으로 추적할 수 있습니다. 화면 드라이버가 완성되기 전부터 완전한 디버깅 환경을 갖추는 셈이죠.
실전 팁
💡 QEMU 실행 시 -serial file:serial.log를 사용하면 모든 시리얼 출력이 파일로 저장되어 나중에 분석할 수 있습니다.
💡 uart_16550 크레이트 대신 직접 I/O 포트를 제어하고 싶다면 x86_64 크레이트의 Port 타입을 사용하세요. 더 가볍고 유연합니다.
💡 시리얼 출력이 안 보인다면 QEMU 옵션에 -serial stdio가 있는지 확인하세요. 기본값은 VC(가상 콘솔)입니다.
💡 성능이 중요한 부분에서는 시리얼 출력을 조건부 컴파일(#[cfg(debug_assertions)])로 감싸세요. 시리얼 I/O는 느립니다.
💡 색상 코드를 사용하면 출력을 구분하기 쉽습니다. ANSI 이스케이프 시퀀스(\x1b[31m 등)를 시리얼로 보내면 터미널에서 색상이 표시됩니다.
5. GDB 디버깅 연결 - 단계별 실행과 브레이크포인트
시작하며
여러분의 OS가 어딘가에서 크래시합니다. 시리얼 로그를 봐도 "Page fault at 0x00000000"이라는 메시지만 있고, 왜 그런지는 모르겠습니다.
어느 함수에서? 어떤 변수 값 때문에?
일반 프로그램이라면 디버거를 붙여서 브레이크포인트를 걸고 단계별로 실행하겠지만, OS는 자체적으로 실행되는데 어떻게 디버깅하죠? 디버거도 OS 위에서 실행되는데 말입니다.
바로 이럴 때 필요한 것이 QEMU의 GDB 서버 기능입니다. QEMU는 실행 중인 OS를 "외부에서" 디버깅할 수 있는 인터페이스를 제공하며, 여러분은 호스트 머신에서 GDB로 연결하기만 하면 됩니다.
개요
간단히 말해서, QEMU의 GDB 지원은 가상 머신 내부에서 실행 중인 코드를 외부 디버거로 제어할 수 있게 해줍니다. 왜 이 기능이 필요한지?
OS 개발에서 가장 어려운 버그는 타이밍 이슈, 메모리 손상, 인터럽트 핸들러 문제 등입니다. 로그만으론 절대 찾을 수 없는 것들이죠.
예를 들어, "왜 페이지 테이블 엔트리가 갑자기 바뀌었지?"라는 질문은 메모리를 실시간으로 관찰해야만 답할 수 있습니다. 기존에는 JTAG 같은 하드웨어 디버거가 필요했다면, QEMU는 소프트웨어적으로 동일한 기능을 제공합니다.
핵심 기능은 원격 디버깅 프로토콜(GDB Remote Serial Protocol), 브레이크포인트 설정, 메모리/레지스터 검사입니다. 이들이 여러분에게 OS 내부에 대한 완전한 가시성을 제공합니다.
코드 예제
# 터미널 1: QEMU를 GDB 서버 모드로 실행
# -s: 포트 1234에 GDB 서버 오픈
# -S: CPU를 일시정지 상태로 시작
qemu-system-x86_64 \
-drive format=raw,file=os.bin \
-s -S
# 터미널 2: GDB 연결
rust-gdb target/x86_64-unknown-none/debug/my_os
# GDB 내부에서:
(gdb) target remote :1234
(gdb) break kernel_main
(gdb) continue
(gdb) layout src
(gdb) step
(gdb) print my_variable
(gdb) x/10x 0x100000 # 메모리 검사
설명
이것이 하는 일: 이 설정은 실행 중인 OS를 완전히 제어할 수 있는 디버깅 환경을 구축합니다. 첫 번째로, QEMU의 -s 옵션은 내장 GDB 서버를 활성화하고 TCP 포트 1234에서 연결을 대기합니다.
-S 옵션은 CPU를 일시정지 상태로 시작하므로, 디버거를 연결하기 전에 코드가 실행되지 않습니다. 왜 이렇게 하는가?
부팅 초기 코드를 디버깅하려면 첫 명령어부터 제어권이 필요하기 때문입니다. 그 다음으로, rust-gdb를 실행하고 target remote :1234로 연결하면, GDB는 QEMU 내부의 CPU 상태와 동기화됩니다.
내부에서는 GDB가 레지스터 값, 프로그램 카운터, 메모리 맵 등을 읽어옵니다. break kernel_main으로 브레이크포인트를 설정하면, GDB는 QEMU에게 "0xXXXXXXXX 주소에 도달하면 멈춰"라고 지시합니다.
마지막으로, continue, step, next 같은 GDB 명령으로 실행을 제어할 수 있습니다. 최종적으로 브레이크포인트에 도달하면 현재 소스 코드 위치, 변수 값, 스택 트레이스를 모두 볼 수 있으며, 마치 일반 프로그램을 디버깅하는 것처럼 OS 내부를 탐색할 수 있습니다.
여러분이 이 디버깅 환경을 사용하면 며칠이 걸릴 버그를 몇 분 만에 찾을 수 있습니다. 예를 들어, 스택 오버플로우가 발생한다면 백트레이스를 보고 어떤 함수 호출 체인이 문제인지 즉시 파악할 수 있죠.
메모리 손상 버그라면 워치포인트를 설정해서 "누가 이 메모리를 덮어썼는지" 정확히 잡아낼 수 있습니다.
실전 팁
💡 .gdbinit 파일을 프로젝트 루트에 만들고 target remote :1234를 추가하면 GDB 시작 시 자동 연결됩니다.
💡 QEMU 모니터(Ctrl+Alt+2)에서 info registers 명령으로 CPU 상태를 직접 볼 수도 있습니다. GDB 없이 빠른 확인이 가능합니다.
💡 watch *0x12345678 명령으로 특정 메모리 주소에 워치포인트를 설정하면, 그 주소가 변경될 때마다 멈춥니다. 메모리 손상 버그 찾기의 핵심입니다.
💡 심볼이 없다고 나온다면 커널을 릴리스 모드가 아닌 디버그 모드로 빌드했는지 확인하세요. cargo build (without --release).
💡 TUI 모드(layout src, layout asm)를 사용하면 소스 코드와 어셈블리를 동시에 볼 수 있어 저수준 디버깅에 유용합니다.
6. VGA 텍스트 버퍼 - 화면에 출력하기
시작하며
여러분의 OS가 성공적으로 부팅했고, 시리얼 로그에도 "Kernel initialized"라고 나옵니다. 하지만 사용자에게는?
검은 화면만 보이죠. 실제 OS처럼 화면에 뭔가 표시하고 싶습니다.
많은 초보 OS 개발자들이 여기서 막힙니다. 그래픽 드라이버를 구현해야 하나?
픽셀을 직접 제어해야 하나? 너무 복잡해 보입니다.
바로 이럴 때 필요한 것이 VGA 텍스트 버퍼입니다. 그래픽이 아닌 텍스트 모드로, 복잡한 드라이버 없이도 즉시 화면에 글자를 띄울 수 있습니다.
개요
간단히 말해서, VGA 텍스트 버퍼는 메모리 주소 0xB8000에 매핑된 80x25 문자 배열로, 여기에 쓰면 자동으로 화면에 표시됩니다. 왜 이게 가능한지?
PC 부팅 시 BIOS는 VGA를 텍스트 모드로 초기화합니다. 이 모드에서는 메모리의 특정 영역(0xB8000~0xBFFFF)이 화면 버퍼로 직접 매핑되어 있습니다.
예를 들어, 0xB8000에 'A'의 ASCII 코드를 쓰면, 화면 왼쪽 위에 'A'가 즉시 나타납니다. 기존에는 BIOS 인터럽트로 문자를 출력했다면, 보호 모드/롱 모드에서는 메모리 매핑 I/O로 직접 접근하는 게 더 빠르고 안정적입니다.
핵심 개념은 메모리 매핑 I/O와 VGA 문자 셀(2바이트: 1바이트 ASCII + 1바이트 색상) 구조입니다. 이들이 간단한 메모리 쓰기만으로 화면 출력을 가능케 합니다.
코드 예제
// VGA 버퍼 구조체
#[repr(transparent)]
struct Buffer {
chars: [[Volatile<ScreenChar>; BUFFER_WIDTH]; BUFFER_HEIGHT],
}
#[repr(C)]
#[derive(Copy, Clone)]
struct ScreenChar {
ascii_character: u8,
color_code: ColorCode,
}
// VGA Writer 구현
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;
}
}
}
}
// 사용 예시
let mut writer = Writer::new();
writer.write_string("Hello from my OS!");
설명
이것이 하는 일: 이 코드는 메모리에 직접 쓰는 것만으로 화면에 글자를 출력합니다. 첫 번째로, Buffer 구조체는 0xB8000 주소의 메모리를 80x25 배열로 해석합니다.
Volatile 타입을 사용하는 이유는 컴파일러 최적화를 방지하기 위해서입니다. 왜 필요한가?
컴파일러는 "읽지 않는 메모리 쓰기"를 제거할 수 있는데, 이 메모리는 실제로는 하드웨어가 읽기 때문입니다. 그 다음으로, ScreenChar는 2바이트 구조로, 하위 바이트가 ASCII 코드, 상위 바이트가 색상 정보입니다.
내부에서 색상 코드는 4비트 전경색 + 4비트 배경색으로 구성되며, 예를 들어 0x0F는 검은 배경에 흰 글자를 의미합니다. 마지막으로, write_byte 메서드는 현재 커서 위치에 문자를 씁니다.
최종적으로 메모리에 쓰는 순간, VGA 하드웨어가 이를 감지하고 해당 위치의 픽셀을 업데이트하여 글자가 화면에 나타납니다. 여러분이 이 VGA 버퍼를 구현하면 표준 출력을 갖춘 OS가 됩니다.
println! 매크로를 VGA Writer로 연결하면, Rust의 모든 포맷팅 기능을 화면 출력에 사용할 수 있습니다. 에러 메시지를 빨간색으로, 경고를 노란색으로 표시하는 것도 간단하죠.
실전 팁
💡 volatile 크레이트를 사용하지 않고 직접 구현한다면 core::ptr::write_volatile을 사용하세요. 안정적인 Rust에서도 동작합니다.
💡 스크롤 기능을 구현할 때는 memmove 대신 행을 하나씩 복사하세요. VGA 버퍼는 일반 메모리가 아니라 더 느릴 수 있습니다.
💡 색상 코드에서 0x08을 더하면 밝은 버전이 됩니다. 예: 0x04(빨강) + 0x08 = 0x0C(밝은 빨강).
💡 QEMU에서 VGA 출력이 안 보인다면 -vga std 옵션을 추가해보세요. 일부 설정에서는 기본 VGA가 비활성화될 수 있습니다.
💡 유니코드를 지원하려면 VGA 텍스트 모드로는 불가능하고, 프레임버퍼 그래픽 모드로 전환해서 폰트 렌더링을 직접 구현해야 합니다.
7. 테스트 프레임워크 구축 - 자동화된 OS 테스팅
시작하며
여러분의 OS에 새 기능을 추가했습니다. 잘 작동하는 것 같은데, 이전 기능들도 여전히 잘 동작하는지 어떻게 확인하나요?
매번 수동으로 QEMU를 실행하고 모든 기능을 테스트하기엔 너무 번거롭습니다. 일반 프로그램이라면 단위 테스트를 작성하겠지만, OS는 표준 테스트 러너가 없습니다.
cargo test는 표준 라이브러리에 의존하는데, OS에는 표준 라이브러리가 없으니까요. 바로 이럴 때 필요한 것이 커스텀 테스트 프레임워크입니다.
QEMU를 자동으로 실행하고, 테스트 결과를 수집하고, 성공/실패를 판정하는 완전한 CI/CD 가능한 테스트 환경을 만들어봅시다.
개요
간단히 말해서, OS 테스트 프레임워크는 베어메탈 환경에서 테스트를 실행하고 결과를 호스트로 전달하는 시스템입니다. 왜 이게 필요한지?
OS 개발은 회귀 버그가 매우 흔합니다. 메모리 관리를 수정했더니 인터럽트가 안 되거나, 새 드라이버를 추가했더니 부팅이 안 되는 경우가 빈번합니다.
예를 들어, 100개의 테스트가 있다면, 모든 커밋에서 자동으로 실행되어야 개발 속도를 유지할 수 있습니다. 기존에는 수동 테스트나 복잡한 스크립트가 필요했다면, Rust의 custom_test_frameworks 기능으로 깔끔하게 구현할 수 있습니다.
핵심 요소는 테스트 러너 함수, QEMU 종료 메커니즘(isa-debug-exit 디바이스), 그리고 성공/실패 코드입니다. 이들이 완전 자동화된 테스트 파이프라인을 가능케 합니다.
코드 예제
// main.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);
port.write(exit_code as u32);
}
}
// 테스트 예시
#[test_case]
fn test_breakpoint_exception() {
serial_print!("test_breakpoint_exception...");
x86_64::instructions::interrupts::int3();
serial_println!("[ok]");
}
// QEMU 실행 시: -device isa-debug-exit,iobase=0xf4,iosize=0x04
설명
이것이 하는 일: 이 프레임워크는 테스트를 실행하고, 성공/실패를 판정하고, QEMU를 종료하는 전체 사이클을 자동화합니다. 첫 번째로, #![test_runner(crate::test_runner)] 속성은 Rust에게 "기본 테스트 러너 대신 이 함수를 사용해"라고 지시합니다.
컴파일 시 모든 #[test_case] 함수들이 배열로 수집되어 test_runner에 전달됩니다. 왜 커스텀 러너가 필요한가?
표준 테스트 러너는 std와 프로세스 종료에 의존하는데, OS에는 둘 다 없기 때문입니다. 그 다음으로, 각 테스트 함수가 실행되면서 시리얼 포트로 결과를 출력합니다.
내부에서 테스트가 패닉하면 패닉 핸들러가 "[failed]"를 출력하고 QEMU를 실패 코드로 종료합니다. 성공하면 "[ok]"를 출력하고 다음 테스트로 진행합니다.
마지막으로, 모든 테스트가 완료되면 exit_qemu 함수가 특별한 I/O 포트(0xf4)에 종료 코드를 씁니다. 최종적으로 QEMU의 isa-debug-exit 디바이스가 이를 감지하고 해당 종료 코드로 프로세스를 종료하며, 호스트의 셸 스크립트나 CI가 이 코드를 읽어 성공/실패를 판단합니다.
여러분이 이 테스트 프레임워크를 구축하면 cargo test만으로 전체 OS를 테스트할 수 있습니다. GitHub Actions나 GitLab CI에 통합하면, 모든 PR이 자동으로 검증되고, 버그가 병합되기 전에 잡힙니다.
개발 속도가 극적으로 향상되죠.
실전 팁
💡 .cargo/config.toml에 [target.x86_64-unknown-none.runner]를 설정하여 테스트용 QEMU 옵션을 별도로 관리하세요.
💡 성공 시 종료 코드를 33으로 설정하면 (33 << 1) | 1 = 67이 반환됩니다. CI 스크립트에서 이를 확인하세요.
💡 통합 테스트(tests/ 디렉토리)를 활용하면 각 테스트가 별도의 OS 인스턴스로 실행되어 독립성이 보장됩니다.
💡 should_panic 속성을 지원하려면 패닉 핸들러에서 예상된 패닉인지 확인하는 로직을 추가해야 합니다.
💡 성능 테스트를 위해 QEMU의 가상 시계(-rtc base=2021-01-01)를 고정하면 재현 가능한 타이밍을 얻을 수 있습니다.
8. QEMU 모니터 콘솔 - 런타임 디버깅과 제어
시작하며
여러분의 OS가 실행 중인데, 갑자기 응답이 없어졌습니다. 멈춘 건지, 무한 루프인지, 아니면 인터럽트를 기다리는 건지 알 수가 없네요.
QEMU를 종료하고 다시 시작하기 전에 뭔가 확인할 방법이 없을까요? 이런 상황은 OS 개발에서 자주 발생합니다.
특히 타이밍에 민감한 코드나 비동기 처리를 다룰 때는 "지금 이 순간" 시스템 상태를 봐야 할 때가 많습니다. 바로 이럴 때 필요한 것이 QEMU 모니터 콘솔입니다.
실행 중인 가상 머신의 내부를 들여다보고, 메모리를 덤프하고, 심지어 실행을 일시정지/재개할 수 있는 강력한 도구입니다.
개요
간단히 말해서, QEMU 모니터는 실행 중인 가상 머신을 제어하고 검사할 수 있는 대화형 콘솔입니다. 왜 모니터가 필요한지?
GDB는 소스 레벨 디버깅에 강력하지만, 하드웨어 레벨 상태를 보기엔 불편합니다. 예를 들어, CPU 레지스터의 정확한 값, 페이지 테이블 상태, I/O 디바이스 레지스터 등을 빠르게 확인하고 싶을 때 모니터가 최적입니다.
기존에는 하드웨어 디버거나 ICE(In-Circuit Emulator)가 필요했던 기능들을, QEMU 모니터는 소프트웨어로 제공합니다. 핵심 명령어는 info registers(레지스터), x(메모리 덤프), stop/cont(실행 제어), savevm/loadvm(스냅샷)입니다.
이들이 실행 중인 OS에 대한 완전한 통찰력을 제공합니다.
코드 예제
# QEMU 모니터 활성화 옵션
qemu-system-x86_64 \
-drive format=raw,file=os.bin \
-monitor stdio
# 또는 별도 창으로: -monitor vc
# 주요 모니터 명령어들 (QEMU 모니터 프롬프트에서)
(qemu) info registers
# CPU 레지스터 전체 출력: RAX, RBX, RIP, CR3 등
(qemu) x/20x 0x100000
# 0x100000부터 20개 워드를 16진수로 출력
(qemu) info mem
# 페이지 테이블 매핑 정보 출력
(qemu) stop
# CPU 실행 일시정지
(qemu) cont
# 실행 재개
(qemu) savevm snapshot1
# 현재 상태를 스냅샷으로 저장
(qemu) loadvm snapshot1
# 스냅샷 복원
설명
이것이 하는 일: 모니터 콘솔은 가상 머신의 하드웨어 레벨 상태에 직접 접근할 수 있는 창구를 제공합니다. 첫 번째로, -monitor stdio 옵션은 QEMU의 표준 입출력을 모니터 콘솔로 전환합니다.
이 모드에서는 키보드 입력이 가상 머신이 아니라 모니터 명령으로 해석됩니다. 왜 이렇게 하는가?
가상 머신이 응답 불능 상태여도 제어권을 유지하기 위해서입니다. 그 다음으로, info registers 같은 명령이 실행되면, QEMU는 에뮬레이션 중인 CPU의 내부 상태를 읽어와 출력합니다.
내부에서는 모든 레지스터 값이 QEMU의 메모리에 저장되어 있으므로, 즉시 접근할 수 있습니다. 예를 들어, RIP(명령어 포인터) 레지스터를 보면 현재 어느 주소를 실행 중인지 정확히 알 수 있습니다.
마지막으로, savevm/loadvm 명령은 전체 가상 머신 상태(메모리, 레지스터, 디바이스 상태)를 파일로 저장하고 복원합니다. 최종적으로 "부팅 직후" 스냅샷을 만들어두면, 테스트할 때마다 긴 부팅 과정을 건너뛸 수 있습니다.
여러분이 모니터를 활용하면 디버깅 시간이 크게 단축됩니다. 예를 들어, "페이지 폴트가 왜 발생했지?"라는 질문에 info registers로 CR2(폴트 주소)를 보고, x로 해당 메모리를 확인하고, info mem으로 페이지 테이블을 검사하면 몇 초 만에 답을 얻을 수 있습니다.
실전 팁
💡 -monitor telnet:127.0.0.1:55555,server,nowait로 텔넷 서버를 열면, 다른 터미널에서 telnet localhost 55555로 연결할 수 있어 편리합니다.
💡 info qtree 명령으로 QEMU의 전체 디바이스 트리를 볼 수 있습니다. 어떤 하드웨어가 에뮬레이션 되는지 확인하는 데 유용합니다.
💡 x 명령의 형식은 /[count][format][size]입니다. 예: x/10i $rip는 현재 명령어 포인터부터 10개 명령어를 디스어셈블합니다.
💡 스냅샷은 -drive 옵션에 snapshot=on을 추가하면 자동으로 쓰기가 임시파일로 리다이렉트되어 원본 이미지가 보호됩니다.
💡 Ctrl+Alt+2로 모니터로 전환, Ctrl+Alt+1로 가상 머신 화면으로 돌아갈 수 있습니다(GUI 모드에서).
9. 네트워크 설정 - 가상 네트워크로 OS 통신 테스트
시작하며
여러분의 OS가 네트워크 스택을 구현했습니다. TCP/IP도 있고, 드라이버도 작성했는데, 실제로 작동하는지 어떻게 테스트하나요?
실제 네트워크 카드를 에뮬레이터에서 사용할 수는 없습니다. 많은 OS 개발자들이 네트워크 테스트 단계에서 막힙니다.
로컬호스트로 테스트하려 해도 OS가 아직 완전하지 않아서 복잡하고요. 바로 이럴 때 필요한 것이 QEMU의 가상 네트워크 기능입니다.
호스트와 게스트 간 네트워크를 구축하여, ping부터 HTTP 서버까지 모든 네트워크 기능을 안전하게 테스트할 수 있습니다.
개요
간단히 말해서, QEMU 네트워크는 가상 네트워크 인터페이스를 만들어 호스트와 게스트를 연결하는 기능입니다. 왜 이게 필요한지?
OS의 네트워크 스택은 가장 복잡한 부분 중 하나입니다. 실제 하드웨어 없이 테스트하려면 시뮬레이션이 필수적이죠.
예를 들어, 여러분이 작성한 TCP 구현이 정말 3-way 핸드셰이크를 올바르게 하는지 확인하려면, 실제로 패킷을 주고받아봐야 합니다. 기존에는 물리적 네트워크 카드와 별도 머신이 필요했다면, QEMU는 소프트웨어로 완전한 이더넷 카드를 에뮬레이션합니다.
핵심 요소는 네트워크 백엔드(user, tap, bridge)와 프론트엔드(e1000, virtio-net) 설정입니다. 이들이 완전히 격리되면서도 실제와 동일한 네트워크 환경을 제공합니다.
코드 예제
# User 모드 네트워크 (가장 간단, NAT 방식)
qemu-system-x86_64 \
-drive format=raw,file=os.bin \
-netdev user,id=net0 \
-device e1000,netdev=net0
# 포트 포워딩 추가 (게스트:80 -> 호스트:8080)
qemu-system-x86_64 \
-drive format=raw,file=os.bin \
-netdev user,id=net0,hostfwd=tcp::8080-:80 \
-device e1000,netdev=net0
# TAP 모드 (브리지 네트워크, 고급)
sudo ip tuntap add dev tap0 mode tap
sudo ip link set tap0 up
qemu-system-x86_64 \
-drive format=raw,file=os.bin \
-netdev tap,id=net0,ifname=tap0,script=no \
-device virtio-net-pci,netdev=net0
# 네트워크 트래픽 덤프 (디버깅용)
qemu-system-x86_64 \
-drive format=raw,file=os.bin \
-netdev user,id=net0 \
-device e1000,netdev=net0 \
-object filter-dump,id=f1,netdev=net0,file=network.pcap
설명
이것이 하는 일: 이 설정은 가상 네트워크 카드를 만들고 호스트 네트워크와 연결합니다. 첫 번째로, -netdev user,id=net0는 사용자 모드 네트워킹을 활성화합니다.
이 모드에서 QEMU는 내장 DHCP 서버와 NAT를 제공하여, 게스트 OS가 자동으로 IP 주소를 받고 인터넷에 접근할 수 있게 합니다. 왜 user 모드인가?
별도의 권한이나 설정 없이 즉시 작동하기 때문입니다. 그 다음으로, -device e1000,netdev=net0가 실행되면서 Intel E1000 네트워크 카드를 에뮬레이션합니다.
내부에서 QEMU는 PCI 버스에 이 디바이스를 등록하고, 여러분의 OS가 PCI 스캔을 하면 실제 E1000 카드처럼 발견됩니다. 여러분의 네트워크 드라이버는 이 가상 카드의 레지스터를 읽고 쓰면서 패킷을 송수신합니다.
마지막으로, hostfwd=tcp::8080-:80 옵션은 호스트의 8080 포트를 게스트의 80 포트로 포워딩합니다. 최종적으로 여러분의 OS가 포트 80에서 웹 서버를 실행하면, 호스트의 브라우저에서 localhost:8080으로 접속할 수 있습니다.
여러분이 이 네트워크 설정을 사용하면 OS의 전체 네트워크 스택을 엔드-투-엔드로 테스트할 수 있습니다. 드라이버부터 TCP/IP, 소켓 API, 그리고 애플리케이션까지 모든 계층이 실제로 통신하는지 확인할 수 있죠.
filter-dump로 패킷을 캡처하면 Wireshark로 분석하여 프로토콜 구현의 정확성을 검증할 수도 있습니다.
실전 팁
💡 virtio-net 드라이버를 구현했다면 -device e1000 대신 -device virtio-net-pci를 사용하세요. 성능이 10배 이상 빠릅니다.
💡 User 모드에서 게스트는 호스트에 접근할 수 없습니다. 양방향 통신이 필요하면 TAP/bridge 모드를 사용하세요.
💡 network.pcap 파일을 Wireshark로 열면 모든 패킷을 시각적으로 분석할 수 있습니다. 프로토콜 버그를 찾는 데 매우 유용합니다.
💡 MAC 주소를 고정하려면 -device e1000,netdev=net0,mac=52:54:00:12:34:56처럼 지정하세요. DHCP 테스트 시 일관성을 유지할 수 있습니다.
💡 멀티캐스트나 브로드캐스트를 테스트하려면 TAP 모드가 필수입니다. User 모드는 유니캐스트만 지원합니다.
10. QEMU 스냅샷과 상태 관리 - 빠른 반복 개발
시작하며
여러분이 커널의 메모리 할당자를 디버깅하고 있습니다. 버그를 재현하려면 매번 OS를 부팅하고, 여러 프로그램을 실행하고, 특정 상황을 만들어야 합니다.
5분이 걸리는데, 한 글자 수정할 때마다 이걸 반복해야 하나요? 이런 느린 반복 주기는 개발 속도를 심각하게 저하시킵니다.
특히 부팅이 느린 OS나 복잡한 초기화가 필요한 경우 더욱 그렇죠. 바로 이럴 때 필요한 것이 QEMU 스냅샷 기능입니다.
"버그 직전" 상태를 저장해두고, 몇 초 만에 그 시점으로 돌아가서 수정된 코드를 테스트할 수 있습니다.
개요
간단히 말해서, QEMU 스냅샷은 가상 머신의 전체 상태를 저장하고 복원하는 기능으로, 타임머신처럼 특정 시점으로 즉시 돌아갈 수 있게 해줍니다. 왜 이게 필요한지?
OS 개발에서 버그 재현은 종종 긴 시퀀스를 거쳐야 합니다. 예를 들어, "10,000번째 메모리 할당에서만 발생하는 버그"를 디버깅한다면, 매번 처음부터 시작하는 건 비현실적입니다.
기존에는 테스트 시나리오를 스크립트로 자동화했지만, 여전히 실행 시간이 걸렸습니다. 스냅샷은 이 시간을 거의 0으로 만듭니다.
핵심 기능은 VM 스냅샷(메모리+디스크+레지스터), qcow2 이미지 포맷, 그리고 복원 속도입니다. 이들이 개발-테스트 사이클을 몇 분에서 몇 초로 단축시켜줍니다.
코드 예제
# qcow2 형식으로 디스크 이미지 변환 (스냅샷 지원)
qemu-img convert -f raw -O qcow2 os.bin os.qcow2
# qcow2 이미지로 QEMU 실행
qemu-system-x86_64 \
-drive format=qcow2,file=os.qcow2 \
-m 512M
# QEMU 모니터에서 스냅샷 관리
(qemu) savevm boot_complete
# "boot_complete"라는 이름으로 현재 상태 저장
(qemu) loadvm boot_complete
# 저장된 상태로 즉시 복원
(qemu) info snapshots
# 모든 스냅샷 목록 확인
(qemu) delvm old_snapshot
# 불필요한 스냅샷 삭제
# 명령줄에서 스냅샷으로 시작
qemu-system-x86_64 \
-drive format=qcow2,file=os.qcow2 \
-loadvm boot_complete
설명
이것이 하는 일: 스냅샷 기능은 가상 머신의 "특정 순간"을 동결하여 나중에 정확히 그 시점으로 돌아갈 수 있게 합니다. 첫 번째로, qemu-img convert로 raw 이미지를 qcow2로 변환합니다.
qcow2(QEMU Copy-On-Write 2)는 스냅샷, 압축, 암호화를 지원하는 고급 디스크 이미지 포맷입니다. 왜 변환이 필요한가?
raw 포맷은 단순한 바이트 덩어리라 메타데이터를 저장할 공간이 없기 때문입니다. 그 다음으로, savevm 명령이 실행되면, QEMU는 현재 메모리 전체(512MB), 모든 CPU 레지스터, 디바이스 상태, 그리고 디스크 변경사항을 qcow2 파일 내부에 저장합니다.
내부에서는 Copy-On-Write 메커니즘을 사용하여, 원본 데이터를 보존하면서 변경된 부분만 별도로 저장합니다. 마지막으로, loadvm 명령은 저장된 상태를 메모리와 레지스터로 복원합니다.
최종적으로 몇 초 만에 VM이 정확히 스냅샷을 찍은 순간으로 돌아가며, 마치 시간을 되돌린 것처럼 동작합니다. 여러분이 이 워크플로우를 사용하면 개발 효율이 극적으로 향상됩니다.
예를 들어, 부팅 완료 직후 스냅샷을 만들어두면, 커널 코드를 수정할 때마다 loadvm으로 몇 초 만에 테스트 환경으로 돌아갈 수 있습니다. 하루에 수백 번 테스트하는 경우, 이는 몇 시간의 시간 절약입니다.
실전 팁
💡 스냅샷 이름을 의미 있게 지으세요. "snap1"보다는 "after_paging_init"처럼 구체적인 이름이 나중에 관리하기 쉽습니다.
💡 qcow2의 백업 파일 기능(-o backing_file=base.qcow2)을 사용하면, 여러 테스트 시나리오를 베이스 이미지 하나에서 분기할 수 있습니다.
💡 스냅샷은 디스크 공간을 많이 차지할 수 있습니다. 주기적으로 qemu-img info로 크기를 확인하고 불필요한 스냅샷을 삭제하세요.
💡 -snapshot 옵션으로 QEMU를 실행하면 모든 변경사항이 임시 파일로 가서, 종료 시 자동으로 버려집니다. 실험용으로 완벽합니다.
💡 CI/CD에서는 "깨끗한 상태" 스냅샷을 만들어두고, 각 테스트 전에 loadvm으로 복원하면 테스트 간 격리를 보장할 수 있습니다.
11. KVM 가속 - 실행 속도 극대화
시작하며
여러분의 OS가 점점 복잡해지면서, QEMU에서 실행이 느려지기 시작합니다. 간단한 작업도 몇 초씩 걸리고, 빌드-테스트 주기가 답답할 정도로 길어집니다.
에뮬레이터니까 어쩔 수 없나요? 실제로 많은 개발자들이 QEMU를 "느린 에뮬레이터"로만 알고 있습니다.
하지만 설정 하나로 실행 속도를 10배 이상 높일 수 있다는 사실을 모르죠. 바로 이럴 때 필요한 것이 KVM(Kernel-based Virtual Machine) 가속입니다.
CPU의 하드웨어 가상화 기능을 활용하여, 에뮬레이션이 아닌 거의 네이티브에 가까운 속도로 OS를 실행할 수 있습니다.
개요
간단히 말해서, KVM은 CPU의 가상화 확장(Intel VT-x, AMD-V)을 사용하여 게스트 코드를 직접 실행하는 기술입니다. 왜 이게 중요한지?
기본 QEMU는 소프트웨어 에뮬레이션을 사용합니다. 게스트의 모든 명령어를 해석하고 번역해야 하므로 느립니다.
예를 들어, 간단한 덧셈 하나가 수십 개의 호스트 명령어로 변환될 수 있습니다. 기존에는 VMware나 VirtualBox 같은 별도 가상화 소프트웨어가 필요했다면, 리눅스는 KVM을 커널에 내장하여 QEMU와 완벽히 통합했습니다.
핵심 요소는 하드웨어 가상화 지원 확인, KVM 모듈 로드, 그리고 -enable-kvm 옵션입니다. 이들이 OS 실행을 에뮬레이션에서 가상화로 전환합니다.
코드 예제
# CPU가 가상화를 지원하는지 확인 (Linux)
grep -E 'vmx|svm' /proc/cpuinfo
# vmx (Intel) 또는 svm (AMD)가 나와야 함
# KVM 모듈이 로드되었는지 확인
lsmod | grep kvm
# kvm_intel 또는 kvm_amd가 보여야 함
# 없다면 모듈 로드
sudo modprobe kvm-intel # Intel CPU
sudo modprobe kvm-amd # AMD CPU
# KVM 가속으로 QEMU 실행
qemu-system-x86_64 \
-drive format=qcow2,file=os.qcow2 \
-m 512M \
-enable-kvm \
-cpu host
# macOS에서는 HVF(Hypervisor Framework) 사용
qemu-system-x86_64 \
-drive format=qcow2,file=os.qcow2 \
-m 512M \
-accel hvf \
-cpu host
# 성능 비교 (간단한 벤치마크)
time qemu-system-x86_64 -drive file=os.qcow2 # 에뮬레이션
time qemu-system-x86_64 -drive file=os.qcow2 -enable-kvm # 가속
설명
이것이 하는 일: KVM은 게스트 코드를 소프트웨어로 번역하는 대신, CPU가 직접 실행하도록 합니다. 첫 번째로, CPU의 가상화 확장(VT-x/AMD-V) 지원을 확인합니다.
현대 CPU는 거의 모두 이 기능을 내장하고 있지만, BIOS에서 비활성화되어 있을 수 있습니다. 왜 하드웨어 지원이 필요한가?
게스트 OS가 특권 명령어(페이지 테이블 수정 등)를 실행할 때, CPU가 자동으로 호스트로 전환하고 다시 돌아가는 메커니즘이 필요하기 때문입니다. 그 다음으로, -enable-kvm 옵션이 활성화되면, QEMU는 /dev/kvm 디바이스를 열고 KVM API를 사용합니다.
내부에서 게스트 코드의 대부분은 CPU에서 직접 실행되며, I/O나 특권 명령어만 QEMU가 가로채서 에뮬레이션합니다. 이를 "하드웨어 보조 가상화"라고 합니다.
마지막으로, -cpu host 옵션은 호스트 CPU의 모든 기능을 게스트에 노출합니다. 최종적으로 게스트가 SSE, AVX 같은 최신 명령어를 직접 사용할 수 있어, 성능이 네이티브에 거의 근접합니다.
여러분이 KVM을 활성화하면 개발 경험이 완전히 바뀝니다. 이전에는 1분 걸리던 부팅이 5초로 줄고, 인터랙티브한 디버깅이 가능해지며, 실제 하드웨어에서의 성능에 더 가까운 결과를 얻을 수 있습니다.
특히 컴파일러 최적화나 성능 튜닝을 테스트할 때 필수적입니다.
실전 팁
💡 권한 문제가 발생하면 사용자를 kvm 그룹에 추가하세요: sudo usermod -aG kvm $USER (재로그인 필요).
💡 KVM이 활성화되었는지 확인하려면 QEMU 실행 중 info kvm을 모니터에서 입력하세요. "enabled"가 나와야 합니다.
💡 Windows 호스트에서는 HAXM(Intel) 또는 WHPX(Windows Hypervisor Platform)를 사용할 수 있습니다. -accel hax 또는 -accel whpx.
💡 중첩 가상화(nested virtualization)를 테스트하려면 호스트에서 echo "options kvm-intel nested=1" | sudo tee /etc/modprobe.d/kvm.conf로 활성화하세요.
💡 macOS의 HVF는 KVM만큼 빠르지 않지만 에뮬레이션보다는 훨씬 낫습니다. Apple Silicon Mac에서는 ARM용 QEMU를 사용하세요.
12. 멀티부팅과 GRUB 통합 - 실제 하드웨어 테스트 준비
시작하며
여러분의 OS가 QEMU에서 완벽하게 동작합니다. 이제 실제 하드웨어에서 테스트하고 싶은데, USB에 어떻게 설치하나요?
다른 OS와 함께 멀티부팅하려면? QEMU에서만 테스트하면 일부 하드웨어 특성(실제 디스크 타이밍, 특정 CPU 버그 등)을 놓칠 수 있습니다.
실제 배포를 위해서는 GRUB 같은 표준 부트로더와의 통합이 필수적입니다. 바로 이럴 때 필요한 것이 GRUB 부트로더 설정입니다.
QEMU에서 GRUB 환경을 시뮬레이션하여, 실제 하드웨어 배포 전에 검증할 수 있습니다.
개요
간단히 말해서, GRUB은 여러 OS 중 하나를 선택해 부팅할 수 있는 표준 부트로더로, 여러분의 커스텀 OS를 실제 컴퓨터에 설치하는 데 필요합니다. 왜 GRUB이 필요한지?
단순한 부트섹터 부팅은 512바이트 제한이 있고, 파일시스템을 이해하지 못하며, 설정 변경이 어렵습니다. 예를 들어, 커널 파일이 디스크의 어디에 있든 GRUB은 파일시스템을 읽어서 로드할 수 있습니다.
기존에는 LILO나 직접 부팅만 가능했다면, GRUB은 모듈식 아키텍처와 다양한 파일시스템 지원으로 사실상 표준이 되었습니다. 핵심 요소는 멀티부트2 헤더, GRUB 설정 파일, 그리고 ISO 이미지 생성입니다.
이들이 여러분의 OS를 GRUB이 인식하고 부팅할 수 있게 만듭니다.
코드 예제
# Rust 커널에 Multiboot2 헤더 추가
// boot.asm (어셈블리)
section .multiboot_header
header_start:
dd 0xe85250d6 ; 매직 넘버
dd 0 ; i386 보호 모드
dd header_end - header_start ; 헤더 길이
dd 0x100000000 - (0xe85250d6 + 0 + (header_end - header_start))
; 종료 태그
dw 0
dw 0
dd 8
header_end:
# GRUB 설정 파일 생성 (grub.cfg)
menuentry "My Rust OS" {
multiboot2 /boot/kernel.bin
boot
}
# ISO 이미지 생성 (CD/USB 부팅용)
mkdir -p isofiles/boot/grub
cp kernel.bin isofiles/boot/
cp grub.cfg isofiles/boot/grub/
grub-mkrescue -o myos.iso isofiles
# QEMU에서 ISO 부팅 테스트
qemu-system-x86_64 -cdrom myos.iso
# 실제 USB에 쓰기 (주의: 데이터 삭제됨!)
# sudo dd if=myos.iso of=/dev/sdX bs=4M status=progress
설명
이것이 하는 일: 이 설정은 여러분의 OS를 GRUB이 로드할 수 있는 형식으로 패키징합니다. 첫 번째로, Multiboot2 헤더는 커널 바이너리의 시작 부분에 특별한 매직 넘버와 메타데이터를 포함합니다.
GRUB은 이 헤더를 찾아서 "이게 부팅 가능한 커널이구나"라고 인식합니다. 왜 표준화된 헤더가 필요한가?
GRUB이 수백 가지 다른 OS를 동일한 방식으로 로드하기 위해서입니다. 그 다음으로, grub.cfg 파일이 GRUB 메뉴를 설정합니다.
내부에서 GRUB은 이 파일을 읽어서 부팅 옵션을 화면에 표시하고, 사용자가 선택하면 해당 커널을 메모리에 로드합니다. multiboot2 명령은 Multiboot2 프로토콜을 사용해 커널을 로드하라는 지시입니다.
마지막으로, grub-mkrescue는 부팅 가능한 ISO 이미지를 생성합니다. 최종적으로 이 ISO는 CD에 구울 수도, USB에 쓸 수도, 가상 머신에 마운트할 수도 있는 완전한 부팅 미디어입니다.
여러분이 GRUB 통합을 완료하면 OS를 실제 컴퓨터에서 테스트할 준비가 끝납니다. QEMU에서 ISO를 먼저 검증하고, 문제없으면 USB에 써서 실제 하드웨어에서 부팅해보세요.
실제 디스크 I/O, 실제 네트워크 카드, 실제 타이밍으로 테스트하면서 QEMU에서 발견하지 못한 버그를 찾을 수 있습니다.
실전 팁
💡 Multiboot2 대신 Multiboot(v1)을 사용하려면 매직 넘버를 0x1BADB002로 변경하세요. 더 단순하지만 기능이 적습니다.
💡 UEFI 부팅을 지원하려면 grub-mkrescue에 --xorriso 옵션을 추가하고 EFI 파티션을 생성해야 합니다.
💡 GRUB 디버깅 시 set debug=all을 grub.cfg에 추가하면 상세한 로그를 볼 수 있습니다.
💡 실제 USB에 쓰기 전 losetup으로 루프백 디바이스로 마운트해서 파일시스템을 검증하세요.
💡 듀얼 부팅을 위해서는 기존 OS의 GRUB 설정(/boot/grub/grub.cfg)에 커스텀 엔트리를 추가하는 게 더 안전합니다.