이미지 로딩 중...
AI Generated
2025. 11. 14. · 0 Views
Rust로 만드는 나만의 OS - 실제 하드웨어에서 테스트
QEMU 에뮬레이터를 벗어나 실제 하드웨어에서 직접 만든 OS를 부팅하는 방법을 다룹니다. USB 부팅 디스크 생성부터 BIOS/UEFI 설정, 하드웨어 호환성 문제 해결까지 실전 OS 개발의 모든 과정을 상세히 알아봅니다.
목차
- 부팅 가능한 USB 디스크 이미지 생성 - bootimage 크레이트로 실제 부팅 미디어 만들기
- USB 드라이브에 OS 이미지 쓰기 - dd와 Rufus를 활용한 부팅 미디어 생성
- BIOS와 UEFI 부팅 설정 - 하드웨어에서 OS를 부팅하는 펌웨어 구성
- 시리얼 포트를 통한 디버깅 - 하드웨어에서 커널 로그 확인하기
- 하드웨어 호환성 문제 해결 - 실제 하드웨어의 차이점 대응하기
- VGA 텍스트 모드와 프레임버퍼 - 실제 화면 출력 구현하기
- 키보드 입력 처리 - PS/2 키보드와 스캔코드 디코딩
- 메모리 매핑과 페이지 테이블 검증 - 실제 하드웨어의 메모리 레이아웃 확인
- 타이머 인터럽트와 PIT/APIC 타이머 - 시간 추적과 선점형 멀티태스킹 기초
- 예외 처리와 패닉 핸들러 - 하드웨어 에러에서의 안전한 복구
1. 부팅 가능한 USB 디스크 이미지 생성 - bootimage 크레이트로 실제 부팅 미디어 만들기
시작하며
여러분이 QEMU에서 완벽하게 동작하던 자신만의 OS를 만들었을 때, "이제 진짜 컴퓨터에서 실행해보고 싶다"는 생각이 들지 않나요? 에뮬레이터가 아닌 실제 하드웨어에서 내가 만든 커널이 부팅되는 순간의 감동은 말로 표현할 수 없습니다.
하지만 막상 실제 하드웨어에 OS를 올리려고 하면 여러 문제에 부딪힙니다. 커널 바이너리를 그냥 USB에 복사한다고 부팅되지 않습니다.
부트로더가 필요하고, 특정 디스크 레이아웃을 따라야 하며, BIOS나 UEFI가 인식할 수 있는 형식이어야 합니다. 바로 이럴 때 필요한 것이 bootimage 크레이트입니다.
이 도구는 여러분의 커널 바이너리를 받아서 부트로더와 결합하고, 실제 하드웨어에서 부팅 가능한 디스크 이미지를 자동으로 생성해줍니다.
개요
간단히 말해서, bootimage는 Rust OS 커널을 실제 부팅 가능한 디스크 이미지로 변환해주는 빌드 도구입니다. 일반적인 OS는 GRUB 같은 부트로더를 수동으로 설치하고 설정해야 하지만, Rust OS 개발에서는 bootimage가 이 모든 과정을 자동화해줍니다.
bootloader 크레이트와 함께 사용하면 BIOS와 UEFI 양쪽을 모두 지원하는 부팅 이미지를 만들 수 있어, 다양한 하드웨어에서 테스트할 수 있습니다. 기존에는 어셈블리로 부트로더를 직접 작성하고, dd 명령어로 섹터별로 디스크 이미지를 구성했다면, 이제는 cargo bootimage 명령 하나로 모든 것이 처리됩니다.
이 도구의 핵심 특징은 첫째, 자동으로 부트로더를 커널과 결합하고, 둘째, FAT 파일시스템을 포함한 UEFI 부팅을 지원하며, 셋째, 생성된 이미지를 바로 USB나 CD에 구울 수 있다는 점입니다. 이러한 특징들이 개발 시간을 크게 단축시키고 하드웨어 테스트를 용이하게 만들어줍니다.
코드 예제
# Cargo.toml에 부트로더 의존성 추가
[dependencies]
bootloader = "0.9.23"
# 빌드 설정 추가
[package.metadata.bootimage]
# UEFI 부팅 지원
build-command = ["build"]
# 부트로더가 사용할 프레임버퍼 설정
run-args = ["-serial", "stdio"]
# bootimage 도구 설치 및 이미지 생성
# cargo install bootimage
# cargo bootimage --release
# 생성된 이미지는 target/x86_64-unknown-none/release/bootimage-os.bin
# 이 파일을 USB에 직접 쓸 수 있음:
# dd if=target/x86_64-unknown-none/release/bootimage-os.bin of=/dev/sdb bs=4M
설명
이것이 하는 일: bootimage는 cargo build로 생성된 커널 바이너리를 가져와서, bootloader 크레이트가 제공하는 부트로더 코드와 결합한 후, 부팅 가능한 디스크 이미지 형식으로 패키징합니다. 첫 번째로, Cargo.toml에 bootloader 의존성을 추가하면 빌드 프로세스에 부트로더가 포함됩니다.
bootloader 크레이트는 BIOS 부팅을 위한 MBR 코드와 UEFI 부팅을 위한 EFI 실행 파일을 모두 포함하고 있습니다. 이렇게 하는 이유는 오래된 컴퓨터(BIOS)와 최신 컴퓨터(UEFI) 양쪽에서 모두 부팅할 수 있도록 하기 위함입니다.
그 다음으로, cargo bootimage 명령을 실행하면 내부적으로 여러 단계가 진행됩니다. 먼저 커널이 컴파일되고, 그 다음 부트로더가 컴파일되며, 마지막으로 두 개가 결합되어 디스크 이미지가 생성됩니다.
이 과정에서 부트로더는 커널을 메모리의 올바른 위치에 로드하고, 64비트 롱 모드로 전환한 후, 커널의 진입점(_start 함수)으로 점프하는 코드를 자동으로 생성합니다. 마지막으로, 생성된 bootimage-os.bin 파일은 완전한 디스크 이미지입니다.
이 파일을 dd 명령으로 USB 드라이브에 직접 쓰면, 그 USB로 컴퓨터를 부팅할 수 있습니다. 파일 내부에는 MBR 부트 섹터, UEFI 파티션, 그리고 커널 바이너리가 모두 포함되어 있어 별도의 파일시스템 조작이 필요 없습니다.
여러분이 이 도구를 사용하면 복잡한 부트로더 설정 없이도 몇 분 만에 실제 하드웨어에서 테스트할 수 있습니다. 특히 GRUB 설정 파일을 작성하거나 파티션 테이블을 수동으로 만들 필요가 없어 개발 속도가 크게 향상됩니다.
또한 QEMU에서 테스트할 때와 동일한 이미지를 실제 하드웨어에서 사용할 수 있어 일관성이 보장됩니다.
실전 팁
💡 cargo bootimage --release를 사용하여 최적화된 이미지를 생성하세요. 디버그 빌드는 크기가 크고 느려서 실제 하드웨어 테스트에 적합하지 않습니다.
💡 dd 명령 사용 시 반드시 올바른 디바이스를 지정하세요. of=/dev/sdb 대신 실수로 of=/dev/sda를 쓰면 주 드라이브의 데이터가 모두 날아갑니다. lsblk로 확인 후 사용하세요.
💡 UEFI 부팅이 안 되면 Cargo.toml에서 bootloader 버전을 확인하세요. 0.9.x 버전부터 UEFI를 제대로 지원하며, 이전 버전은 BIOS만 지원합니다.
💡 부팅 이미지 크기를 줄이려면 strip 심볼을 추가하세요. [profile.release]에 strip = true를 설정하면 디버그 심볼이 제거되어 이미지 크기가 절반 이하로 줄어듭니다.
💡 VirtualBox나 VMware에서 먼저 테스트한 후 실제 하드웨어로 넘어가세요. 가상 머신은 실제 하드웨어와 거의 동일하게 동작하면서도 디버깅이 훨씬 쉽습니다.
2. USB 드라이브에 OS 이미지 쓰기 - dd와 Rufus를 활용한 부팅 미디어 생성
시작하며
여러분이 bootimage로 완벽한 디스크 이미지를 만들었다면, 이제 이것을 실제 USB 드라이브에 써야 합니다. 하지만 단순히 파일을 복사하는 것으로는 부팅이 되지 않습니다.
파일 복사와 디스크 이미지 쓰기는 완전히 다른 작업입니다. 이 문제는 많은 초보 OS 개발자들이 겪는 혼란입니다.
윈도우 탐색기에서 드래그 앤 드롭으로 파일을 복사하면 파일시스템에 파일만 추가되지만, 부팅 이미지는 디스크의 섹터 레벨에서 직접 써야 합니다. 부트 섹터, 파티션 테이블, 모든 것이 정확한 위치에 있어야 BIOS나 UEFI가 인식할 수 있습니다.
바로 이럴 때 필요한 것이 dd(Linux/Mac) 또는 Rufus(Windows) 같은 로우 레벨 디스크 쓰기 도구입니다. 이들은 이미지를 바이트 단위로 그대로 디스크에 복사하여 부팅 가능한 USB를 만들어줍니다.
개요
간단히 말해서, dd는 블록 단위로 데이터를 복사하는 Unix 도구이며, 디스크 이미지를 USB 드라이브에 섹터 레벨에서 직접 쓸 수 있습니다. Linux나 Mac 환경에서는 dd가 기본적으로 설치되어 있어 매우 편리합니다.
하지만 dd는 강력한 만큼 위험하기도 해서 "disk destroyer"라는 별명을 가지고 있습니다. 잘못된 디바이스를 지정하면 시스템 드라이브의 모든 데이터가 순식간에 지워지므로 매우 주의해야 합니다.
기존에는 UNetbootin이나 Etcher 같은 GUI 도구를 사용했다면, OS 개발에서는 dd나 Rufus를 사용하는 것이 더 정확합니다. 이들은 이미지를 변조하지 않고 그대로 쓰기 때문에 부팅 문제가 발생할 가능성이 낮습니다.
핵심 특징은 첫째, 섹터 단위로 정확한 복사가 가능하고, 둘째, 부트 섹터와 파티션 테이블을 올바르게 유지하며, 셋째, MBR과 UEFI 부팅을 모두 지원한다는 점입니다. 이러한 특징들이 여러분의 OS가 다양한 하드웨어에서 정상적으로 부팅되도록 보장합니다.
코드 예제
# Linux에서 USB 디바이스 확인
lsblk
# sdb가 16GB USB라고 가정
# USB 마운트 해제 (마운트된 상태에서는 쓰기 불가)
sudo umount /dev/sdb*
# 디스크 이미지를 USB에 쓰기
# if=입력파일, of=출력디바이스, bs=블록크기, status=진행상황
sudo dd if=target/x86_64-unknown-none/release/bootimage-os.bin \
of=/dev/sdb \
bs=4M \
status=progress \
conv=fsync
# 완료 후 버퍼 플러시 (안전한 제거를 위해)
sync
# 검증: 이미지가 제대로 쓰였는지 확인
sudo dd if=/dev/sdb bs=512 count=1 | hexdump -C | head
설명
이것이 하는 일: dd 명령은 디스크 이미지 파일을 읽어서 USB 드라이브의 첫 번째 섹터부터 순차적으로 바이트 단위로 복사합니다. 파일시스템을 거치지 않고 직접 디바이스에 쓰기 때문에 부트 섹터와 파티션 테이블이 정확하게 복사됩니다.
첫 번째로, lsblk 명령으로 USB 드라이브의 디바이스 이름을 확인해야 합니다. 보통 /dev/sdb나 /dev/sdc 같은 이름을 가지는데, 절대 /dev/sda(주 시스템 드라이브)를 선택하면 안 됩니다.
umount로 마운트를 해제하는 이유는 파일시스템이 활성화된 상태에서는 로우 레벨 쓰기가 충돌을 일으킬 수 있기 때문입니다. 그 다음으로, dd 명령의 핵심 옵션들을 살펴봅시다.
if(input file)는 bootimage로 생성한 이미지 파일을 지정하고, of(output file)는 USB 드라이브 디바이스를 지정합니다. 중요한 점은 /dev/sdb1(파티션)이 아니라 /dev/sdb(전체 디스크)를 지정해야 한다는 것입니다.
bs=4M은 한 번에 4메가바이트씩 복사하라는 의미로, 작은 블록 크기보다 훨씬 빠릅니다. 세 번째로, status=progress 옵션은 진행 상황을 실시간으로 보여주어 큰 이미지를 쓸 때 유용합니다.
conv=fsync는 각 쓰기 작업 후 버퍼를 디스크에 플러시하여 데이터 손실을 방지합니다. 마지막으로 sync 명령은 시스템의 모든 버퍼를 플러시하여 USB를 안전하게 제거할 수 있도록 합니다.
여러분이 이 방법을 사용하면 이미지가 비트 단위로 정확하게 복사되어 부팅 문제가 거의 발생하지 않습니다. Windows에서는 Rufus를 사용하되 "DD 이미지 모드"를 선택해야 동일한 효과를 얻을 수 있습니다.
검증 단계에서 hexdump로 첫 섹터를 확인하면 부트 시그니처(0x55AA)가 마지막에 있는지 확인할 수 있어 이미지가 올바르게 쓰였는지 판단할 수 있습니다.
실전 팁
💡 dd 실행 전에 반드시 lsblk와 fdisk -l로 두 번 확인하세요. 한 번의 실수로 모든 데이터를 잃을 수 있습니다. USB의 용량과 레이블을 확인하여 올바른 디바이스인지 검증하세요.
💡 bs(블록 크기)는 4M이 최적입니다. 너무 작으면(512) 느리고, 너무 크면(1G) 메모리를 많이 사용합니다. 4M~8M 사이가 속도와 안정성의 균형점입니다.
💡 Windows에서 Rufus 사용 시 "ISO 이미지 모드"가 아닌 "DD 이미지 모드"를 선택하세요. ISO 모드는 이미지를 변조하여 부팅이 안 될 수 있습니다.
💡 dd 진행 중 아무 반응이 없어 보여도 Ctrl+C로 중단하지 마세요. 다른 터미널에서 sudo pkill -USR1 dd를 실행하면 현재 진행 상황이 출력됩니다.
💡 부팅이 안 되면 dd 대신 cat bootimage.bin > /dev/sdb를 시도해보세요. 드물게 dd의 버퍼링 문제로 부팅이 안 되는 경우가 있는데, cat은 이런 문제가 없습니다.
3. BIOS와 UEFI 부팅 설정 - 하드웨어에서 OS를 부팅하는 펌웨어 구성
시작하며
여러분이 USB에 OS 이미지를 완벽하게 쓰고 컴퓨터를 재부팅했는데도 "No bootable device found" 메시지가 나온다면? 아니면 Windows가 그냥 정상적으로 부팅된다면?
이것은 BIOS/UEFI 설정 문제일 가능성이 높습니다. 이 문제는 하드웨어 부팅에서 가장 흔하게 발생합니다.
최신 컴퓨터는 보안을 위해 Secure Boot가 활성화되어 있고, USB 부팅이 우선순위에서 밀려 있으며, Legacy BIOS 모드가 비활성화되어 있을 수 있습니다. 이런 설정들이 여러분의 OS가 부팅되는 것을 막고 있습니다.
바로 이럴 때 필요한 것이 BIOS/UEFI 설정 변경입니다. 부팅 순서를 조정하고, Secure Boot를 끄고, Legacy 지원을 활성화하면 대부분의 부팅 문제가 해결됩니다.
개요
간단히 말해서, BIOS(Basic Input/Output System)와 UEFI(Unified Extensible Firmware Interface)는 컴퓨터가 켜질 때 하드웨어를 초기화하고 OS를 로드하는 펌웨어입니다. BIOS는 1980년대부터 사용된 레거시 펌웨어로, MBR(Master Boot Record) 방식으로 부팅하며 16비트 모드에서 동작합니다.
UEFI는 2000년대 후반부터 도입된 현대적 펌웨어로, GPT(GUID Partition Table) 파티션을 지원하고, 그래픽 인터페이스를 제공하며, Secure Boot 같은 보안 기능을 포함합니다. 대부분의 OS 개발 프로젝트는 두 가지를 모두 지원해야 합니다.
기존에는 BIOS만 신경 쓰면 됐다면, 이제는 UEFI 환경도 고려해야 합니다. 다행히 bootloader 크레이트는 두 가지를 모두 지원하지만, 하드웨어 설정에서 이를 제대로 활성화해야 합니다.
핵심 특징은 첫째, 부팅 순서를 변경할 수 있고, 둘째, Secure Boot를 끄거나 서명을 추가할 수 있으며, 셋째, Legacy BIOS 호환 모드를 선택할 수 있다는 점입니다. 이러한 설정들을 올바르게 구성하는 것이 하드웨어 테스트의 첫 단계입니다.
코드 예제
// BIOS/UEFI 설정 접근 방법 (컴퓨터마다 다름)
// 부팅 시 다음 키 중 하나를 연타:
// - Dell: F2, F12
// - HP: F10, ESC
// - Lenovo: F1, F2, Fn+F2
// - ASUS: F2, Delete
// - MSI: Delete
// 설정 변경 체크리스트:
// 1. Boot Order (부팅 순서)
// - USB-HDD를 첫 번째로 이동
// - 또는 Boot Override로 일회성 USB 부팅
// 2. Secure Boot (보안 부팅)
// - Disabled로 설정 (서명되지 않은 OS이므로)
// - 일부 시스템은 "Other OS" 모드 제공
// 3. CSM (Compatibility Support Module)
// - Enabled로 설정 (Legacy BIOS 지원)
// - Boot Mode를 "UEFI and Legacy" 또는 "Legacy only"로 설정
// 4. Fast Boot
// - Disabled로 설정 (USB 초기화 시간 확보)
설명
이것이 하는 일: BIOS/UEFI 설정은 하드웨어가 어떤 장치에서 어떤 방식으로 OS를 부팅할지 결정하는 구성입니다. 올바르게 설정하지 않으면 USB에 완벽한 이미지가 있어도 부팅되지 않습니다.
첫 번째로, BIOS/UEFI 설정 화면에 진입해야 합니다. 컴퓨터가 켜지는 순간 제조사 로고가 나타날 때 특정 키를 연타해야 하는데, Dell은 F2나 F12, HP는 F10이나 ESC, ASUS는 Delete 키를 주로 사용합니다.
타이밍을 놓치면 OS가 부팅되므로 다시 재부팅해야 합니다. 일부 노트북은 Fn 키와 조합이 필요하기도 합니다.
그 다음으로, 가장 중요한 설정은 Secure Boot입니다. 이것은 Microsoft나 특정 CA가 서명한 부트로더만 실행하도록 제한하는 보안 기능인데, 여러분의 자작 OS는 서명이 없으므로 반드시 Disabled로 설정해야 합니다.
Security 탭이나 Boot 탭에서 찾을 수 있으며, "Other OS" 모드를 선택하면 자동으로 비활성화되는 경우도 있습니다. 세 번째로, CSM(Compatibility Support Module) 설정을 찾아 활성화하세요.
이것은 UEFI 시스템에서 Legacy BIOS 모드를 에뮬레이션하는 기능입니다. bootloader 크레이트는 UEFI와 BIOS를 모두 지원하지만, BIOS 모드가 더 안정적인 경우가 많아 CSM을 켜는 것이 좋습니다.
Boot Mode를 "UEFI and Legacy" 또는 "Legacy First"로 설정하세요. 마지막으로, Boot Order(부팅 순서)에서 USB-HDD 또는 USB Storage를 최상위로 올려야 합니다.
일부 시스템은 영구적 순서 변경 외에 "Boot Override" 메뉴를 제공하는데, 이것은 일회성 부팅에 유용합니다. Fast Boot를 끄는 것도 잊지 마세요.
Fast Boot는 초기화를 건너뛰어 USB 장치가 인식되지 않을 수 있습니다. 여러분이 이 설정들을 모두 올바르게 구성하면 대부분의 하드웨어에서 부팅이 가능해집니다.
설정 저장은 보통 F10 키로 하며, Exit Saving Changes를 선택하면 재부팅과 함께 적용됩니다. 만약 여전히 부팅이 안 된다면 USB 포트를 바꿔보세요.
USB 3.0 포트보다 USB 2.0 포트가 더 호환성이 좋은 경우가 많습니다.
실전 팁
💡 Secure Boot를 끌 수 없다면 "Setup Mode"나 "Custom Mode"로 변경 후 키를 삭제하세요. 일부 제조사는 Secure Boot를 단순히 끄는 옵션을 제공하지 않습니다.
💡 USB 부팅이 목록에 안 나타나면 BIOS/UEFI 화면에서 USB를 꽂아보세요. 일부 시스템은 펌웨어가 로드된 후에야 USB 장치를 인식합니다.
💡 UEFI 모드로만 부팅을 시도하려면 CSM을 끄고 Boot Mode를 "UEFI Only"로 설정하세요. 이 경우 bootloader가 UEFI 부팅 경로를 사용하므로 디버깅이 다릅니다.
💡 BIOS 암호가 걸려있다면 제조사의 마스터 암호나 CMOS 리셋을 시도하세요. 노트북은 CMOS 배터리를 빼기 어려우므로 제조사 지원을 받아야 할 수 있습니다.
💡 설정을 바꿨는데도 부팅이 안 되면 "Load Setup Defaults"로 초기화 후 다시 시도하세요. 이전 설정들이 충돌을 일으킬 수 있습니다.
4. 시리얼 포트를 통한 디버깅 - 하드웨어에서 커널 로그 확인하기
시작하며
여러분이 실제 하드웨어에서 OS를 부팅했는데 화면에 아무것도 나타나지 않는다면? 커널 패닉이 발생했는지, 어디서 멈췄는지 전혀 알 수 없다면?
QEMU에서는 println!이 터미널에 출력됐지만, 실제 하드웨어에서는 어디에도 로그가 나타나지 않습니다. 이 문제는 실제 하드웨어 디버깅의 가장 큰 난관입니다.
그래픽 출력이 초기화되기 전에 크래시가 발생하면 아무것도 볼 수 없고, 어떤 코드에서 문제가 생겼는지 추적할 방법이 없습니다. printf 디버깅조차 할 수 없는 상황입니다.
바로 이럴 때 필요한 것이 시리얼 포트 디버깅입니다. 시리얼 포트는 가장 기본적인 하드웨어 통신 방법으로, BIOS가 초기화하기 때문에 커널이 부팅되는 순간부터 사용할 수 있습니다.
여러분의 모든 로그를 다른 컴퓨터로 전송하여 실시간으로 확인할 수 있습니다.
개요
간단히 말해서, 시리얼 포트 디버깅은 COM1(0x3F8) 포트를 통해 커널의 로그를 다른 컴퓨터로 전송하거나 화면에 출력하는 기법입니다. 시리얼 포트는 옛날 기술처럼 보이지만, OS 개발에서는 여전히 가장 신뢰할 수 있는 디버깅 수단입니다.
USB나 네트워크와 달리 복잡한 드라이버가 필요 없고, 단 몇 줄의 포트 I/O 코드만으로 작동합니다. 실제로 Linux 커널 개발자들도 초기 부팅 문제를 디버깅할 때 시리얼 콘솔을 사용합니다.
기존에는 VGA 버퍼에 직접 쓰거나 println! 매크로를 사용했다면, 이제는 시리얼 포트로도 동일한 출력을 보낼 수 있습니다.
uart_16550 크레이트를 사용하면 Rust에서 쉽게 시리얼 통신을 구현할 수 있습니다. 핵심 특징은 첫째, 초기 부팅 단계부터 사용 가능하고, 둘째, VGA보다 훨씬 빠르며, 셋째, 다른 컴퓨터에서 로그를 실시간으로 수집할 수 있다는 점입니다.
이러한 특징들이 하드웨어 디버깅을 가능하게 만들어줍니다.
코드 예제
// Cargo.toml에 시리얼 드라이버 추가
[dependencies]
uart_16550 = "0.2.18"
// src/serial.rs
use uart_16550::SerialPort;
use spin::Mutex;
use lazy_static::lazy_static;
lazy_static! {
pub static ref SERIAL1: Mutex<SerialPort> = {
let mut serial_port = unsafe { SerialPort::new(0x3F8) }; // COM1
serial_port.init();
Mutex::new(serial_port)
};
}
// 시리얼 출력 매크로
#[macro_export]
macro_rules! serial_print {
($($arg:tt)*) => {
$crate::serial::_print(format_args!($($arg)*));
};
}
#[macro_export]
macro_rules! serial_println {
() => ($crate::serial_print!("\n"));
($($arg:tt)*) => ($crate::serial_print!("{}\n", format_args!($($arg)*)));
}
#[doc(hidden)]
pub fn _print(args: fmt::Arguments) {
use core::fmt::Write;
SERIAL1.lock().write_fmt(args).expect("Serial write failed");
}
// 사용 예시
serial_println!("Kernel started!");
serial_println!("Initializing memory at {:#x}", addr);
설명
이것이 하는 일: 시리얼 포트 드라이버는 0x3F8(COM1) I/O 포트에 바이트를 쓰면 하드웨어가 이를 시리얼 신호로 변환하여 외부로 전송합니다. 다른 컴퓨터나 USB-to-Serial 어댑터로 이 신호를 받아 터미널에 표시할 수 있습니다.
첫 번째로, uart_16550 크레이트를 사용하여 시리얼 포트를 초기화합니다. SerialPort::new(0x3F8)는 COM1 포트의 베이스 주소를 지정하고, init()은 보드레이트(115200bps)와 데이터 비트(8N1: 8비트, 패리티 없음, 스톱 비트 1)를 설정합니다.
lazy_static!으로 전역 변수로 만들고 Mutex로 감싸는 이유는 여러 CPU 코어에서 동시에 접근할 때 데이터가 섞이지 않도록 보호하기 위함입니다. 그 다음으로, serial_println!
매크로를 정의하여 println!과 동일한 인터페이스를 제공합니다. 내부적으로는 format_args!로 포맷팅하고, fmt::Write 트레이트를 통해 시리얼 포트에 씁니다.
이렇게 하면 기존 코드의 println!을 serial_println!으로 바꾸기만 하면 시리얼 출력으로 전환할 수 있습니다. 세 번째로, 실제 하드웨어에서 시리얼 출력을 보려면 수신 장치가 필요합니다.
최신 컴퓨터는 시리얼 포트가 없으므로 USB-to-Serial 어댑터(FT232, CP2102 등)를 사용해야 합니다. 다른 컴퓨터에서 minicom, screen, PuTTY 같은 터미널 프로그램을 실행하고 /dev/ttyUSB0(Linux) 또는 COM3(Windows)에 115200 8N1로 연결하면 커널 로그가 실시간으로 나타납니다.
여러분이 이 방법을 사용하면 커널이 어디서 멈췄는지, 어떤 에러가 발생했는지 정확히 추적할 수 있습니다. VGA 출력보다 훨씬 빠르고, 버퍼 오버플로우 걱정도 없으며, 로그를 파일로 저장할 수도 있습니다.
실제 임베디드 시스템이나 서버에서도 시리얼 콘솔은 필수적인 디버깅 도구입니다.
실전 팁
💡 노트북에는 시리얼 포트가 없으므로 데스크톱이나 USB-to-Serial 어댑터를 사용하세요. FT232는 가장 호환성이 좋고 Linux에서 드라이버 설치 없이 작동합니다.
💡 QEMU에서도 시리얼을 테스트할 수 있습니다. -serial stdio 옵션을 추가하면 시리얼 출력이 터미널에 나타나 실제 하드웨어로 가기 전에 검증할 수 있습니다.
💡 부팅 초기에 serial_println!("Starting kernel")을 여러 곳에 추가하여 어디까지 실행됐는지 추적하세요. 바이너리 서치 방식으로 크래시 지점을 빠르게 찾을 수 있습니다.
💡 시리얼 출력이 깨진다면 보드레이트 불일치입니다. 커널에서 115200으로 설정했다면 터미널 프로그램도 정확히 115200이어야 합니다. 9600이나 38400은 구식입니다.
💡 panic 핸들러에서도 serial_println!을 사용하여 패닉 메시지를 출력하세요. 화면이 초기화되지 않아도 시리얼로는 에러를 볼 수 있어 디버깅이 훨씬 쉬워집니다.
5. 하드웨어 호환성 문제 해결 - 실제 하드웨어의 차이점 대응하기
시작하며
여러분이 QEMU에서 완벽하게 동작하던 OS가 실제 하드웨어에서는 부팅 직후 멈춰버린다면? 또는 키보드 입력이 안 되거나, 화면 해상도가 이상하다면?
이것은 하드웨어 호환성 문제입니다. QEMU는 이상적인 가상 하드웨어를 제공하지만, 실제 하드웨어는 훨씬 다양하고 예측 불가능합니다.
이 문제는 모든 OS 개발자가 반드시 겪는 관문입니다. Intel과 AMD CPU는 ISA가 같아도 내부 구현이 다르고, 각 제조사의 메인보드는 칩셋이 다르며, 레거시 장치들은 표준을 제각각 구현합니다.
ACPI 테이블이 손상된 메인보드, 제대로 구현되지 않은 PCI 버스, 비표준 타이머 등 수많은 함정이 기다리고 있습니다. 바로 이럴 때 필요한 것이 방어적 프로그래밍과 하드웨어 감지입니다.
CPU 기능을 확인하고(CPUID), 장치를 안전하게 감지하며(PCI enumeration), 대체 코드 경로를 준비하면(fallback) 대부분의 호환성 문제를 해결할 수 있습니다.
개요
간단히 말해서, 하드웨어 호환성 문제는 실제 물리적 장치들이 표준을 다르게 구현하거나, 여러분이 가정한 기능을 지원하지 않아서 발생하는 모든 문제를 의미합니다. 가장 흔한 문제는 CPU 기능 가정입니다.
QEMU는 최신 CPU 기능(SSE, AVX 등)을 기본으로 제공하지만, 오래된 하드웨어는 이를 지원하지 않을 수 있습니다. CPUID 명령으로 기능을 확인하지 않고 바로 사용하면 Invalid Opcode 예외가 발생합니다.
또한 APIC(Advanced Programmable Interrupt Controller)가 없는 시스템에서 APIC를 사용하려 하면 부팅이 멈춥니다. 기존에는 모든 하드웨어가 동일하다고 가정했다면, 이제는 런타임에 기능을 감지하고 있으면 사용하고 없으면 대체 방법을 쓰는 코드를 작성해야 합니다.
이를 "feature detection" 또는 "capability negotiation"이라고 합니다. 핵심 특징은 첫째, CPUID로 CPU 기능을 확인하고, 둘째, ACPI 테이블을 파싱하여 하드웨어 구성을 파악하며, 셋째, 각 기능에 대한 fallback 코드를 준비한다는 점입니다.
이러한 접근이 다양한 하드웨어에서 안정적으로 동작하는 OS를 만드는 비결입니다.
코드 예제
// CPUID를 통한 기능 감지
use core::arch::x86_64::{__cpuid, CpuidResult};
fn check_cpu_features() {
// CPUID 기능 1: 프로세서 정보 및 기능 비트
let result = unsafe { __cpuid(1) };
// EDX 레지스터 비트 플래그
let has_fpu = (result.edx & (1 << 0)) != 0; // FPU 온칩
let has_apic = (result.edx & (1 << 9)) != 0; // APIC 온칩
let has_sse = (result.edx & (1 << 25)) != 0; // SSE 지원
let has_sse2 = (result.edx & (1 << 26)) != 0; // SSE2 지원
serial_println!("CPU Features:");
serial_println!(" FPU: {}", has_fpu);
serial_println!(" APIC: {}", has_apic);
serial_println!(" SSE: {}", has_sse);
serial_println!(" SSE2: {}", has_sse2);
// 필수 기능 확인
if !has_sse2 {
panic!("SSE2 is required but not supported by this CPU");
}
// 선택적 기능은 fallback
if has_apic {
init_apic(); // APIC 사용
} else {
init_pic(); // 레거시 8259 PIC 사용
}
}
// PIT vs TSC: 타이머 선택
fn init_timer() {
if has_tsc() {
setup_tsc_timer(); // 더 정확한 TSC 사용
} else {
setup_pit_timer(); // fallback: 8254 PIT 사용
}
}
설명
이것이 하는 일: CPUID 명령은 CPU 제조사, 모델, 그리고 지원하는 기능들을 비트 플래그로 반환합니다. 이를 분석하여 코드가 실행 중인 하드웨어의 능력을 파악하고, 적절한 코드 경로를 선택합니다.
첫 번째로, CPUID를 호출하는 방법을 이해해야 합니다. EAX 레지스터에 기능 번호를 넣고 CPUID 명령을 실행하면, EAX/EBX/ECX/EDX에 결과가 반환됩니다.
기능 번호 1은 가장 기본적인 CPU 정보를 제공하는데, EDX의 각 비트가 하나의 기능을 나타냅니다. 예를 들어 비트 9는 APIC 지원 여부, 비트 25는 SSE 지원 여부를 나타냅니다.
그 다음으로, 각 비트를 마스킹하여 기능을 확인합니다. (result.edx & (1 << 9)) != 0은 9번 비트가 설정되어 있는지 확인하는 표준 비트 연산입니다.
이렇게 감지한 기능에 따라 has_apic 같은 불리언 변수에 저장하여 코드 전체에서 사용할 수 있습니다. 세 번째로, 필수 기능과 선택적 기능을 구분해야 합니다.
SSE2는 64비트 코드에서 필수이므로 없으면 panic!으로 종료해야 합니다. 하지만 APIC는 선택적 기능이므로 있으면 사용하고 없으면 레거시 8259 PIC를 사용하는 fallback을 준비합니다.
이러한 방어적 프로그래밍이 다양한 하드웨어에서의 호환성을 보장합니다. 여러분이 이 패턴을 모든 하드웨어 기능에 적용하면 호환성 문제가 크게 줄어듭니다.
타이머는 TSC가 있으면 사용하고 없으면 PIT를 사용하고, 그래픽은 멀티부트 프레임버퍼가 있으면 사용하고 없으면 VGA 텍스트 모드로 fallback하는 식입니다. Linux 커널도 동일한 전략을 사용하여 1990년대 하드웨어부터 최신 서버까지 모두 지원합니다.
실전 팁
💡 CPUID 결과를 부팅 초기에 한 번만 확인하고 전역 구조체에 저장하세요. 매번 CPUID를 호출하면 느리고, 일관성이 보장되지 않습니다.
💡 오래된 Intel Pentium이나 AMD K6 같은 CPU는 CPUID 자체를 지원하지 않을 수 있습니다. EFLAGS의 ID 비트를 토글하여 CPUID 지원 여부를 먼저 확인하세요.
💡 ACPI 테이블이 손상된 메인보드가 있으므로 ACPI 파싱은 항상 방어적으로 작성하세요. 체크섬 검증, 범위 확인, 타임아웃 설정이 필수입니다.
💡 하드웨어 테스트는 가능한 한 다양한 기기에서 하세요. 특히 Intel/AMD, 데스크톱/노트북, 신형/구형을 각각 한 대씩 테스트하면 대부분의 문제를 발견할 수 있습니다.
💡 부팅이 멈추면 시리얼로 "Checkpoint 1", "Checkpoint 2" 같은 메시지를 출력하여 어느 단계에서 멈췄는지 추적하세요. 이진 탐색 방식으로 문제 지점을 빠르게 좁힐 수 있습니다.
6. VGA 텍스트 모드와 프레임버퍼 - 실제 화면 출력 구현하기
시작하며
여러분이 시리얼 포트로 디버깅은 가능하지만, 결국 사용자에게 보여줄 화면 출력이 필요합니다. 검은 화면에 커서만 깜빡이거나, 아예 아무것도 나타나지 않는다면 OS가 제대로 작동하는지조차 알 수 없습니다.
이 문제는 그래픽 출력의 복잡성 때문에 발생합니다. QEMU는 자동으로 VGA를 초기화하고 멀티부트 프레임버퍼를 제공하지만, 실제 하드웨어는 BIOS가 설정한 모드 그대로입니다.
또한 최신 그래픽 카드는 UEFI GOP(Graphics Output Protocol)를 사용하는데, 레거시 VGA와는 완전히 다른 인터페이스입니다. 바로 이럴 때 필요한 것이 VGA 텍스트 모드 지원입니다.
가장 기본적이고 호환성이 높은 출력 방법으로, 모든 PC 호환 하드웨어에서 작동합니다. 80x25 문자만 출력할 수 있지만, 초기 부팅과 디버깅에는 충분합니다.
개요
간단히 말해서, VGA 텍스트 모드는 0xB8000 메모리 주소에 문자와 색상 정보를 쓰면 화면에 자동으로 표시되는 가장 단순한 그래픽 모드입니다. VGA(Video Graphics Array)는 1987년 IBM이 도입한 표준으로, 모든 PC는 부팅 시 자동으로 80x25 텍스트 모드로 시작합니다.
이 모드에서는 0xB8000~0xB8FA0 메모리 영역이 화면 버퍼에 매핑되어 있고, 2바이트(문자 코드 + 색상 속성) 단위로 80x25=2000개 문자를 표현할 수 있습니다. 복잡한 초기화가 필요 없어 OS 개발 초기에 가장 많이 사용됩니다.
기존에는 GRUB 같은 부트로더가 제공하는 프레임버퍼를 사용했다면, 이제는 VGA 텍스트 모드를 직접 제어하여 부트로더 독립적인 출력을 구현할 수 있습니다. 이는 다양한 부트로더와 하드웨어에서 작동하는 장점이 있습니다.
핵심 특징은 첫째, 모든 PC 호환 하드웨어에서 작동하고, 둘째, 별도의 초기화 없이 즉시 사용 가능하며, 셋째, 메모리 매핑으로 매우 빠르다는 점입니다. 이러한 특징들이 VGA 텍스트 모드를 OS 개발의 표준으로 만들었습니다.
코드 예제
// src/vga_buffer.rs
use core::fmt;
use volatile::Volatile;
// VGA 색상 열거형
#[repr(u8)]
pub enum Color {
Black = 0,
Blue = 1,
Green = 2,
Cyan = 3,
Red = 4,
Magenta = 5,
Brown = 6,
LightGray = 7,
White = 15,
}
// 색상 속성: 상위 4비트=배경, 하위 4비트=전경
#[derive(Copy, Clone)]
#[repr(transparent)]
struct ColorCode(u8);
impl ColorCode {
fn new(foreground: Color, background: Color) -> ColorCode {
ColorCode((background as u8) << 4 | (foreground as u8))
}
}
// VGA 버퍼 문자: ASCII + 색상
#[derive(Copy, Clone)]
#[repr(C)]
struct ScreenChar {
ascii_character: u8,
color_code: ColorCode,
}
const BUFFER_HEIGHT: usize = 25;
const BUFFER_WIDTH: usize = 80;
// VGA 버퍼 구조체
#[repr(transparent)]
struct Buffer {
chars: [[Volatile<ScreenChar>; BUFFER_WIDTH]; BUFFER_HEIGHT],
}
pub struct Writer {
column_position: usize,
color_code: ColorCode,
buffer: &'static mut Buffer,
}
impl Writer {
pub fn write_byte(&mut self, byte: u8) {
match byte {
b'\n' => self.new_line(),
byte => {
if self.column_position >= BUFFER_WIDTH {
self.new_line();
}
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;
}
}
}
pub fn write_string(&mut self, s: &str) {
for byte in s.bytes() {
match byte {
0x20..=0x7e | b'\n' => self.write_byte(byte),
_ => self.write_byte(0xfe), // 비ASCII는 ■로 표시
}
}
}
fn new_line(&mut self) {
// 스크롤 구현: 모든 줄을 한 칸씩 위로 이동
for row in 1..BUFFER_HEIGHT {
for col in 0..BUFFER_WIDTH {
let character = self.buffer.chars[row][col].read();
self.buffer.chars[row - 1][col].write(character);
}
}
self.clear_row(BUFFER_HEIGHT - 1);
self.column_position = 0;
}
fn clear_row(&mut self, row: usize) {
let blank = ScreenChar {
ascii_character: b' ',
color_code: self.color_code,
};
for col in 0..BUFFER_WIDTH {
self.buffer.chars[row][col].write(blank);
}
}
}
// 전역 Writer 생성
use lazy_static::lazy_static;
use spin::Mutex;
lazy_static! {
pub static ref WRITER: Mutex<Writer> = Mutex::new(Writer {
column_position: 0,
color_code: ColorCode::new(Color::White, Color::Black),
buffer: unsafe { &mut *(0xb8000 as *mut Buffer) },
});
}
// println! 매크로 구현
#[macro_export]
macro_rules! print {
($($arg:tt)*) => ($crate::vga_buffer::_print(format_args!($($arg)*)));
}
#[macro_export]
macro_rules! println {
() => ($crate::print!("\n"));
($($arg:tt)*) => ($crate::print!("{}\n", format_args!($($arg)*)));
}
#[doc(hidden)]
pub fn _print(args: fmt::Arguments) {
use core::fmt::Write;
WRITER.lock().write_fmt(args).unwrap();
}
설명
이것이 하는 일: VGA 버퍼 코드는 0xB8000 메모리 주소를 Buffer 구조체로 캐스팅하여, 배열 인덱스로 화면의 각 위치에 문자와 색상을 쓸 수 있게 합니다. 하드웨어가 이 메모리를 실시간으로 읽어 화면에 표시합니다.
첫 번째로, 메모리 매핑의 작동 원리를 이해해야 합니다. 0xB8000은 VGA 하드웨어가 모니터링하는 특별한 메모리 영역으로, 여기에 쓰기 작업이 발생하면 하드웨어가 즉시 감지하여 화면을 업데이트합니다.
각 문자는 2바이트인데, 첫 번째 바이트는 ASCII 코드(예: 'A' = 0x41), 두 번째 바이트는 색상 속성(상위 4비트는 배경색, 하위 4비트는 전경색)입니다. 예를 들어 0x1F는 파란 배경(1)에 흰 글자(F)를 의미합니다.
그 다음으로, Volatile 래퍼의 필요성을 알아야 합니다. 일반 메모리 접근은 컴파일러가 최적화할 수 있지만, VGA 버퍼는 하드웨어와 연결되어 있어 최적화되면 안 됩니다.
Volatile<T>는 컴파일러에게 "이 메모리는 외부에서 변경될 수 있으니 최적화하지 마"라고 알려줍니다. read()와 write() 메서드는 volatile 읽기/쓰기를 보장합니다.
세 번째로, 스크롤 구현을 살펴봅시다. 화면이 가득 차면 new_line()이 호출되고, 124번 줄을 023번 줄로 복사하여 한 칸씩 올립니다.
마지막 줄은 공백으로 채워지고 커서는 왼쪽 끝으로 이동합니다. 이것이 터미널 스크롤의 기본 원리입니다.
복사는 메모리 간 복사이므로 매우 빠르게 수행됩니다. 마지막으로, 전역 WRITER를 lazy_static!과 Mutex로 감싸는 이유는 println!이 어디서든 호출될 수 있고, 여러 스레드(나중에 구현)에서 동시에 쓰지 못하도록 보호하기 위함입니다.
Mutex는 한 번에 한 스레드만 Writer에 접근할 수 있게 보장하여 출력이 섞이는 것을 방지합니다. 여러분이 이 코드를 사용하면 println!("Hello, hardware!")처럼 일반 Rust 코드와 동일한 방식으로 화면 출력이 가능해집니다.
이것이 OS 개발의 첫 번째 마일스톤으로, 여기서부터 인터럽트, 메모리 관리, 파일시스템 등 더 복잡한 기능을 추가할 수 있습니다.
실전 팁
💡 VGA 버퍼에 접근하기 전에 반드시 volatile 래퍼를 사용하세요. 최적화 빌드(-O2, --release)에서는 일반 쓰기가 제거될 수 있습니다.
💡 색상을 활용하여 로그 레벨을 구분하세요. 에러는 빨간색(Color::Red), 경고는 노란색(Color::Brown), 정보는 녹색(Color::Green)으로 출력하면 디버깅이 훨씬 쉬워집니다.
💡 화면이 깜빡이면 더블 버퍼링을 구현하세요. 별도 메모리에 렌더링한 후 한 번에 VGA 버퍼로 복사하면 깜빡임이 사라집니다. 하지만 텍스트 모드에서는 보통 필요 없습니다.
💡 UEFI 환경에서는 0xB8000이 매핑되지 않을 수 있습니다. GOP(Graphics Output Protocol)를 사용하거나, 페이지 테이블에 0xB8000을 수동으로 매핑해야 합니다.
💡 비ASCII 문자는 CP437 인코딩을 사용하여 표현할 수 있습니다. 예를 들어 0xB0~0xDF는 박스 그리기 문자로, TUI(Text User Interface)를 만들 때 유용합니다.
7. 키보드 입력 처리 - PS/2 키보드와 스캔코드 디코딩
시작하며
여러분의 OS가 화면에 출력할 수 있게 되었다면, 이제 사용자로부터 입력을 받아야 합니다. 하지만 키보드를 눌러도 아무 반응이 없거나, 이상한 문자가 나타나거나, 일부 키만 작동한다면?
키보드 입력은 생각보다 훨씬 복잡합니다. 이 문제는 키보드 하드웨어의 레거시 때문에 발생합니다.
현대 USB 키보드도 내부적으로는 PS/2 에뮬레이션을 사용하고, 스캔코드(scan code)라는 하드웨어 의존적인 코드를 보냅니다. 'A' 키를 누르면 ASCII 'A'가 오는 것이 아니라, 0x1C(make code)가 오고, 떼면 0x9C(break code)가 옵니다.
이를 해석해야 비로소 문자를 얻을 수 있습니다. 바로 이럴 때 필요한 것이 키보드 컨트롤러 드라이버와 스캔코드 변환입니다.
0x60 포트에서 스캔코드를 읽고, Shift/Ctrl/Alt 상태를 추적하며, 스캔코드 세트 2(또는 1)를 ASCII나 Unicode로 변환해야 합니다.
개요
간단히 말해서, PS/2 키보드는 0x60 I/O 포트를 통해 스캔코드를 전송하며, 이를 읽고 해석하여 실제 문자나 키 이벤트로 변환하는 것이 키보드 드라이버의 역할입니다. PS/2 키보드 컨트롤러(8042 칩)는 1980년대부터 사용된 표준으로, 모든 PC는 이를 지원합니다.
USB 키보드도 BIOS나 USB Legacy 모드를 통해 PS/2로 에뮬레이션되므로 OS 초기 단계에서는 PS/2 드라이버만 있으면 충분합니다. 키보드 IRQ(Interrupt Request)는 IRQ 1번이며, 키를 누르거나 뗄 때마다 인터럽트가 발생하여 커널에 알립니다.
기존에는 폴링(지속적으로 확인)으로 키보드를 읽었다면, 이제는 인터럽트 기반으로 처리하여 CPU를 절약할 수 있습니다. 키가 눌렸을 때만 핸들러가 호출되므로 훨씬 효율적입니다.
핵심 특징은 첫째, 인터럽트 기반으로 즉시 응답하고, 둘째, 스캔코드를 키 이벤트로 변환하며, 셋째, 모디파이어 키(Shift, Ctrl, Alt) 상태를 추적한다는 점입니다. 이러한 기능들이 대화형 OS의 기초를 이룹니다.
코드 예제
// src/keyboard.rs
use x86_64::instructions::port::Port;
use pc_keyboard::{layouts, HandleControl, Keyboard, ScancodeSet1};
use spin::Mutex;
use lazy_static::lazy_static;
lazy_static! {
static ref KEYBOARD: Mutex<Keyboard<layouts::Us104Key, ScancodeSet1>> =
Mutex::new(Keyboard::new(
layouts::Us104Key,
ScancodeSet1,
HandleControl::Ignore
));
}
// 키보드 인터럽트 핸들러 (IRQ 1 = 인터럽트 33)
pub extern "x86-interrupt" fn keyboard_interrupt_handler(
_stack_frame: InterruptStackFrame
) {
use pc_keyboard::{DecodedKey, KeyCode};
let mut keyboard = KEYBOARD.lock();
let mut port = Port::new(0x60); // PS/2 데이터 포트
// 스캔코드 읽기
let scancode: u8 = unsafe { port.read() };
// 스캔코드를 키 이벤트로 변환
if let Ok(Some(key_event)) = keyboard.add_byte(scancode) {
if let Some(key) = keyboard.process_keyevent(key_event) {
match key {
DecodedKey::Unicode(character) => {
print!("{}", character);
}
DecodedKey::RawKey(key) => {
// 특수 키 처리
match key {
KeyCode::ArrowUp => println!("[Up Arrow]"),
KeyCode::Enter => println!(),
KeyCode::Backspace => handle_backspace(),
_ => {}
}
}
}
}
}
// 인터럽트 완료 신호 (EOI)
unsafe {
PICS.lock()
.notify_end_of_interrupt(InterruptIndex::Keyboard.as_u8());
}
}
fn handle_backspace() {
use crate::vga_buffer::WRITER;
let mut writer = WRITER.lock();
// 커서를 한 칸 뒤로 이동하고 공백으로 덮어쓰기
if writer.column_position > 0 {
writer.column_position -= 1;
writer.write_byte(b' ');
writer.column_position -= 1;
}
}
설명
이것이 하는 일: 키보드 인터럽트 핸들러는 IRQ 1이 발생할 때 자동으로 호출되어, 0x60 포트에서 스캔코드를 읽고, pc_keyboard 크레이트를 사용하여 이를 실제 문자나 키코드로 변환하여 화면에 출력합니다. 첫 번째로, 인터럽트 핸들러 등록의 원리를 이해해야 합니다.
IDT(Interrupt Descriptor Table)에 keyboard_interrupt_handler 함수를 33번(IRQ 1 + 32)에 등록하면, 키보드 이벤트가 발생할 때 CPU가 자동으로 이 함수를 호출합니다. extern "x86-interrupt" 호출 규약은 인터럽트 핸들러에 필요한 특별한 스택 프레임 처리를 자동으로 수행합니다.
그 다음으로, 스캔코드 읽기와 변환 과정을 살펴봅시다. Port::new(0x60).read()로 바이트를 읽으면 이것이 스캔코드입니다.
예를 들어 'A' 키를 누르면 0x1E(Set 1의 'A' make code)가 옵니다. pc_keyboard 크레이트의 add_byte()는 이 스캔코드를 버퍼에 추가하고, process_keyevent()는 Shift 상태를 고려하여 'a' 또는 'A'로 변환합니다.
이 크레이트는 멀티바이트 스캔코드(화살표 키 등)도 올바르게 처리합니다. 세 번째로, 키 이벤트의 두 가지 타입을 이해해야 합니다.
DecodedKey::Unicode는 출력 가능한 문자('a', '1', ' ' 등)이고, DecodedKey::RawKey는 특수 키(Enter, Backspace, F1 등)입니다. Unicode는 그대로 print!하면 되지만, RawKey는 애플리케이션이 의미를 부여해야 합니다.
예를 들어 Backspace는 커서를 뒤로 이동하고 공백을 쓰는 로직을 수동으로 구현해야 합니다. 마지막으로, EOI(End Of Interrupt) 신호의 중요성을 알아야 합니다.
PIC(Programmable Interrupt Controller)는 인터럽트를 전달한 후 EOI를 받을 때까지 같은 IRQ의 추가 인터럽트를 차단합니다. notify_end_of_interrupt()를 호출하지 않으면 첫 번째 키 입력 후 모든 키보드가 멈춥니다.
반드시 핸들러 끝에서 호출해야 합니다. 여러분이 이 코드를 사용하면 실제 하드웨어에서 키보드 입력을 받아 화면에 출력하고, 간단한 텍스트 기반 인터페이스를 만들 수 있습니다.
이것이 셸(shell)의 기초이며, 여기서부터 명령어 파싱, 파일 시스템 탐색, 프로그램 실행 등으로 확장할 수 있습니다.
실전 팁
💡 스캔코드 세트를 확인하세요. 대부분의 키보드는 Set 2를 사용하지만, BIOS가 Set 1로 변환하는 경우가 많습니다. 잘못된 세트를 선택하면 키 매핑이 엉망이 됩니다.
💡 인터럽트 핸들러에서 println!을 과도하게 사용하지 마세요. 출력이 느리면 키보드 버퍼가 오버플로우되어 입력이 손실됩니다. 입력을 큐에 저장하고 나중에 처리하는 것이 좋습니다.
💡 USB 키보드가 작동하지 않으면 BIOS에서 "USB Legacy Support"를 활성화하세요. 이 옵션이 꺼져 있으면 USB 키보드가 PS/2로 에뮬레이션되지 않습니다.
💡 Ctrl+C, Ctrl+D 같은 제어 문자를 구현하려면 HandleControl::MapLettersToUnicode를 사용하세요. Ctrl+C는 Unicode 0x03으로 변환되어 시그널 처리에 활용할 수 있습니다.
💡 키 반복(key repeat)을 구현하려면 타이머와 결합하세요. 키를 계속 누르고 있으면 500ms 후부터 30ms 간격으로 반복 이벤트를 생성하는 것이 표준 동작입니다.
8. 메모리 매핑과 페이지 테이블 검증 - 실제 하드웨어의 메모리 레이아웃 확인
시작하며
여러분이 QEMU에서 메모리 관리를 구현했다면, 실제 하드웨어에서도 동일하게 작동할 거라 기대할 수 있습니다. 하지만 실제로는 페이지 폴트가 발생하거나, 특정 메모리 영역에 접근할 때 시스템이 멈춰버릴 수 있습니다.
실제 하드웨어의 메모리 레이아웃은 QEMU와 다릅니다. 이 문제는 메모리 맵의 다양성 때문에 발생합니다.
QEMU는 일관된 메모리 레이아웃을 제공하지만, 실제 하드웨어는 BIOS, 비디오 메모리, PCI 장치, ACPI 테이블 등이 서로 다른 주소에 매핑되어 있습니다. BIOS/UEFI가 제공하는 메모리 맵(E820 또는 UEFI 메모리 맵)을 파싱하지 않으면 예약된 영역에 데이터를 쓰게 되어 시스템이 불안정해집니다.
바로 이럴 때 필요한 것이 메모리 맵 파싱과 페이지 테이블 검증입니다. 부트로더가 전달하는 메모리 맵을 읽어 사용 가능한 메모리 영역을 파악하고, 예약된 영역은 페이지 테이블에서 제외해야 안전한 메모리 관리가 가능합니다.
개요
간단히 말해서, 메모리 맵은 물리 메모리의 어느 영역이 사용 가능(Usable)하고, 어느 영역이 예약됨(Reserved), 하드웨어 매핑됨(MMIO), 또는 ACPI 데이터인지 알려주는 정보입니다. BIOS는 INT 0x15, EAX=0xE820을 통해 E820 메모리 맵을 제공하고, UEFI는 GetMemoryMap() 함수를 제공합니다.
bootloader 크레이트는 이를 자동으로 파싱하여 Rust 구조체로 제공합니다. 각 엔트리는 베이스 주소, 크기, 타입(Usable, Reserved, ACPI Reclaimable 등)을 포함합니다.
예를 들어 0x00x9FC00은 보통 Usable이지만, 0xA00000xFFFFF는 VGA와 BIOS ROM으로 Reserved입니다. 기존에는 모든 메모리를 사용 가능하다고 가정했다면, 이제는 메모리 맵을 확인하여 Usable로 표시된 영역만 할당자에 추가해야 합니다.
예약된 영역에 할당하면 하드웨어 레지스터를 덮어쓰거나 펌웨어 데이터를 손상시켜 시스템이 크래시될 수 있습니다. 핵심 특징은 첫째, 실제 사용 가능한 메모리를 정확히 파악하고, 둘째, 하드웨어 매핑 영역을 피하며, 셋째, ACPI 테이블을 보존한다는 점입니다.
이러한 정보가 안정적인 메모리 관리의 기초가 됩니다.
코드 예제
// bootloader 크레이트가 제공하는 메모리 맵 사용
use bootloader::bootinfo::{BootInfo, MemoryMap, MemoryRegionType};
pub fn init_heap(boot_info: &'static BootInfo) {
use x86_64::structures::paging::{PageTableFlags, Size4KiB, Mapper};
// 부트로더가 제공한 메모리 맵 출력
serial_println!("Memory Map:");
for region in boot_info.memory_map.iter() {
serial_println!(
" {:#x} - {:#x} ({:?})",
region.range.start_addr(),
region.range.end_addr(),
region.region_type
);
}
// Usable 영역만 필터링
let usable_regions = boot_info.memory_map.iter()
.filter(|r| r.region_type == MemoryRegionType::Usable);
// 가장 큰 Usable 영역을 힙으로 사용
let largest_region = usable_regions
.max_by_key(|r| r.range.end_addr() - r.range.start_addr())
.expect("No usable memory region found");
let heap_start = largest_region.range.start_addr();
let heap_size = (largest_region.range.end_addr() - heap_start) as usize;
serial_println!("Heap: {:#x} - {:#x} ({} MB)",
heap_start,
heap_start + heap_size as u64,
heap_size / 1024 / 1024
);
// 힙 영역을 페이지 테이블에 매핑
let page_range = {
let heap_start_page = Page::<Size4KiB>::containing_address(VirtAddr::new(heap_start));
let heap_end_page = Page::containing_address(VirtAddr::new(heap_start + heap_size as u64 - 1));
Page::range_inclusive(heap_start_page, heap_end_page)
};
for page in page_range {
let frame = frame_allocator.allocate_frame()
.ok_or("Out of frames")?;
let flags = PageTableFlags::PRESENT | PageTableFlags::WRITABLE;
unsafe {
mapper.map_to(page, frame, flags, frame_allocator)?
.flush();
}
}
// 힙 할당자 초기화
unsafe {
ALLOCATOR.lock().init(heap_start as usize, heap_size);
}
}
설명
이것이 하는 일: 메모리 맵 파싱 코드는 부트로더가 전달한 BootInfo 구조체에서 메모리 영역 리스트를 읽고, 각 영역의 타입을 확인하여 Usable 영역만 선택한 후, 이를 페이지 테이블에 매핑하고 힙 할당자에 제공합니다. 첫 번째로, BootInfo 구조체의 역할을 이해해야 합니다.
bootloader 크레이트는 커널을 로드하기 전에 BIOS/UEFI로부터 메모리 맵을 수집하고, 이를 Rust 구조체로 변환하여 커널의 _start 함수에 매개변수로 전달합니다. memory_map 필드는 MemoryRegion의 반복자를 제공하며, 각 MemoryRegion은 시작 주소, 끝 주소, 타입을 포함합니다.
그 다음으로, 메모리 영역 타입의 의미를 알아야 합니다. MemoryRegionType::Usable은 일반 RAM으로 자유롭게 사용 가능합니다.
Reserved는 BIOS, 옵션 ROM, 하드웨어 레지스터 등으로 절대 건드리면 안 됩니다. AcpiReclaimable은 ACPI 테이블이 있지만 파싱 후에는 재사용 가능하고, AcpiNvs는 ACPI가 계속 사용하므로 건드리면 안 됩니다.
Bootloader는 부트로더 자신이 사용 중이므로 커널이 접근하면 안 됩니다. 세 번째로, 페이지 테이블 매핑의 과정을 이해해야 합니다.
VirtAddr를 Page로 변환하고, 프레임 할당자에서 물리 프레임을 할당받아, Mapper::map_to()로 가상 주소와 물리 주소를 연결합니다. PageTableFlags는 PRESENT(유효함), WRITABLE(쓰기 가능), USER_ACCESSIBLE(유저 모드 접근 가능) 등을 지정합니다.
힙은 커널이 읽고 쓰므로 PRESENT | WRITABLE이 필요합니다. 마지막으로, 프레임 할당자도 메모리 맵을 사용해야 합니다.
프레임 할당자는 Usable 영역의 물리 주소만 반환해야 하며, 이를 위해 메모리 맵을 내부 리스트로 저장합니다. 할당 요청이 오면 Usable 영역에서 4KB 정렬된 프레임을 찾아 반환하고, 해당 영역을 할당됨으로 표시합니다.
여러분이 이 방법을 사용하면 실제 하드웨어의 복잡한 메모리 레이아웃에서도 안전하게 메모리를 관리할 수 있습니다. 잘못된 영역을 건드려 시스템이 크래시되는 일이 사라지고, 사용 가능한 모든 RAM을 효율적으로 활용할 수 있습니다.
Linux도 동일한 메커니즘을 사용하여 수천 가지 하드웨어 구성을 지원합니다.
실전 팁
💡 메모리 맵을 부팅 초기에 시리얼로 출력하여 로그로 남기세요. 이상한 동작이 발생하면 메모리 레이아웃을 먼저 확인하는 것이 디버깅의 시작점입니다.
💡 0x0~0x1000 (첫 4KB)는 절대 사용하지 마세요. 이것은 null 포인터 역참조 감지용으로 예약되어 있고, 일부 BIOS는 IVT(Interrupt Vector Table)로 사용합니다.
💡 4GB 이상의 메모리(PAE)를 지원하려면 CR4의 PAE 비트를 활성화해야 합니다. 그렇지 않으면 메모리 맵에 4GB 이상 영역이 있어도 접근할 수 없습니다.
💡 ACPI Reclaimable 영역은 ACPI 테이블을 파싱한 후 회수하세요. 이 영역은 수 MB에 달할 수 있어 메모리가 부족한 시스템에서는 중요합니다.
💡 멀티부트2나 UEFI를 사용하면 부트로더가 이미 매핑한 페이지 테이블을 받습니다. 이를 그대로 사용할 수도 있지만, 보안을 위해 새 페이지 테이블을 만들고 필요한 것만 다시 매핑하는 것이 좋습니다.
9. 타이머 인터럽트와 PIT/APIC 타이머 - 시간 추적과 선점형 멀티태스킹 기초
시작하며
여러분의 OS가 입출력을 처리할 수 있게 되었다면, 이제 시간 개념이 필요합니다. 일정 시간 대기하거나(sleep), 작업의 실행 시간을 측정하거나, 나중에 구현할 멀티태스킹에서 프로세스를 전환하려면 정기적인 타이머 인터럽트가 필수입니다.
이 문제는 시간이 없는 시스템의 한계에서 발생합니다. 타이머 없이는 while 루프로 바쁘게 대기(busy-wait)해야 하고, CPU를 100% 사용하며, 정확한 시간 측정도 불가능합니다.
사용자 프로그램이 무한 루프에 빠지면 시스템 전체가 멈춰버립니다. 바로 이럴 때 필요한 것이 타이머 인터럽트입니다.
PIT(Programmable Interval Timer)나 APIC 타이머를 설정하면 일정 간격(예: 10ms)마다 인터럽트가 발생하여 커널이 제어권을 되찾고, 시간을 추적하고, 프로세스를 전환할 수 있습니다.
개요
간단히 말해서, 타이머 인터럽트는 하드웨어 타이머가 일정 주기로 IRQ 0(PIT) 또는 APIC 타이머 인터럽트를 발생시켜, 커널이 시간을 추적하고 스케줄링을 수행할 수 있게 하는 메커니즘입니다. PIT(8253/8254 칩)는 1980년대부터 사용된 레거시 타이머로, 1.193182 MHz 기본 주파수를 제공하고 분주비를 설정하여 원하는 인터럽트 주기를 만들 수 있습니다.
APIC(Advanced Programmable Interrupt Controller) 타이머는 멀티코어 시스템을 위한 현대적 타이머로, 각 CPU 코어마다 독립적인 타이머를 가지고 있어 SMP(Symmetric Multi-Processing)에 적합합니다. 기존에는 QEMU에서 타이머가 자동으로 작동했다면, 실제 하드웨어에서는 타이머를 명시적으로 초기화하고 인터럽트 핸들러를 등록해야 합니다.
타이머 주파수는 보통 100Hz(10ms)에서 1000Hz(1ms) 사이를 사용하는데, 너무 높으면 인터럽트 오버헤드가 커지고 너무 낮으면 응답성이 떨어집니다. 핵심 특징은 첫째, 정기적으로 제어권을 커널에 반환하고, 둘째, 시스템 시간(uptime)을 추적하며, 셋째, 선점형 멀티태스킹의 기초를 제공한다는 점입니다.
이러한 기능들이 현대 OS의 스케줄러를 가능하게 만듭니다.
코드 예제
// src/interrupts/timer.rs
use x86_64::instructions::port::Port;
use crate::println;
// 시스템 틱 카운터 (인터럽트 발생 횟수)
static TICKS: AtomicU64 = AtomicU64::new(0);
// PIT 초기화: 100Hz (10ms마다 인터럽트)
pub fn init_pit() {
const PIT_FREQUENCY: u32 = 1193182; // PIT 기본 주파수 (Hz)
const TARGET_FREQUENCY: u32 = 100; // 목표 인터럽트 빈도 (Hz)
let divisor = PIT_FREQUENCY / TARGET_FREQUENCY;
unsafe {
// 명령 레지스터 (0x43): 채널 0, lo/hi 바이트, 모드 3 (사각파)
Port::new(0x43).write(0b00110110u8);
// 채널 0 데이터 (0x40): divisor의 하위/상위 바이트 전송
Port::new(0x40).write((divisor & 0xFF) as u8);
Port::new(0x40).write((divisor >> 8) as u8);
}
serial_println!("PIT initialized: {}Hz ({}ms)", TARGET_FREQUENCY, 1000 / TARGET_FREQUENCY);
}
// 타이머 인터럽트 핸들러 (IRQ 0 = 인터럽트 32)
pub extern "x86-interrupt" fn timer_interrupt_handler(
_stack_frame: InterruptStackFrame
) {
// 틱 증가
TICKS.fetch_add(1, Ordering::Relaxed);
// 매 100틱(1초)마다 uptime 출력
let ticks = TICKS.load(Ordering::Relaxed);
if ticks % 100 == 0 {
println!("Uptime: {} seconds", ticks / 100);
}
// TODO: 프로세스 스케줄링
// schedule_next_process();
// EOI 신호
unsafe {
PICS.lock()
.notify_end_of_interrupt(InterruptIndex::Timer.as_u8());
}
}
// 밀리초 대기 함수 (타이머 기반)
pub fn sleep_ms(ms: u64) {
let target_ticks = TICKS.load(Ordering::Relaxed) + (ms / 10); // 10ms per tick
while TICKS.load(Ordering::Relaxed) < target_ticks {
x86_64::instructions::hlt(); // CPU를 쉬게 하여 전력 절약
}
}
// 시스템 uptime 조회 (밀리초)
pub fn uptime_ms() -> u64 {
TICKS.load(Ordering::Relaxed) * 10
}
설명
이것이 하는 일: PIT 초기화 코드는 기본 주파수를 분주하여 원하는 인터럽트 주기를 설정하고, 타이머 인터럽트 핸들러는 매 인터럽트마다 호출되어 틱 카운터를 증가시키고 스케줄링 로직을 실행할 수 있게 합니다. 첫 번째로, PIT 하드웨어의 구조를 이해해야 합니다.
PIT는 3개의 채널을 가지고 있는데, 채널 0은 IRQ 0에 연결되어 시스템 타이머로 사용되고, 채널 1은 DRAM 리프레시(구식), 채널 2는 PC 스피커에 연결되어 있습니다. 0x43 포트는 명령 레지스터로, 어느 채널을 설정할지, 어떤 모드를 사용할지 지정합니다.
0b00110110은 "채널 0, lo/hi 바이트 모드, 모드 3(사각파 생성)"을 의미합니다. 그 다음으로, 분주비(divisor) 계산을 살펴봅시다.
PIT의 기본 주파수는 1.193182 MHz로 고정되어 있고, 이를 divisor로 나눈 값이 실제 인터럽트 주파수가 됩니다. 100Hz를 원하면 1193182 / 100 = 11932를 divisor로 사용합니다.
이 값을 하위 바이트(11932 & 0xFF = 0x9C)와 상위 바이트(11932 >> 8 = 0x2E) 순서로 0x40 포트에 두 번 써야 합니다. PIT는 16비트 카운터이므로 두 번에 나눠 전송합니다.
세 번째로, 인터럽트 핸들러의 역할을 이해해야 합니다. timer_interrupt_handler는 10ms마다 호출되어 AtomicU64 타입의 TICKS를 증가시킵니다.
Atomic 타입을 사용하는 이유는 멀티코어 환경에서도 안전하게 접근하기 위함입니다. 여러 CPU가 동시에 TICKS를 읽거나 쓸 수 있으므로 원자적 연산이 필요합니다.
네 번째로, sleep_ms() 함수의 구현을 살펴봅시다. 목표 틱을 계산하고(현재 틱 + 대기 틱), while 루프로 목표에 도달할 때까지 대기합니다.
중요한 점은 hlt 명령을 사용한다는 것입니다. hlt는 다음 인터럽트가 발생할 때까지 CPU를 일시 정지시켜 전력 소비를 크게 줄입니다.
hlt 없이 빈 루프를 돌리면 CPU가 100% 사용되어 발열과 전력 낭비가 심합니다. 여러분이 이 타이머를 사용하면 정확한 시간 측정, 타임아웃, 그리고 나중에 구현할 선점형 멀티태스킹이 가능해집니다.
타이머 인터럽트 핸들러에 schedule_next_process()를 추가하면 10ms마다 프로세스가 전환되어 여러 프로그램이 동시에 실행되는 것처럼 보이는 멀티태스킹을 구현할 수 있습니다.
실전 팁
💡 타이머 주파수는 100Hz(10ms)가 일반적입니다. 1000Hz는 응답성이 좋지만 CPU 오버헤드가 10배 증가하고, 10Hz는 느려서 사용자 경험이 나쁩니다.
💡 멀티코어 시스템에서는 PIT 대신 APIC 타이머를 사용하세요. PIT는 전역적이지만 APIC는 코어마다 독립적이어서 SMP에 적합합니다. APIC 초기화는 더 복잡하지만 성능이 훨씬 좋습니다.
💡 타이머 인터럽트 핸들러는 가능한 한 빨라야 합니다. 무거운 작업은 하지 말고, 플래그만 설정한 후 메인 루프에서 처리하세요. 인터럽트가 오래 걸리면 다른 인터럽트가 지연됩니다.
💡 TICKS가 오버플로우할 수 있습니다. u64는 100Hz에서 5,849,424,173,791년이 지나야 오버플로우되므로 실용적으로는 문제없지만, 코드 리뷰에서는 지적받을 수 있습니다.
💡 실시간 벽시계 시간(wall clock time)도 필요하면 RTC(Real Time Clock, 0x70/0x71 포트)를 읽으세요. RTC는 배터리로 작동하여 컴퓨터를 꺼도 시간을 유지하지만, PIT는 상대 시간만 제공합니다.
10. 예외 처리와 패닉 핸들러 - 하드웨어 에러에서의 안전한 복구
시작하며
여러분의 OS가 실제 하드웨어에서 실행되면 QEMU에서는 볼 수 없었던 다양한 예외가 발생할 수 있습니다. 페이지 폴트, 일반 보호 오류(GPF), 더블 폴트 등이 발생하면 시스템이 즉시 재부팅되거나 멈춰버립니다.
에러 메시지조차 볼 수 없습니다. 이 문제는 예외 핸들러의 부재 때문에 발생합니다.
CPU가 예외를 발생시켰을 때 처리할 핸들러가 IDT(Interrupt Descriptor Table)에 등록되어 있지 않으면, CPU는 더블 폴트를 발생시키고, 그것도 처리되지 않으면 트리플 폴트로 시스템이 리셋됩니다. 디버깅 정보는 완전히 사라집니다.
바로 이럴 때 필요한 것이 포괄적인 예외 핸들러입니다. 각 예외 타입에 대한 핸들러를 등록하고, 에러 코드를 파싱하며, 스택 트레이스를 출력하면 무엇이 잘못되었는지 정확히 파악할 수 있습니다.
개요
간단히 말해서, 예외 핸들러는 CPU가 예외 상황(잘못된 메모리 접근, 나눗셈 0, 잘못된 명령어 등)을 감지했을 때 호출되는 함수로, IDT에 등록되어 시스템 크래시를 방지하고 디버깅 정보를 제공합니다. x86_64 아키텍처는 32개의 예외 벡터를 정의합니다.
0번은 Divide Error, 3번은 Breakpoint, 6번은 Invalid Opcode, 8번은 Double Fault, 13번은 General Protection Fault, 14번은 Page Fault 등입니다. 일부 예외는 에러 코드를 스택에 푸시하여 추가 정보를 제공합니다.
예를 들어 Page Fault는 어느 주소에서 발생했는지 CR2 레지스터에 저장합니다. 기존에는 panic!만으로 에러를 처리했다면, 이제는 각 예외 타입별로 세밀한 핸들러를 작성하여 복구 가능한 에러는 복구하고, 치명적인 에러는 상세한 정보와 함께 패닉할 수 있습니다.
예를 들어 Page Fault는 스왑 메모리로 복구 가능하지만, Double Fault는 거의 항상 치명적입니다. 핵심 특징은 첫째, 모든 예외 타입을 처리하고, 둘째, 에러 코드와 레지스터 상태를 출력하며, 셋째, 스택 트레이스를 제공한다는 점입니다.
이러한 정보들이 하드웨어 디버깅의 핵심입니다.
코드 예제
// src/interrupts.rs
use x86_64::structures::idt::{InterruptDescriptorTable, InterruptStackFrame, PageFaultErrorCode};
lazy_static! {
static ref IDT: InterruptDescriptorTable = {
let mut idt = InterruptDescriptorTable::new();
// 예외 핸들러 등록
idt.breakpoint.set_handler_fn(breakpoint_handler);
idt.double_fault.set_handler_fn(double_fault_handler);
idt.page_fault.set_handler_fn(page_fault_handler);
idt.general_protection_fault.set_handler_fn(gpf_handler);
idt.invalid_opcode.set_handler_fn(invalid_opcode_handler);
idt.divide_error.set_handler_fn(divide_error_handler);
// 인터럽트 핸들러 등록
idt[InterruptIndex::Timer.as_usize()]
.set_handler_fn(timer_interrupt_handler);
idt[InterruptIndex::Keyboard.as_usize()]
.set_handler_fn(keyboard_interrupt_handler);
idt
};
}
pub fn init_idt() {
IDT.load();
}
// Page Fault 핸들러: 가장 흔한 예외
extern "x86-interrupt" fn page_fault_handler(
stack_frame: InterruptStackFrame,
error_code: PageFaultErrorCode,
) {
use x86_64::registers::control::Cr2;
serial_println!("\nEXCEPTION: PAGE FAULT");
serial_println!("Accessed Address: {:?}", Cr2::read());
serial_println!("Error Code: {:?}", error_code);
serial_println!("{:#?}", stack_frame);
// 에러 코드 분석
if error_code.contains(PageFaultErrorCode::PROTECTION_VIOLATION) {
serial_println!(" → Protection violation (page present)");
} else {
serial_println!(" → Page not present");
}
if error_code.contains(PageFaultErrorCode::CAUSED_BY_WRITE) {
serial_println!(" → Caused by write");
} else {
serial_println!(" → Caused by read");
}
if error_code.contains(PageFaultErrorCode::USER_MODE) {
serial_println!(" → Occurred in user mode");
} else {
serial_println!(" → Occurred in kernel mode");
}
panic!("Page fault");
}
// Double Fault: 가장 치명적인 예외 (별도 스택 필요)
extern "x86-interrupt" fn double_fault_handler(
stack_frame: InterruptStackFrame,
_error_code: u64,
) -> ! {
panic!("EXCEPTION: DOUBLE FAULT\n{:#?}", stack_frame);
}
// General Protection Fault: 권한 위반
extern "x86-interrupt" fn gpf_handler(
stack_frame: InterruptStackFrame,
error_code: u64,
) {
serial_println!("\nEXCEPTION: GENERAL PROTECTION FAULT");
serial_println!("Error Code: {:#x}", error_code);
serial_println!("{:#?}", stack_frame);
// 에러 코드의 하위 3비트는 세그먼트 정보
let segment = error_code & 0xFFF8;
if segment != 0 {
serial_println!(" → Segment selector: {:#x}", segment);
}
panic!("General protection fault");
}
// Divide Error: 0으로 나누기
extern "x86-interrupt" fn divide_error_handler(
stack_frame: InterruptStackFrame,
) {
serial_println!("\nEXCEPTION: DIVIDE ERROR");
serial_println!("{:#?}", stack_frame);
panic!("Divide by zero");
}
설명
이것이 하는 일: IDT 초기화 코드는 각 예외 벡터에 핸들러 함수를 등록하여, CPU가 예외를 발생시킬 때 해당 핸들러가 자동으로 호출되도록 합니다. 핸들러는 에러 정보를 수집하고 출력한 후 panic으로 시스템을 안전하게 중단합니다.
첫 번째로, InterruptDescriptorTable의 구조를 이해해야 합니다. IDT는 256개의 엔트리를 가진 테이블로, 031번은 예외, 32255번은 인터럽트(하드웨어 IRQ, 소프트웨어 인터럽트 등)에 사용됩니다.
각 엔트리는 핸들러 함수의 주소, 세그먼트 셀렉터(커널 코드 세그먼트), 그리고 플래그(특권 레벨, 활성화 여부 등)를 포함합니다. x86_64 크레이트는 이를 안전한 Rust 인터페이스로 감싸줍니다.
그 다음으로, 예외 핸들러의 시그니처를 살펴봅시다. extern "x86-interrupt"는 특별한 호출 규약으로, 인터럽트 발생 시 CPU가 자동으로 푸시하는 스택 프레임(명령어 포인터, 코드 세그먼트, RFLAGS 등)을 InterruptStackFrame 타입으로 받습니다.
일부 예외는 추가로 error_code를 받는데, Page Fault는 PageFaultErrorCode라는 비트플래그 타입으로 에러 원인을 분석할 수 있습니다. 세 번째로, Page Fault 에러 코드의 의미를 이해해야 합니다.
PROTECTION_VIOLATION 비트가 설정되어 있으면 페이지는 존재하지만 권한이 없는 접근(예: 읽기 전용 페이지에 쓰기)이고, 설정되어 있지 않으면 페이지가 아예 매핑되지 않은 것입니다. CAUSED_BY_WRITE는 쓰기 접근인지 읽기 접근인지, USER_MODE는 유저 모드 코드에서 발생했는지 커널 모드에서 발생했는지를 알려줍니다.
CR2 레지스터는 접근하려 한 가상 주소를 포함합니다. 네 번째로, Double Fault의 특별한 처리를 알아야 합니다.
Double Fault는 예외 핸들러에서 또 다른 예외가 발생할 때 일어나는데(예: Page Fault 핸들러가 스택 오버플로우로 또 Page Fault를 발생), 일반 스택이 손상된 경우가 많아 별도의 IST(Interrupt Stack Table) 스택을 사용해야 합니다. 그렇지 않으면 트리플 폴트로 리셋됩니다.
여러분이 이 예외 핸들러들을 구현하면 실제 하드웨어에서 크래시가 발생해도 원인을 정확히 파악할 수 있습니다. 시리얼 콘솔에 출력된 스택 트레이스로 어느 함수에서 문제가 발생했는지, 어떤 메모리 주소가 문제인지, 왜 권한이 거부되었는지 모두 알 수 있어 디버깅 시간이 크게 단축됩니다.
실전 팁
💡 Double Fault 핸들러는 반드시 IST(Interrupt Stack Table)를 사용하세요. TSS(Task State Segment)에 별도 스택을 설정하지 않으면 Double Fault도 처리하지 못해 트리플 폴트가 됩니다.
💡 Breakpoint 예외(INT3)는 디버깅에