이미지 로딩 중...
AI Generated
2025. 11. 14. · 3 Views
Rust로 만드는 나만의 OS 입출력 포트 관리
x86/x64 아키텍처의 I/O 포트를 안전하게 관리하는 방법을 배워봅니다. 하드웨어와 직접 통신하는 저수준 프로그래밍 기법과 Rust의 안전성을 결합한 실전 OS 개발 가이드입니다.
목차
- I/O 포트의 기본 개념과 동작 원리
- Port 구조체를 통한 타입 안전 추상화
- 시리얼 포트 드라이버 구현
- PIC(Programmable Interrupt Controller) 제어
- PIT(Programmable Interval Timer) 설정
- 포트 접근 권한과 IOPL
- Memory-Mapped I/O와의 차이점
- 안전한 포트 추상화 라이브러리 설계
- DMA와 I/O 포트의 상호작용
- 포트 접근 최적화와 성능
1. I/O 포트의 기본 개념과 동작 원리
시작하며
여러분이 키보드 입력을 받거나 화면에 문자를 출력하는 OS 코드를 작성할 때, "도대체 하드웨어와 어떻게 통신하지?"라는 의문을 가져본 적 있나요? 운영체제는 메모리 접근만으로는 모든 하드웨어를 제어할 수 없습니다.
x86/x64 아키텍처에서는 메모리 주소 공간과 별개로 I/O 포트라는 특별한 주소 공간이 존재합니다. 이 공간은 0x0000부터 0xFFFF까지 65,536개의 포트로 구성되어 있으며, 각 포트는 특정 하드웨어 장치와 연결되어 있습니다.
예를 들어 시리얼 포트는 0x3F8, VGA 컨트롤러는 0x3D4 같은 고정된 주소를 사용합니다. 바로 이럴 때 필요한 것이 I/O 포트 관리 시스템입니다.
CPU의 특수 명령어인 in과 out을 사용하여 하드웨어와 직접 데이터를 주고받을 수 있습니다.
개요
간단히 말해서, I/O 포트는 CPU가 하드웨어 장치와 통신하기 위한 별도의 주소 공간입니다. 메모리 맵 I/O와 달리 포트 맵 I/O는 전용 명령어를 사용합니다.
이는 메모리 공간을 절약하고, 하드웨어 접근을 명확히 구분할 수 있다는 장점이 있습니다. 예를 들어, PIC(Programmable Interrupt Controller) 설정이나 PIT(Programmable Interval Timer) 제어 같은 경우에 매우 유용합니다.
기존에는 어셈블리로 in al, 0x60 같은 코드를 직접 작성했다면, 이제는 Rust의 타입 안전성과 인라인 어셈블리를 활용하여 안전하게 포트 접근을 추상화할 수 있습니다. I/O 포트의 핵심 특징은 첫째, 8비트/16비트/32비트 크기로 읽기/쓰기가 가능하고, 둘째, Ring 0(커널 모드)에서만 접근 가능하며, 셋째, 순차적 접근이 보장된다는 점입니다.
이러한 특징들이 하드웨어 레지스터를 안정적으로 제어하는 데 필수적입니다.
코드 예제
// x86/x64 아키텍처의 I/O 포트 읽기/쓰기 기본 함수
use core::arch::asm;
// 8비트 포트에서 데이터 읽기
#[inline]
pub unsafe fn inb(port: u16) -> u8 {
let value: u8;
asm!("in al, dx", out("al") value, in("dx") port, options(nomem, nostack));
value
}
// 8비트 데이터를 포트에 쓰기
#[inline]
pub unsafe fn outb(port: u16, value: u8) {
asm!("out dx, al", in("dx") port, in("al") value, options(nomem, nostack));
}
설명
이것이 하는 일: I/O 포트를 통해 하드웨어 레지스터에 직접 접근하여 데이터를 읽거나 쓰는 저수준 인터페이스를 제공합니다. 첫 번째로, inb 함수는 지정된 포트 번호에서 1바이트를 읽어옵니다.
asm! 매크로를 사용하여 인라인 어셈블리 in al, dx를 실행하는데, 여기서 dx 레지스터에는 포트 번호가, al 레지스터에는 읽은 값이 저장됩니다. options(nomem, nostack)는 컴파일러에게 이 명령이 메모리나 스택을 건드리지 않는다고 알려줍니다.
그 다음으로, outb 함수가 실행되면서 반대 방향으로 데이터를 전송합니다. out dx, al 명령어는 al 레지스터의 값을 dx가 가리키는 포트로 출력합니다.
이때 unsafe 키워드가 필수인 이유는 잘못된 포트 접근이 시스템 전체를 불안정하게 만들 수 있기 때문입니다. 마지막으로, 이 함수들은 #[inline]으로 표시되어 컴파일 시 호출 오버헤드 없이 직접 인라인됩니다.
최종적으로 하드웨어 레지스터 접근이라는 타이밍에 민감한 작업을 최적화된 형태로 수행할 수 있습니다. 여러분이 이 코드를 사용하면 키보드 컨트롤러(0x60), 시리얼 포트(0x3F8), PIC(0x20, 0xA0) 등 다양한 하드웨어를 제어할 수 있습니다.
Ring 0 권한에서만 동작하므로 커널 개발에 필수적이며, 타입 안전성 덕분에 포트 번호 실수를 컴파일 타임에 잡을 수 있습니다.
실전 팁
💡 I/O 포트 접근 후에는 항상 적절한 딜레이를 추가하세요. 일부 하드웨어는 연속된 포트 접근 사이에 대기 시간이 필요합니다. outb(0x80, 0)을 사용한 I/O delay가 일반적입니다.
💡 포트 번호를 하드코딩하지 말고 상수로 정의하세요. const COM1_PORT: u16 = 0x3F8; 같은 방식으로 가독성과 유지보수성을 높일 수 있습니다.
💡 16비트(inw/outw)와 32비트(inl/outl) 버전도 함께 구현하세요. PCI 설정 공간 접근 등에 필수적입니다.
💡 디버깅 시 QEMU의 -d int,cpu_reset 옵션을 사용하면 포트 접근 로그를 확인할 수 있습니다.
💡 멀티코어 환경에서는 포트 접근에 뮤텍스를 사용하여 경쟁 조건을 방지하세요. 특히 PIC나 PIT 같은 공유 하드웨어는 주의가 필요합니다.
2. Port 구조체를 통한 타입 안전 추상화
시작하며
여러분이 여러 곳에서 unsafe { outb(0x3F8, data) } 같은 코드를 반복해서 작성하다 보면, "이거 포트 번호 틀리면 어떡하지?"라는 불안감을 느낀 적 있나요? 직접적인 함수 호출은 타입 체크도 없고, 실수로 잘못된 포트에 접근할 위험이 큽니다.
이런 문제는 실제 OS 개발에서 치명적인 버그로 이어집니다. 잘못된 포트에 데이터를 쓰면 다른 하드웨어가 오작동하거나, 심지어 시스템이 멈출 수도 있습니다.
특히 PIC의 마스크 레지스터를 잘못 건드리면 인터럽트가 완전히 차단될 수 있습니다. 바로 이럴 때 필요한 것이 Port 구조체입니다.
포트 번호를 타입에 캡슐화하여 컴파일 타임에 안전성을 확보하고, 각 포트마다 전용 인스턴스를 만들어 명확성을 높일 수 있습니다.
개요
간단히 말해서, Port 구조체는 특정 I/O 포트 번호를 감싸는 타입 안전 래퍼입니다. 제네릭 타입을 사용하여 포트의 데이터 크기(u8, u16, u32)를 컴파일 타임에 강제할 수 있습니다.
이는 8비트 포트에 16비트 데이터를 쓰려는 실수를 원천 차단합니다. 예를 들어, 시리얼 포트의 데이터 레지스터는 Port<u8>, PCI 설정 공간은 Port<u32> 같은 방식으로 타입을 명확히 합니다.
기존에는 매번 포트 번호를 인자로 전달했다면, 이제는 let mut serial = Port::new(0x3F8)로 한 번만 생성하고 serial.write(b'A')처럼 간결하게 사용할 수 있습니다. Port 구조체의 핵심 특징은 첫째, 제네릭을 통한 타입 안전성, 둘째, 불변성 제어를 통한 실수 방지, 셋째, 메서드 체이닝으로 가독성 향상입니다.
이러한 특징들이 수천 줄의 드라이버 코드를 관리 가능하게 만듭니다.
코드 예제
// 타입 안전 I/O 포트 추상화
use core::marker::PhantomData;
pub struct Port<T> {
port: u16,
phantom: PhantomData<T>,
}
impl<T> Port<T> {
pub const fn new(port: u16) -> Self {
Port { port, phantom: PhantomData }
}
}
impl Port<u8> {
// 8비트 읽기
pub unsafe fn read(&mut self) -> u8 {
inb(self.port)
}
// 8비트 쓰기
pub unsafe fn write(&mut self, value: u8) {
outb(self.port, value)
}
}
설명
이것이 하는 일: 포트 번호와 데이터 타입을 하나의 구조체로 묶어 안전하고 편리한 하드웨어 접근 인터페이스를 제공합니다. 첫 번째로, Port<T> 구조체는 port 필드에 포트 번호를 저장하고, PhantomData<T>로 타입 정보를 컴파일 타임에만 유지합니다.
PhantomData는 런타임 오버헤드가 전혀 없으며, 단지 컴파일러에게 "이 구조체는 T 타입과 관련이 있다"고 알려주는 역할만 합니다. const fn으로 정의하여 컴파일 타임 상수로도 생성 가능합니다.
그 다음으로, impl Port<u8> 블록이 실행되면서 8비트 포트 전용 메서드를 구현합니다. read와 write 메서드는 내부적으로 앞서 정의한 inb/outb를 호출하지만, 이제 포트 번호가 구조체에 캡슐화되어 있어 매번 전달할 필요가 없습니다.
&mut self를 사용하여 동시 접근을 방지합니다. 마지막으로, 제네릭 특화(specialization)를 통해 Port<u16>과 Port<u32>에 대한 별도 구현을 추가할 수 있습니다.
최종적으로 잘못된 타입으로 포트를 사용하려 하면 컴파일 에러가 발생하여 런타임 버그를 예방할 수 있습니다. 여러분이 이 코드를 사용하면 각 하드웨어 장치마다 전용 Port 인스턴스를 만들어 코드의 의도를 명확히 할 수 있습니다.
타입 시스템이 실수를 잡아주므로 디버깅 시간이 크게 줄어들며, const fn 덕분에 정적 변수로도 선언할 수 있어 초기화 순서 문제도 해결됩니다.
실전 팁
💡 Port 인스턴스는 static mut보다 lazy_static!이나 OnceCell로 관리하세요. 안전한 초기화와 동시성 제어가 가능합니다.
💡 읽기 전용 포트는 별도의 ReadOnlyPort<T> 타입으로 분리하면 실수로 쓰기를 시도하는 것을 방지할 수 있습니다.
💡 디버그 빌드에서만 포트 접근 로그를 남기려면 조건부 컴파일을 활용하세요: #[cfg(debug_assertions)] log::trace!("Port {:#x} write: {:#x}", self.port, value);
💡 포트 범위 검증을 추가하세요. assert!(port <= 0xFFFF)로 유효하지 않은 포트 번호를 걸러낼 수 있습니다.
💡 시리얼 포트처럼 여러 레지스터를 가진 장치는 SerialPort 같은 상위 추상화를 만들고, 내부에 여러 Port 인스턴스를 두는 것이 좋습니다.
3. 시리얼 포트 드라이버 구현
시작하며
여러분이 OS 개발 초기에 디버깅 출력을 어떻게 할지 고민해본 적 있나요? VGA 텍스트 모드는 초기화가 복잡하고, 그래픽 모드는 더욱 어렵습니다.
가장 간단하면서도 강력한 방법은 시리얼 포트입니다. 시리얼 포트는 COM1부터 COM4까지 있으며, 각각 0x3F8, 0x2F8, 0x3E8, 0x2E8 주소를 사용합니다.
QEMU나 Bochs 같은 에뮬레이터는 시리얼 출력을 터미널로 리디렉션할 수 있어 실시간 로그 확인이 매우 편리합니다. 실제 하드웨어에서도 USB-to-Serial 어댑터로 연결하여 사용할 수 있습니다.
바로 이럴 때 필요한 것이 시리얼 포트 드라이버입니다. UART(Universal Asynchronous Receiver/Transmitter) 레지스터를 제어하여 문자 단위 입출력을 구현할 수 있습니다.
개요
간단히 말해서, 시리얼 포트 드라이버는 UART 하드웨어를 추상화하여 텍스트 기반 입출력을 제공하는 소프트웨어 계층입니다. COM 포트는 여러 개의 레지스터로 구성되어 있습니다.
데이터 레지스터(+0), 인터럽트 활성화(+1), FIFO 제어(+2), 라인 제어(+3), 모뎀 제어(+4), 라인 상태(+5) 등이 있으며, 각각의 역할이 명확합니다. 예를 들어, 보드레이트 설정이나 데이터 비트, 패리티, 스톱 비트 구성 같은 경우에 라인 제어 레지스터를 조작합니다.
기존에는 BIOS 인터럽트로 문자 출력을 했다면, 이제는 직접 하드웨어를 제어하여 커널 모드에서도 자유롭게 사용할 수 있습니다. 시리얼 드라이버의 핵심 특징은 첫째, 초기화 시 보드레이트와 데이터 포맷 설정이 가능하고, 둘째, 폴링 방식과 인터럽트 방식을 모두 지원하며, 셋째, fmt::Write 트레이트 구현으로 Rust의 포맷팅 시스템과 통합된다는 점입니다.
이러한 특징들이 커널 로깅 시스템의 기반이 됩니다.
코드 예제
// 시리얼 포트 드라이버 기본 구현
pub struct SerialPort {
data: Port<u8>,
int_en: Port<u8>,
fifo_ctrl: Port<u8>,
line_ctrl: Port<u8>,
modem_ctrl: Port<u8>,
line_status: Port<u8>,
}
impl SerialPort {
pub const fn new(base: u16) -> Self {
SerialPort {
data: Port::new(base),
int_en: Port::new(base + 1),
fifo_ctrl: Port::new(base + 2),
line_ctrl: Port::new(base + 3),
modem_ctrl: Port::new(base + 4),
line_status: Port::new(base + 5),
}
}
// 초기화: 115200 보드레이트, 8N1 설정
pub unsafe fn init(&mut self) {
self.int_en.write(0x00); // 모든 인터럽트 비활성화
self.line_ctrl.write(0x80); // DLAB 활성화
self.data.write(0x01); // divisor low: 115200 baud
self.int_en.write(0x00); // divisor high
self.line_ctrl.write(0x03); // 8비트, 패리티 없음, 1 스톱비트
self.fifo_ctrl.write(0xC7); // FIFO 활성화, 14바이트 트리거
self.modem_ctrl.write(0x0B);// RTS/DSR 설정
}
// 전송 버퍼가 비었는지 확인
fn is_transmit_empty(&mut self) -> bool {
unsafe { self.line_status.read() & 0x20 != 0 }
}
// 1바이트 전송
pub fn write_byte(&mut self, byte: u8) {
while !self.is_transmit_empty() {}
unsafe { self.data.write(byte); }
}
}
설명
이것이 하는 일: UART 칩의 여러 레지스터를 체계적으로 관리하며, 초기화부터 데이터 송수신까지 완전한 시리얼 통신 기능을 제공합니다. 첫 번째로, SerialPort 구조체는 6개의 주요 레지스터를 Port<u8> 타입으로 보유합니다.
각 레지스터는 베이스 주소에서 고정된 오프셋에 위치하며, const fn new를 통해 컴파일 타임에 생성 가능합니다. 이렇게 하는 이유는 정적 변수로 선언하여 전역적으로 사용할 수 있게 하기 위함입니다.
그 다음으로, init 메서드가 실행되면서 하드웨어를 사용 가능한 상태로 만듭니다. DLAB(Divisor Latch Access Bit)을 활성화하여 보드레이트 설정 모드로 전환한 후, divisor 값을 설정합니다.
115200 baud는 divisor 1에 해당하며, 내부적으로 1.8432MHz 클럭을 16으로 나눈 후 다시 divisor로 나누어 최종 속도를 결정합니다. 세 번째 단계로, FIFO 버퍼를 활성화합니다.
0xC7 값은 FIFO 활성화(0x01), 수신/송신 버퍼 클리어(0x06), 14바이트 인터럽트 트리거(0xC0)를 의미합니다. FIFO가 없으면 매 바이트마다 인터럽트가 발생하여 성능이 저하되므로 반드시 활성화해야 합니다.
마지막으로, write_byte는 전송 버퍼가 빌 때까지 폴링한 후 데이터를 씁니다. 라인 상태 레지스터의 비트 5(Transmitter Holding Register Empty)를 확인하여 안전한 타이밍을 보장합니다.
최종적으로 데이터 레지스터에 바이트를 쓰면 UART가 자동으로 시리얼 라인으로 전송합니다. 여러분이 이 코드를 사용하면 serial.write_byte(b'H')처럼 간단히 문자를 출력할 수 있습니다.
QEMU에서 -serial stdio 옵션을 사용하면 터미널에서 바로 확인 가능하며, 인터럽트를 활성화하면 입력도 처리할 수 있습니다. 초기 부팅 로그부터 패닉 메시지까지 모든 디버깅 정보를 출력하는 핵심 인프라가 됩니다.
실전 팁
💡 초기화 후 자가 진단 테스트를 수행하세요. 모뎀 제어 레지스터를 루프백 모드로 설정하고 데이터를 보낸 후 받아서 비교하면 하드웨어 정상 여부를 확인할 수 있습니다.
💡 fmt::Write 트레이트를 구현하면 write!(serial, "Value: {}", x) 같은 포맷팅이 가능합니다: fn write_str(&mut self, s: &str) -> fmt::Result { for byte in s.bytes() { self.write_byte(byte); } Ok(()) }
💡 인터럽트 모드를 사용할 때는 인터럽트 식별 레지스터(+2)를 읽어 인터럽트 원인을 파악하세요. 수신 데이터, 전송 완료, 에러 등 여러 원인이 있습니다.
💡 실제 하드웨어에서는 포트가 존재하지 않을 수 있으니 초기화 후 라인 상태 레지스터를 읽어 0xFF가 아닌지 확인하세요. 0xFF는 포트가 없다는 신호입니다.
💡 멀티코어 환경에서는 스핀락으로 시리얼 포트 접근을 보호하세요. 여러 CPU가 동시에 출력하면 문자가 섞일 수 있습니다.
4. PIC(Programmable Interrupt Controller) 제어
시작하며
여러분이 키보드 입력이나 타이머 인터럽트를 받으려고 할 때, "인터럽트는 어떻게 활성화하지?"라는 막막함을 느낀 적 있나요? x86 시스템에서 하드웨어 인터럽트는 PIC라는 칩이 관리합니다.
8259 PIC는 1970년대부터 사용된 레거시 칩으로, 마스터와 슬레이브 두 개가 캐스케이드 연결되어 총 15개의 IRQ 라인을 제공합니다. 최신 시스템은 APIC를 사용하지만, 초기 부팅 단계에서는 여전히 PIC를 거쳐야 합니다.
PIC를 잘못 설정하면 인터럽트가 전혀 발생하지 않거나, 엉뚱한 핸들러가 호출될 수 있습니다. 바로 이럴 때 필요한 것이 PIC 제어 코드입니다.
포트 I/O를 통해 초기화 및 마스킹을 수행하여 원하는 인터럽트만 선택적으로 받을 수 있습니다.
개요
간단히 말해서, PIC 제어는 하드웨어 인터럽트의 라우팅과 우선순위를 관리하는 시스템입니다. PIC는 두 개의 주요 포트를 가집니다.
마스터 PIC는 0x20(명령)과 0x21(데이터), 슬레이브 PIC는 0xA0과 0xA1입니다. ICW(Initialization Command Words)와 OCW(Operation Command Words)라는 특수한 명령 시퀀스로 제어하며, 순서가 틀리면 정상 동작하지 않습니다.
예를 들어, 인터럽트 벡터 재매핑이나 EOI(End of Interrupt) 신호 같은 경우에 정확한 타이밍이 중요합니다. 기존에는 BIOS가 PIC를 설정했지만, 보호 모드나 롱 모드로 전환하면 인터럽트 번호가 CPU 예외와 충돌합니다.
이제는 직접 재매핑하여 IRQ 0-15를 인터럽트 32-47로 이동시킬 수 있습니다. PIC 제어의 핵심 특징은 첫째, 초기화 시 4개의 ICW를 순차적으로 보내야 하고, 둘째, 마스크 레지스터로 개별 IRQ를 활성화/비활성화할 수 있으며, 셋째, 인터럽트 처리 후 EOI 신호를 보내야 다음 인터럽트를 받는다는 점입니다.
이러한 특징들이 안정적인 인터럽트 처리를 가능케 합니다.
코드 예제
// PIC 초기화 및 재매핑
const PIC1_CMD: u16 = 0x20;
const PIC1_DATA: u16 = 0x21;
const PIC2_CMD: u16 = 0xA0;
const PIC2_DATA: u16 = 0xA1;
const ICW1_INIT: u8 = 0x11; // 초기화 + ICW4 필요
const ICW4_8086: u8 = 0x01; // 8086 모드
pub unsafe fn init_pic(offset1: u8, offset2: u8) {
// 기존 마스크 저장
let mask1 = inb(PIC1_DATA);
let mask2 = inb(PIC2_DATA);
// ICW1: 초기화 시작
outb(PIC1_CMD, ICW1_INIT);
io_wait();
outb(PIC2_CMD, ICW1_INIT);
io_wait();
// ICW2: 인터럽트 벡터 오프셋 설정
outb(PIC1_DATA, offset1);
io_wait();
outb(PIC2_DATA, offset2);
io_wait();
// ICW3: 캐스케이드 설정 (마스터 IRQ2에 슬레이브 연결)
outb(PIC1_DATA, 0x04);
io_wait();
outb(PIC2_DATA, 0x02);
io_wait();
// ICW4: 8086 모드
outb(PIC1_DATA, ICW4_8086);
io_wait();
outb(PIC2_DATA, ICW4_8086);
io_wait();
// 마스크 복원
outb(PIC1_DATA, mask1);
outb(PIC2_DATA, mask2);
}
fn io_wait() {
unsafe { outb(0x80, 0); } // 미사용 포트로 지연
}
// EOI 신호 전송
pub unsafe fn send_eoi(irq: u8) {
if irq >= 8 {
outb(PIC2_CMD, 0x20); // 슬레이브 EOI
}
outb(PIC1_CMD, 0x20); // 마스터 EOI
}
설명
이것이 하는 일: 8259 PIC 칩을 프로그래밍하여 하드웨어 인터럽트를 CPU 예외와 분리하고, 선택적 인터럽트 활성화를 가능하게 합니다. 첫 번째로, 초기화 전에 현재 마스크를 저장합니다.
이는 BIOS가 설정한 값을 보존하거나, 재초기화 시 상태를 유지하기 위함입니다. ICW1을 0x11로 보내면 PIC가 초기화 모드로 들어가며, 이후 ICW2-4를 순차적으로 받을 준비를 합니다.
io_wait()는 포트 0x80에 더미 값을 써서 약간의 지연을 만드는데, 일부 구형 하드웨어가 연속된 포트 접근을 처리하지 못하기 때문입니다. 그 다음으로, ICW2에서 인터럽트 벡터 오프셋을 설정합니다.
offset1은 보통 32, offset2는 40으로 설정하여 IRQ 0-7은 INT 32-39로, IRQ 8-15는 INT 40-47로 매핑됩니다. 이렇게 하면 CPU 예외(INT 0-31)와 충돌하지 않습니다.
ICW3에서는 마스터의 IRQ2에 슬레이브가 연결되어 있다고 알려줍니다(0x04는 비트 2를 의미). 세 번째 단계로, ICW4에서 8086 모드를 설정합니다.
이는 자동 EOI 없이 수동 EOI를 사용하겠다는 의미로, 인터럽트 처리 후 명시적으로 send_eoi를 호출해야 합니다. 자동 EOI는 편리하지만 중첩된 인터럽트 처리에서 문제가 발생할 수 있어 보통 사용하지 않습니다.
마지막으로, send_eoi 함수는 인터럽트 처리 완료를 PIC에 알립니다. IRQ 8 이상(슬레이브 PIC)이면 슬레이브와 마스터 모두에 EOI를 보내야 합니다.
최종적으로 EOI를 보내지 않으면 해당 우선순위 이하의 인터럽트가 모두 차단되므로 반드시 호출해야 합니다. 여러분이 이 코드를 사용하면 타이머(IRQ 0), 키보드(IRQ 1), 마우스(IRQ 12) 등 모든 하드웨어 인터럽트를 받을 수 있습니다.
초기화 후 outb(PIC1_DATA, 0xFC)처럼 마스크 레지스터를 조작하여 타이머와 키보드만 활성화하는 식으로 세밀한 제어가 가능합니다. APIC로 전환하기 전까지 모든 인터럽트 관리의 기반이 됩니다.
실전 팁
💡 APIC 사용 시 PIC를 완전히 비활성화하세요. 두 모드를 혼용하면 인터럽트가 중복 발생합니다: outb(PIC1_DATA, 0xFF); outb(PIC2_DATA, 0xFF);
💡 스퓨리어스 인터럽트(IRQ 7, 15)를 처리하세요. ISR(In-Service Register)을 읽어 실제 인터럽트인지 확인 후 EOI를 보내야 합니다.
💡 IRQ 마스크/언마스크 함수를 만들어 개별 제어를 편리하게 하세요: pub fn mask_irq(irq: u8) { let port = if irq < 8 { PIC1_DATA } else { PIC2_DATA }; let value = inb(port) | (1 << (irq % 8)); outb(port, value); }
💡 멀티코어 시스템에서는 부팅 CPU(BSP)만 PIC를 초기화하세요. 애플리케이션 프로세서(AP)는 APIC만 설정합니다.
💡 디버깅 시 IRR(Interrupt Request Register)과 ISR을 읽어 인터럽트 상태를 확인하세요. outb(PIC1_CMD, 0x0A) 후 inb(PIC1_CMD)로 IRR을 읽습니다.
5. PIT(Programmable Interval Timer) 설정
시작하며
여러분이 OS에서 시간을 측정하거나 스케줄링을 구현하려고 할 때, "정확한 타이밍을 어떻게 얻지?"라는 고민을 해본 적 있나요? x86 시스템에는 8253/8254 PIT라는 타이머 칩이 내장되어 있습니다.
PIT는 1.193182MHz의 고정 클럭을 분주하여 원하는 주파수의 인터럽트를 생성합니다. 예를 들어 100Hz(10ms마다)로 설정하면 정확한 시간 간격으로 스케줄러를 호출할 수 있습니다.
하지만 잘못 설정하면 너무 빠른 인터럽트로 CPU를 낭비하거나, 너무 느려서 응답성이 떨어질 수 있습니다. 바로 이럴 때 필요한 것이 PIT 프로그래밍입니다.
채널 0을 레이트 제너레이터 모드로 설정하여 주기적인 IRQ 0 인터럽트를 발생시킬 수 있습니다.
개요
간단히 말해서, PIT 설정은 하드웨어 타이머의 주파수를 프로그래밍하여 정확한 시간 인터럽트를 생성하는 과정입니다. PIT는 3개의 채널을 가지며, 각각 다른 용도로 사용됩니다.
채널 0은 시스템 타이머로 IRQ 0에 연결, 채널 1은 DRAM 리프레시(레거시), 채널 2는 PC 스피커 제어에 쓰입니다. 포트 0x40-0x42는 각 채널의 카운터, 0x43은 명령 레지스터입니다.
예를 들어, 채널 0을 100Hz로 설정하면 스케줄러나 시간 측정 같은 경우에 정확한 타이밍 기준을 제공합니다. 기존에는 고정된 18.2Hz 타이머를 사용했지만, 이제는 원하는 주파수로 자유롭게 설정하여 밀리초 단위의 정밀한 시간 관리가 가능합니다.
PIT 설정의 핵심 특징은 첫째, 분주비(divisor)를 계산하여 원하는 주파수를 얻고, 둘째, 모드 레지스터로 동작 방식(원샷, 주기적 등)을 선택하며, 셋째, LSB/MSB 순서로 16비트 카운터를 로드한다는 점입니다. 이러한 특징들이 시간 기반 기능의 기초가 됩니다.
코드 예제
// PIT 채널 0을 지정된 주파수로 설정
const PIT_CHANNEL0: u16 = 0x40;
const PIT_COMMAND: u16 = 0x43;
const PIT_FREQUENCY: u32 = 1193182; // PIT의 기본 주파수
// 모드/명령 레지스터 비트
const PIT_MODE_RATE_GEN: u8 = 0x04; // 모드 2: 레이트 제너레이터
const PIT_ACCESS_LOHI: u8 = 0x30; // LSB 먼저, 그다음 MSB
pub unsafe fn init_pit(frequency: u32) {
let divisor = (PIT_FREQUENCY / frequency) as u16;
// 명령 레지스터 설정: 채널 0, LSB/MSB, 레이트 제너레이터
let command = PIT_ACCESS_LOHI | PIT_MODE_RATE_GEN;
outb(PIT_COMMAND, command);
// LSB 전송
outb(PIT_CHANNEL0, (divisor & 0xFF) as u8);
// MSB 전송
outb(PIT_CHANNEL0, ((divisor >> 8) & 0xFF) as u8);
}
// 타이머 인터럽트 핸들러에서 호출할 틱 카운터
static mut TICKS: u64 = 0;
pub unsafe fn timer_tick() {
TICKS += 1;
send_eoi(0); // IRQ 0에 대한 EOI
}
pub fn get_ticks() -> u64 {
unsafe { TICKS }
}
설명
이것이 하는 일: 1.193182MHz 클럭을 분주하여 정확한 주기로 IRQ 0 인터럽트를 발생시키는 하드웨어 타이머를 프로그래밍합니다. 첫 번째로, 원하는 주파수를 얻기 위한 분주비를 계산합니다.
PIT의 기본 클럭을 목표 주파수로 나누면 되는데, 예를 들어 100Hz를 원하면 1193182 / 100 = 11931.82, 정수로 11932가 됩니다. 이 값은 16비트 범위(0-65535)에 맞아야 하므로 최소 주파수는 약 18.2Hz입니다.
이렇게 하는 이유는 하드웨어의 카운터 크기 제한 때문입니다. 그 다음으로, 명령 레지스터(0x43)에 설정 바이트를 보냅니다.
상위 2비트(0x00)는 채널 0 선택, 다음 2비트(0x30)는 LSB/MSB 양쪽 전송, 다음 3비트(0x04)는 모드 2(레이트 제너레이터) 선택, 마지막 비트는 바이너리 카운팅을 의미합니다. 모드 2는 카운터가 0에 도달하면 자동으로 리로드되어 주기적인 신호를 만듭니다.
세 번째 단계로, 분주비를 LSB와 MSB로 나누어 채널 0 포트(0x40)에 순차적으로 씁니다. 하드웨어가 LSB를 먼저 받고 MSB를 나중에 받도록 설계되어 있어 순서가 중요합니다.
두 바이트를 모두 받으면 PIT가 즉시 카운팅을 시작하며, 설정된 분주비만큼 카운트다운 후 IRQ 0을 발생시킵니다. 마지막으로, timer_tick 함수는 인터럽트 핸들러에서 호출되어 전역 틱 카운터를 증가시킵니다.
최종적으로 get_ticks()로 부팅 이후 경과 틱 수를 얻어 밀리초 단위 시간을 계산하거나, 스케줄러의 타임 슬라이스를 관리할 수 있습니다. 여러분이 이 코드를 사용하면 정확히 10ms마다(100Hz 설정 시) 타이머 인터럽트를 받아 선점형 멀티태스킹을 구현할 수 있습니다.
sleep(ms) 함수는 현재 틱을 저장하고 목표 틱까지 대기하는 방식으로 구현되며, 벤치마킹이나 타임아웃 처리에도 활용됩니다. HPET나 APIC 타이머로 전환하기 전까지 시간 관리의 핵심입니다.
실전 팁
💡 1000Hz 이상은 권장하지 않습니다. 타이머 인터럽트가 너무 잦으면 컨텍스트 스위칭 오버헤드로 CPU 시간을 낭비합니다. 100-250Hz가 적정선입니다.
💡 현재 카운터 값을 읽으려면 래치 명령을 먼저 보내세요: outb(PIT_COMMAND, 0x00); let lsb = inb(PIT_CHANNEL0); let msb = inb(PIT_CHANNEL0); 이렇게 하면 짧은 시간 측정이 가능합니다.
💡 채널 2로 PC 스피커를 제어하여 비프음을 낼 수 있습니다. 포트 0x61의 비트 0과 1을 조작하여 활성화합니다: outb(0x61, inb(0x61) | 0x03);
💡 TICKS 변수는 원자적 접근이 필요합니다. AtomicU64를 사용하거나 인터럽트를 비활성화한 상태에서 읽으세요: cli(); let t = TICKS; sti(); t
💡 HPET나 TSC로 마이그레이션할 때 동일한 추상화 인터페이스를 유지하세요. trait Timer { fn get_frequency(&self) -> u64; fn get_ticks(&self) -> u64; } 같은 트레이트를 정의하면 교체가 쉽습니다.
6. 포트 접근 권한과 IOPL
시작하며
여러분이 사용자 프로그램에서 I/O 포트에 접근하려고 했을 때 General Protection Fault가 발생한 경험이 있나요? 기본적으로 Ring 3(사용자 모드)에서는 I/O 포트 접근이 금지되어 있습니다.
x86 CPU는 EFLAGS 레지스터의 IOPL(I/O Privilege Level) 필드로 포트 접근 권한을 제어합니다. IOPL이 0이면 Ring 0에서만 포트 접근 가능하고, 3으로 설정하면 사용자 모드에서도 가능합니다.
하지만 모든 사용자 프로그램에 포트 접근을 허용하면 보안 문제가 발생합니다. 악의적인 프로그램이 디스크 컨트롤러를 직접 조작하여 데이터를 훔칠 수 있기 때문입니다.
바로 이럴 때 필요한 것이 I/O Permission Bitmap입니다. TSS(Task State Segment)에 포함된 비트맵으로 포트별 세밀한 접근 제어가 가능합니다.
개요
간단히 말해서, I/O 권한 관리는 IOPL과 I/O Permission Bitmap을 통해 어떤 특권 레벨에서 어떤 포트에 접근할 수 있는지 제어하는 메커니즘입니다. IOPL은 EFLAGS의 비트 12-13에 위치하며, 0-3의 값을 가집니다.
현재 CPL(Current Privilege Level)이 IOPL 이하일 때만 포트 접근이 허용됩니다. I/O Permission Bitmap은 TSS의 끝부분에 위치하며, 65536개 포트 각각에 대해 1비트씩 할당되어 총 8KB 크기입니다.
예를 들어, VGA 포트만 사용자에게 허용하고 싶을 때는 해당 포트들의 비트를 0으로 설정하는 식입니다. 기존에는 IOPL을 3으로 설정하여 모든 포트를 열거나, 0으로 하여 완전히 막았다면, 이제는 비트맵으로 선택적 허용이 가능합니다.
I/O 권한 관리의 핵심 특징은 첫째, IOPL은 커널만 변경 가능하고, 둘째, 비트맵은 포트별 세밀한 제어를 제공하며, 셋째, 태스크 전환 시 자동으로 비트맵이 바뀐다는 점입니다. 이러한 특징들이 안전한 포트 가상화를 가능하게 합니다.
코드 예제
// IOPL 읽기 및 설정
use core::arch::asm;
const IOPL_MASK: u64 = 0x3000; // EFLAGS 비트 12-13
// 현재 IOPL 읽기
pub fn get_iopl() -> u8 {
let flags: u64;
unsafe {
asm!("pushfq; pop {}", out(reg) flags, options(nomem, nostack));
}
((flags & IOPL_MASK) >> 12) as u8
}
// IOPL 설정 (Ring 0에서만 가능)
pub unsafe fn set_iopl(level: u8) {
let level = (level as u64 & 0x3) << 12;
let mut flags: u64;
asm!("pushfq; pop {}", out(reg) flags, options(nomem, nostack));
flags = (flags & !IOPL_MASK) | level;
asm!("push {}; popfq", in(reg) flags, options(nomem, nostack));
}
// I/O Permission Bitmap 관리 (TSS 구조체 일부)
#[repr(C, packed)]
pub struct IoPermissionBitmap {
bitmap: [u8; 8192], // 65536 포트 / 8 비트
}
impl IoPermissionBitmap {
pub fn new() -> Self {
IoPermissionBitmap { bitmap: [0xFF; 8192] } // 기본적으로 모두 차단
}
// 특정 포트 허용
pub fn allow_port(&mut self, port: u16) {
let index = (port / 8) as usize;
let bit = port % 8;
self.bitmap[index] &= !(1 << bit);
}
// 특정 포트 차단
pub fn deny_port(&mut self, port: u16) {
let index = (port / 8) as usize;
let bit = port % 8;
self.bitmap[index] |= 1 << bit;
}
}
설명
이것이 하는 일: CPU의 권한 체계와 TSS의 비트맵을 활용하여 안전하게 I/O 포트 접근을 관리하는 시스템을 제공합니다. 첫 번째로, get_iopl은 EFLAGS 레지스터를 스택에 푸시한 후 일반 레지스터로 읽어옵니다.
pushfq는 64비트 EFLAGS(RFLAGS)를 스택에 푸시하고, pop으로 변수에 저장합니다. 비트 12-13을 추출하여 0-3 범위의 IOPL 값을 얻습니다.
이렇게 하는 이유는 EFLAGS를 직접 읽을 수 있는 명령어가 없어 스택을 경유해야 하기 때문입니다. 그 다음으로, set_iopl은 반대 과정으로 IOPL을 변경합니다.
현재 EFLAGS를 읽어 IOPL 비트만 교체한 후 popfq로 다시 로드합니다. unsafe인 이유는 Ring 3에서 IOPL을 높이려 하면 무시되며, Ring 0에서만 실제 변경이 가능하기 때문입니다.
IOPL을 3으로 설정하면 사용자 프로그램이 모든 포트에 접근할 수 있어 위험합니다. 세 번째 단계로, IoPermissionBitmap은 8KB 배열로 각 포트의 허용 여부를 비트 단위로 저장합니다.
allow_port는 해당 비트를 0으로 설정하여 접근을 허용하고, deny_port는 1로 설정하여 차단합니다. 비트맵은 TSS의 iomap_base 필드가 가리키는 위치에 있어야 하며, CPU가 포트 접근 시 자동으로 확인합니다.
마지막으로, 포트 접근 시도 시 CPU는 먼저 CPL과 IOPL을 비교합니다. CPL > IOPL이면 비트맵을 확인하고, 해당 비트가 1이면 General Protection Fault(#GP)를 발생시킵니다.
최종적으로 VGA 드라이버 같은 사용자 공간 컴포넌트에 특정 포트만 허용하여 안전한 그래픽 출력을 구현할 수 있습니다. 여러분이 이 코드를 사용하면 DOS 에뮬레이터나 가상 머신 같은 환경에서 사용자 프로그램에 제한적인 하드웨어 접근을 허용할 수 있습니다.
커널은 IOPL을 0으로 유지하면서 비트맵으로 세밀하게 제어하므로 보안과 유연성을 동시에 확보합니다. 프로세스별로 다른 비트맵을 TSS에 로드하여 각기 다른 권한을 부여하는 것도 가능합니다.
실전 팁
💡 비트맵의 끝에는 반드시 0xFF 바이트를 추가하세요. CPU가 범위를 벗어난 포트 접근을 차단하도록 하는 센티널 값입니다.
💡 iomap_base 필드는 TSS 시작으로부터의 오프셋을 바이트 단위로 저장합니다. 일반적으로 size_of::<Tss>() 위치에 비트맵을 배치합니다.
💡 사용자 프로그램에 포트를 허용할 때는 반드시 검증하세요. 디스크 컨트롤러(0x1F0-0x1F7)나 PIC(0x20, 0xA0) 같은 중요 포트는 절대 허용하지 마세요.
💡 IOPL 변경은 컨텍스트 스위칭 오버헤드를 추가하지 않지만, 비트맵은 TSS 로드 시 복사되므로 성능 영향이 있습니다. 가능하면 프로세스 그룹별로 비트맵을 공유하세요.
💡 64비트 모드에서는 sti/cli도 IOPL의 영향을 받습니다. IOPL < 3인 상태에서 사용자가 인터럽트를 제어하려 하면 #GP가 발생합니다.
7. Memory-Mapped I/O와의 차이점
시작하며
여러분이 PCI 장치를 다루다가 "이건 포트가 아니라 메모리 주소네?"라는 의문을 가진 적 있나요? 모든 하드웨어가 I/O 포트를 사용하는 것은 아닙니다.
최신 하드웨어는 대부분 MMIO(Memory-Mapped I/O)를 선호합니다. VGA 프레임버퍼(0xA0000-0xBFFFF), APIC 레지스터(0xFEE00000), PCI 설정 공간 등이 대표적입니다.
MMIO는 일반 메모리 읽기/쓰기 명령으로 하드웨어를 제어할 수 있어 편리하지만, 캐싱 문제를 고려해야 합니다. 반면 포트 I/O는 전용 명령어가 필요하지만 캐시되지 않아 순서가 보장됩니다.
바로 이럴 때 필요한 것이 두 방식의 차이를 이해하고 적절히 선택하는 능력입니다. 레거시 장치는 포트 I/O, 최신 장치는 MMIO를 사용하는 경향이 있습니다.
개요
간단히 말해서, 포트 I/O와 MMIO는 하드웨어 레지스터 접근 방식의 두 가지 패러다임으로, 각각 장단점이 있습니다. 포트 I/O는 별도의 주소 공간(0x0000-0xFFFF)을 사용하며, in/out 명령어로만 접근 가능합니다.
명령어 자체가 직렬화되어 있어 순서가 보장되고, 캐시나 추측 실행의 영향을 받지 않습니다. MMIO는 일반 메모리 공간에 매핑되어 mov, movq 같은 일반 명령어로 접근하며, 컴파일러 최적화의 이점을 받지만 volatile 속성이 필수입니다.
예를 들어, 키보드 컨트롤러는 포트 0x60/0x64를 사용하지만, AHCI SATA 컨트롤러는 MMIO 영역을 사용합니다. 기존에는 x86이 포트 I/O 중심이었다면, 이제는 ARM이나 RISC-V 같은 아키텍처가 MMIO만 지원하는 추세입니다.
두 방식의 핵심 차이는 첫째, 주소 공간 분리 여부(포트는 독립, MMIO는 메모리와 통합), 둘째, 접근 명령어(전용 vs 일반), 셋째, 캐싱 및 순서 보장(포트는 자동, MMIO는 수동 제어 필요)입니다. 이러한 차이들이 드라이버 설계 방식을 결정합니다.
코드 예제
// 포트 I/O vs MMIO 비교 예제
use core::ptr;
// 포트 I/O: 시리얼 포트 데이터 읽기
pub fn read_serial_port() -> u8 {
unsafe { inb(0x3F8) }
}
// MMIO: VGA 텍스트 버퍼에 쓰기
const VGA_BUFFER: *mut u8 = 0xB8000 as *mut u8;
pub fn write_vga_char(offset: usize, ch: u8, color: u8) {
unsafe {
// 문자 쓰기
ptr::write_volatile(VGA_BUFFER.add(offset * 2), ch);
// 색상 속성 쓰기
ptr::write_volatile(VGA_BUFFER.add(offset * 2 + 1), color);
}
}
// MMIO 레지스터 추상화
#[repr(transparent)]
pub struct MmioRegister<T> {
addr: *mut T,
}
impl<T: Copy> MmioRegister<T> {
pub const fn new(addr: usize) -> Self {
MmioRegister { addr: addr as *mut T }
}
pub fn read(&self) -> T {
unsafe { ptr::read_volatile(self.addr) }
}
pub fn write(&mut self, value: T) {
unsafe { ptr::write_volatile(self.addr, value); }
}
}
// 사용 예: APIC 레지스터
const APIC_ID_REG: MmioRegister<u32> = MmioRegister::new(0xFEE00020);
설명
이것이 하는 일: 두 가지 하드웨어 접근 방식의 차이를 보여주고, 각각에 적합한 추상화를 제공합니다. 첫 번째로, 포트 I/O 예제는 시리얼 포트에서 1바이트를 읽습니다.
inb 명령어는 CPU가 포트 번호를 디코딩하여 해당 장치에 읽기 신호를 보내고, 데이터 버스를 통해 값을 받아옵니다. 이 과정은 메모리 버스와 완전히 분리되어 있으며, 캐시 일관성 문제가 없습니다.
이렇게 하는 이유는 하드웨어 레지스터는 읽을 때마다 값이 바뀔 수 있어 캐싱이 위험하기 때문입니다. 그 다음으로, MMIO 예제는 VGA 텍스트 버퍼에 문자를 씁니다.
ptr::write_volatile은 컴파일러에게 이 쓰기를 최적화하지 말고 항상 실행하라고 지시합니다. volatile 없이 일반 포인터 쓰기를 하면 컴파일러가 "어차피 다시 안 읽으니까 생략해도 되겠네" 하고 제거할 수 있습니다.
VGA 버퍼는 메모리 주소지만 실제로는 비디오 RAM에 매핑되어 있어 쓰면 즉시 화면에 반영됩니다. 세 번째 단계로, MmioRegister 구조체는 MMIO를 타입 안전하게 추상화합니다.
포인터를 캡슐화하고, read/write 메서드로 항상 volatile 접근을 강제합니다. #[repr(transparent)]는 래퍼가 런타임 오버헤드 없이 포인터와 동일한 메모리 레이아웃을 가지도록 합니다.
마지막으로, APIC 레지스터 예제는 MMIO의 실제 사용 사례를 보여줍니다. 0xFEE00020은 로컬 APIC ID 레지스터의 고정 주소이며, APIC_ID_REG.read()로 현재 CPU의 ID를 읽을 수 있습니다.
최종적으로 포트 I/O와 MMIO 모두 비슷한 추상화 수준으로 관리되어 드라이버 코드의 일관성이 유지됩니다. 여러분이 이 코드를 사용하면 레거시 ISA 장치는 포트 I/O로, PCI/PCIe 장치는 MMIO로 자연스럽게 처리할 수 있습니다.
페이지 테이블에서 MMIO 영역을 캐시 비활성화(PCD, PWT 비트)로 설정하면 포트 I/O와 유사한 순서 보장을 얻을 수 있습니다. 두 방식을 모두 지원하는 유연한 드라이버 프레임워크를 만들 수 있습니다.
실전 팁
💡 MMIO 영역은 페이지 테이블에서 반드시 캐시 비활성화로 매핑하세요. PAT(Page Attribute Table)나 MTRR(Memory Type Range Register)을 사용하여 Uncacheable로 설정합니다.
💡 DMA 버퍼는 MMIO와 다릅니다. DMA는 일반 메모리를 하드웨어가 읽는 것이고, MMIO는 하드웨어 레지스터가 메모리처럼 보이는 것입니다. 혼동하지 마세요.
💡 ARM 같은 플랫폼으로 포팅할 때는 포트 I/O 호출을 MMIO로 변환해야 합니다. 추상화 계층을 두어 trait HardwareAccess { fn read(&self) -> T; fn write(&mut self, T); }처럼 통일하세요.
💡 64비트 MMIO 레지스터는 원자적 접근이 보장되지 않을 수 있습니다. 두 번의 32비트 읽기로 나누거나, 하드웨어 스펙을 확인하세요.
💡 QEMU의 -trace enable=* 옵션으로 포트 I/O와 MMIO 접근을 추적할 수 있습니다. 디버깅 시 하드웨어 상호작용을 시각화하는 데 유용합니다.
8. 안전한 포트 추상화 라이브러리 설계
시작하며
여러분이 여러 드라이버를 작성하다 보면 "매번 똑같은 포트 관리 코드를 반복하네?"라는 생각을 한 적 있나요? 각 드라이버마다 Port 구조체를 복사하고, unsafe 블록을 중복해서 작성하는 것은 비효율적입니다.
체계적인 추상화 라이브러리가 없으면 포트 번호 충돌, 동시 접근 경쟁 조건, unsafe 코드 남용 같은 문제가 발생합니다. 예를 들어 두 드라이버가 같은 포트를 동시에 사용하면 하드웨어 상태가 꼬이고, 디버깅이 거의 불가능해집니다.
바로 이럴 때 필요한 것이 안전한 포트 추상화 라이브러리입니다. 타입 시스템으로 독점적 접근을 보장하고, 포트 할당을 중앙 관리하며, 최소한의 unsafe로 안전한 API를 제공할 수 있습니다.
개요
간단히 말해서, 포트 추상화 라이브러리는 I/O 포트를 안전하고 효율적으로 관리하기 위한 타입 안전, 소유권 기반 프레임워크입니다. 핵심은 포트 번호를 리소스로 취급하여 한 번에 하나의 소유자만 접근 가능하게 만드는 것입니다.
Rust의 소유권 시스템을 활용하면 컴파일 타임에 포트 충돌을 방지할 수 있습니다. 포트 레지스트리를 두어 할당된 포트를 추적하고, 중복 요청을 거부합니다.
예를 들어, PortManager::request_port(0x3F8)는 성공 시 OwnedPort<u8> 객체를 반환하고, 이미 할당되었으면 Err을 반환하는 식입니다. 기존에는 포트 접근이 무정부 상태였다면, 이제는 명확한 소유권과 생명주기 관리로 안전성을 확보할 수 있습니다.
라이브러리 설계의 핵심 특징은 첫째, 제로 코스트 추상화로 런타임 오버헤드가 없고, 둘째, 타입 시스템으로 컴파일 타임 검증이 가능하며, 셋째, 멀티코어 환경에서 동기화를 자동 처리한다는 점입니다. 이러한 특징들이 대규모 OS 프로젝트의 유지보수성을 크게 향상시킵니다.
코드 예제
// 안전한 포트 추상화 라이브러리
use spin::Mutex;
use alloc::collections::BTreeMap;
// 포트 관리자 (싱글톤)
pub struct PortManager {
allocated: Mutex<BTreeMap<u16, &'static str>>,
}
impl PortManager {
pub const fn new() -> Self {
PortManager { allocated: Mutex::new(BTreeMap::new()) }
}
// 포트 요청 (독점적 소유권)
pub fn request_port<T>(&self, port: u16, owner: &'static str)
-> Result<OwnedPort<T>, PortError>
{
let mut map = self.allocated.lock();
if map.contains_key(&port) {
return Err(PortError::AlreadyAllocated);
}
map.insert(port, owner);
Ok(OwnedPort::new(port))
}
// 포트 범위 요청
pub fn request_range<T>(&self, start: u16, count: u16, owner: &'static str)
-> Result<PortRange<T>, PortError>
{
let mut map = self.allocated.lock();
for offset in 0..count {
if map.contains_key(&(start + offset)) {
return Err(PortError::AlreadyAllocated);
}
}
for offset in 0..count {
map.insert(start + offset, owner);
}
Ok(PortRange::new(start, count))
}
}
// 소유권을 가진 포트 (Drop 시 자동 해제)
pub struct OwnedPort<T> {
port: Port<T>,
}
impl<T> OwnedPort<T> {
fn new(port: u16) -> Self {
OwnedPort { port: Port::new(port) }
}
}
impl OwnedPort<u8> {
pub fn read(&mut self) -> u8 {
unsafe { self.port.read() }
}
pub fn write(&mut self, value: u8) {
unsafe { self.port.write(value); }
}
}
// 포트 범위 관리
pub struct PortRange<T> {
base: u16,
count: u16,
phantom: PhantomData<T>,
}
impl<T> PortRange<T> {
fn new(base: u16, count: u16) -> Self {
PortRange { base, count, phantom: PhantomData }
}
pub fn port(&self, offset: u16) -> Option<Port<T>> {
if offset < self.count {
Some(Port::new(self.base + offset))
} else {
None
}
}
}
#[derive(Debug)]
pub enum PortError {
AlreadyAllocated,
InvalidRange,
}
static PORT_MANAGER: PortManager = PortManager::new();
설명
이것이 하는 일: Rust의 타입 시스템과 소유권 모델을 활용하여 I/O 포트를 리소스로 관리하고, 컴파일 타임 + 런타임 안전성을 제공합니다. 첫 번째로, PortManager는 할당된 포트를 BTreeMap으로 추적합니다.
키는 포트 번호, 값은 소유자 이름(디버깅용)입니다. Mutex로 감싸져 있어 멀티코어 환경에서도 안전하게 접근할 수 있습니다.
request_port를 호출하면 맵을 확인하여 이미 할당된 포트면 에러를 반환하고, 아니면 등록 후 OwnedPort를 반환합니다. 이렇게 하는 이유는 두 드라이버가 동시에 같은 포트를 사용하는 것을 원천 차단하기 위함입니다.
그 다음으로, OwnedPort는 포트의 독점적 소유권을 나타냅니다. 내부적으로 Port 구조체를 보유하지만, 외부에는 안전한 메서드만 노출합니다.
read와 write는 내부에서 unsafe를 캡슐화하여 호출자는 안전하게 사용할 수 있습니다. 소유권이 이동하면 포트 접근도 함께 이동하므로, 컴파일러가 동시 접근을 막아줍니다.
세 번째 단계로, PortRange는 여러 연속된 포트를 그룹으로 관리합니다. 시리얼 포트처럼 여러 레지스터를 가진 장치에 유용합니다.
port(offset) 메서드로 범위 내의 특정 포트를 얻을 수 있으며, 범위를 벗어나면 None을 반환하여 오류를 방지합니다. 마지막으로, Drop 트레이트를 구현하면(예제에는 생략) OwnedPort가 스코프를 벗어날 때 자동으로 포트를 해제할 수 있습니다.
최종적으로 RAII(Resource Acquisition Is Initialization) 패턴으로 포트 누수를 방지하고, 리소스 관리를 자동화할 수 있습니다. 여러분이 이 코드를 사용하면 드라이버 초기화 시 let serial = PORT_MANAGER.request_port(0x3F8, "serial")?처럼 포트를 요청하고, 드라이버가 제거되면 자동으로 해제됩니다.
포트 충돌은 컴파일 타임에 타입 시스템이, 런타임에 포트 매니저가 이중으로 방지합니다. 대규모 커널 프로젝트에서 수십 개의 드라이버가 안전하게 공존할 수 있는 기반이 됩니다.
실전 팁
💡 PortManager를 lazy_static이나 OnceCell로 초기화하여 첫 접근 시 자동 생성하세요. 부팅 순서 문제를 피할 수 있습니다.
💡 포트 범위에 이름을 붙이는 상수를 정의하세요: const SERIAL_COM1: (u16, u16) = (0x3F8, 8); 이렇게 하면 request_range(SERIAL_COM1.0, SERIAL_COM1.1, "serial")로 명확하게 사용 가능합니다.
💡 디버그 빌드에서 포트 접근 로그를 남기는 래퍼를 추가하세요. #[cfg(debug_assertions)] log::trace!("Port {:#x} read", self.port.port); 같은 식으로 추적이 쉬워집니다.
💡 OwnedPort에 Clone 대신 split() 메서드를 구현하여 읽기/쓰기를 분리하세요. let (reader, writer) = serial.split();로 서로 다른 태스크에 안전하게 분배할 수 있습니다.
💡 포트 할당 실패 시 대체 포트를 시도하는 헬퍼를 만드세요: fn request_first_available(ports: &[u16], owner: &str) -> Result<OwnedPort<u8>, PortError> 이렇게 하면 COM1이 실패해도 COM2를 자동 시도합니다.
9. DMA와 I/O 포트의 상호작용
시작하며
여러분이 디스크에서 대용량 데이터를 읽을 때 "CPU가 매번 바이트를 복사하면 너무 느린데?"라는 생각을 해본 적 있나요? Programmed I/O(PIO)는 CPU가 직접 데이터를 전송하므로 다른 작업을 할 수 없습니다.
DMA(Direct Memory Access)는 하드웨어가 CPU 개입 없이 메모리와 장치 간 데이터를 전송하는 기술입니다. 8237 DMA 컨트롤러는 ISA 버스 시대부터 사용되었으며, 플로피 디스크나 사운드 카드 같은 레거시 장치에 필수적입니다.
DMA를 설정하려면 여러 I/O 포트를 정확한 순서로 프로그래밍해야 하며, 잘못하면 메모리 손상이나 시스템 크래시가 발생할 수 있습니다. 바로 이럴 때 필요한 것이 DMA 컨트롤러 프로그래밍입니다.
I/O 포트를 통해 전송 주소, 길이, 모드를 설정하여 백그라운드 데이터 전송을 자동화할 수 있습니다.
개요
간단히 말해서, DMA 설정은 I/O 포트를 통해 하드웨어 전송 엔진을 프로그래밍하여 CPU 부하 없이 대용량 데이터 이동을 수행하는 과정입니다. 8237 DMA 컨트롤러는 4개의 채널을 제공하며, 각 채널은 주소 레지스터, 카운트 레지스터, 페이지 레지스터를 가집니다.
마스터 DMA는 0x00-0x0F 포트를, 슬레이브는 0xC0-0xDE를 사용합니다. 채널 0-3은 8비트 전송, 5-7은 16비트 전송을 담당합니다.
예를 들어, 플로피 디스크 읽기는 채널 2를 사용하며, 주소와 카운트를 설정 후 마스크를 해제하면 자동으로 데이터가 메모리로 전송됩니다. 기존에는 CPU가 루프를 돌며 한 바이트씩 복사했다면, 이제는 DMA 설정 후 인터럽트를 기다리기만 하면 됩니다.
DMA 프로그래밍의 핵심 특징은 첫째, 물리 주소를 20비트(페이지 + 오프셋)로 분할하여 설정하고, 둘째, 전송 모드(읽기, 쓰기, 자동 초기화)를 선택하며, 셋째, 채널 마스킹으로 전송을 시작/정지한다는 점입니다. 이러한 특징들이 효율적인 I/O 처리를 가능케 합니다.
코드 예제
// DMA 컨트롤러 프로그래밍
const DMA_CHANNEL2_ADDR: u16 = 0x04;
const DMA_CHANNEL2_COUNT: u16 = 0x05;
const DMA_CHANNEL2_PAGE: u16 = 0x81;
const DMA_MODE_REG: u16 = 0x0B;
const DMA_SINGLE_MASK: u16 = 0x0A;
const DMA_FLIP_FLOP: u16 = 0x0C;
// DMA 모드
const DMA_MODE_READ: u8 = 0x46; // 채널 2, 싱글 전송, 증가, 읽기
const DMA_MODE_WRITE: u8 = 0x4A; // 채널 2, 싱글 전송, 증가, 쓰기
pub unsafe fn setup_dma_transfer(buffer: *const u8, len: usize, read: bool) {
let phys_addr = virt_to_phys(buffer as usize);
let page = (phys_addr >> 16) as u8;
let offset = (phys_addr & 0xFFFF) as u16;
let count = (len - 1) as u16; // 카운트는 실제 길이 - 1
// 채널 2 마스크 (비활성화)
outb(DMA_SINGLE_MASK, 0x06);
// 플립플롭 리셋
outb(DMA_FLIP_FLOP, 0x00);
// 모드 설정
let mode = if read { DMA_MODE_READ } else { DMA_MODE_WRITE };
outb(DMA_MODE_REG, mode);
// 주소 설정 (LSB, MSB)
outb(DMA_CHANNEL2_ADDR, (offset & 0xFF) as u8);
outb(DMA_CHANNEL2_ADDR, ((offset >> 8) & 0xFF) as u8);
// 페이지 설정
outb(DMA_CHANNEL2_PAGE, page);
// 카운트 설정 (LSB, MSB)
outb(DMA_CHANNEL2_COUNT, (count & 0xFF) as u8);
outb(DMA_CHANNEL2_COUNT, ((count >> 8) & 0xFF) as u8);
// 채널 2 언마스크 (활성화)
outb(DMA_SINGLE_MASK, 0x02);
}
// 가상 주소를 물리 주소로 변환 (페이지 테이블 사용)
unsafe fn virt_to_phys(virt: usize) -> usize {
// 실제 구현은 페이지 테이블을 순회해야 함
virt // 단순화를 위해 동일 매핑 가정
}
설명
이것이 하는 일: DMA 컨트롤러의 여러 레지스터를 I/O 포트를 통해 구성하여 메모리와 장치 간 자동 데이터 전송을 설정합니다. 첫 번째로, 가상 주소를 물리 주소로 변환합니다.
DMA는 MMU를 거치지 않고 물리 메모리에 직접 접근하므로 반드시 물리 주소가 필요합니다. 20비트 주소를 페이지(상위 4비트)와 오프셋(하위 16비트)으로 분할하는 이유는 8237 DMA가 16비트 레지스터만 가지기 때문입니다.
이렇게 하는 이유는 레거시 설계의 한계로, 최대 16MB까지만 접근 가능합니다. 그 다음으로, 채널을 마스크하여 설정 중 의도치 않은 전송을 방지합니다.
플립플롭을 리셋하는 이유는 주소와 카운트 레지스터가 LSB와 MSB를 같은 포트로 받기 때문입니다. 첫 쓰기는 LSB, 두 번째 쓰기는 MSB로 해석되며, 플립플롭이 이 상태를 추적합니다.
리셋하지 않으면 LSB/MSB가 뒤바뀌어 엉뚱한 주소가 설정됩니다. 세 번째 단계로, 모드 레지스터에 전송 방향과 방식을 설정합니다.
0x46의 의미는 비트 0-1(채널 2), 비트 2-3(전송 모드: 싱글), 비트 4(증가 방향), 비트 5-6(읽기/쓰기)입니다. 읽기 모드는 메모리 <- 장치, 쓰기 모드는 메모리 -> 장치입니다.
마지막으로, 카운트는 실제 전송할 바이트 수보다 1 작은 값을 씁니다. 하드웨어가 0에서 멈추는 것이 아니라 -1에서 멈추기 때문입니다.
언마스크(0x02)로 채널을 활성화하면 장치가 DRQ(DMA Request) 신호를 보낼 때마다 자동으로 전송이 진행됩니다. 최종적으로 모든 바이트가 전송되면 TC(Terminal Count) 인터럽트가 발생하여 완료를 알립니다.
여러분이 이 코드를 사용하면 플로피 디스크에서 섹터를 읽거나, ISA 사운드 카드로 오디오 샘플을 전송할 수 있습니다. CPU는 DMA 설정 후 다른 작업을 계속하다가 인터럽트가 오면 결과를 처리하면 됩니다.
PIO 대비 CPU 사용률이 크게 줄어들며, 특히 대용량 전송에서 성능 차이가 확연합니다.
실전 팁
💡 DMA 버퍼는 64KB 경계를 넘지 않도록 배치하세요. 8237은 16비트 카운터라 경계를 넘으면 주소가 0으로 롤오버됩니다.
💡 캐시 일관성에 주의하세요. DMA 전송 전에 캐시를 플러시하고(wbinvd), 전송 후에는 캐시를 무효화하여 CPU가 최신 데이터를 보도록 해야 합니다.
💡 최신 시스템에서는 8237 대신 버스 마스터 DMA를 사용하세요. PCI 장치는 자체 DMA 엔진을 가지며 64비트 주소를 지원하여 4GB 이상 메모리도 접근 가능합니다.
💡 DMA 전송 완료를 폴링으로 확인하려면 상태 레지스터(0x08)를 읽어 TC 비트를 확인하세요. 인터럽트 없이도 완료를 감지할 수 있습니다.
💡 IOMMU가 활성화된 시스템에서는 DMA 주소를 IOMMU를 통해 변환해야 합니다. 물리 주소를 직접 쓰면 보안 위반으로 차단될 수 있습니다.
10. 포트 접근 최적화와 성능
시작하며
여러분이 고속 네트워크 카드나 NVMe 드라이버를 작성할 때 "포트 접근이 병목이네?"라는 생각을 해본 적 있나요? 나이브한 포트 I/O는 의외로 느립니다.
I/O 포트 명령어는 직렬화(serializing) 속성을 가져 파이프라인을 정지시키고, 메모리 접근보다 레이턴시가 높습니다. 현대 CPU는 비순차 실행(out-of-order execution)으로 수백 개의 명령어를 동시에 처리하지만, in/out을 만나면 모든 이전 명령이 완료될 때까지 대기합니다.
루프에서 연속으로 포트를 읽으면 파이프라인이 계속 멈춰 성능이 크게 저하됩니다. 바로 이럴 때 필요한 것이 포트 접근 최적화입니다.
배치 처리, 캐싱, MMIO 전환 같은 기법으로 throughput을 향상시킬 수 있습니다.
개요
간단히 말해서, 포트 접근 최적화는 하드웨어 통신의 오버헤드를 줄여 드라이버 성능을 극대화하는 기술입니다. 포트 I/O의 주요 병목은 첫째, 직렬화로 인한 파이프라인 정지, 둘째, 비교적 긴 레이턴시(수십 사이클), 셋째, 루프에서 반복 호출 시 누적 오버헤드입니다.
반면 MMIO는 일반 메모리 명령어라 파이프라인 친화적이며, 프리페칭과 버스트 전송을 활용할 수 있습니다. 예를 들어, 네트워크 패킷 디스크립터를 읽을 때는 MMIO로 여러 필드를 한 번에 읽는 것이 포트로 하나씩 읽는 것보다 훨씬 빠릅니다.
기존에는 매번 포트를 직접 읽었다면, 이제는 중간 버퍼를 두거나 하드웨어 상태를 캐싱하여 접근 횟수를 줄일 수 있습니다. 최적화의 핵심 특징은 첫째, 불필요한 포트 읽기를 제거하여 레이턴시를 숨기고, 둘째, MMIO로 가능한 한 전환하며, 셋째, 배치 처리로 오버헤드를 분산시킨다는 점입니다.
이러한 특징들이 고성능 드라이버 개발의 기초가 됩니다.
코드 예제
// 포트 접근 최적화 예제
use core::sync::atomic::{AtomicU8, Ordering};
// 나이브한 방법: 매번 포트 읽기
pub fn poll_status_naive(port: &mut Port<u8>) -> bool {
loop {
let status = unsafe { port.read() };
if status & 0x01 != 0 {
return true;
}
}
}
// 최적화 1: 지연을 두어 파이프라인 활용
pub fn poll_status_with_delay(port: &mut Port<u8>) -> bool {
loop {
let status = unsafe { port.read() };
if status & 0x01 != 0 {
return true;
}
// 짧은 스핀으로 다른 작업 가능
for _ in 0..100 {
core::hint::spin_loop();
}
}
}
// 최적화 2: 상태 캐싱
pub struct CachedPort {
port: Port<u8>,
cached_value: AtomicU8,
}
impl CachedPort {
pub fn read_cached(&self) -> u8 {
self.cached_value.load(Ordering::Relaxed)
}
pub fn refresh(&mut self) {
let value = unsafe { self.port.read() };
self.cached_value.store(value, Ordering::Relaxed);
}
// 주기적으로 호출하여 캐시 갱신
pub fn periodic_update(&mut self) {
self.refresh();
}
}
// 최적화 3: 배치 읽기 (연속된 포트)
pub unsafe fn read_port_batch(base: u16, buffer: &mut [u8]) {
for (i, byte) in buffer.iter_mut().enumerate() {
*byte = inb(base + i as u16);
}
// 컴파일러가 루프를 최적화하여 연속 읽기로 변환
}
// 최적화 4: MMIO 선호
#[inline(always)]
pub fn use_mmio_if_available(device: &Device) -> u32 {
if let Some(mmio_addr) = device.mmio_base {
// MMIO는 훨씬 빠름
unsafe { ptr::read_volatile(mmio_addr as *const u32) }
} else {
// 포트 I/O 폴백
unsafe {
let low = inb(device.port) as u32;
let high = inb(device.port + 1) as u32;
low | (high << 8)
}
}
}
설명
이것이 하는 일: 다양한 기법을 통해 I/O 포트 접근의 성능 영향을 줄이고, 드라이버 전체 throughput을 향상시킵니다. 첫 번째로, 나이브한 폴링은 타이트 루프에서 포트를 계속 읽습니다.
이는 CPU를 100% 사용하면서도 파이프라인 정지로 실제 성능은 낮습니다. 하드웨어가 응답할 때까지 수백만 번 읽기가 발생하여 버스 대역폭도 낭비합니다.
이렇게 하는 것은 좋지 않은 방법입니다. 그 다음으로, 지연을 추가한 버전은 각 읽기 사이에 짧은 스핀 루프를 넣습니다.
core::hint::spin_loop()는 pause 명령어를 생성하여 하이퍼스레딩 환경에서 다른 논리 코어에 자원을 양보합니다. 포트 접근 빈도가 줄어들어 버스 경합이 감소하고, 하드웨어가 응답할 시간을 주므로 전체적으로 더 효율적입니다.
세 번째 단계로, 상태 캐싱은 읽기 전용 레지스터의 값을 메모리에 저장하여 재사용합니다. 타이머 인터럽트 같은 주기적 이벤트에서 캐시를 갱신하면, 대부분의 읽기는 빠른 메모리 접근으로 처리됩니다.
AtomicU8을 사용하여 멀티코어 환경에서도 안전하게 공유할 수 있습니다. 네 번째 단계로, 배치 읽기는 연속된 포트를 한 번에 읽습니다.
컴파일러는 루프를 언롤링하거나 SIMD 명령어로 변환할 수 있으며, 하드웨어 프리페처도 패턴을 예측하여 레이턴시를 숨길 수 있습니다. 마지막으로, MMIO 선호 전략은 장치가 둘 다 지원하면 MMIO를 우선합니다.
최종적으로 MMIO는 캐시 라인 단위로 읽어 여러 레지스터를 한 번에 가져올 수 있고, 비직렬화 로드를 사용하면 다른 명령과 병렬 실행이 가능합니다. 여러분이 이 코드를 사용하면 고속 장치 드라이버에서 수십 퍼센트의 성능 향상을 얻을 수 있습니다.
네트워크 카드에서 패킷 처리 속도가 올라가고, 디스크 I/O 레이턴시가 줄어듭니다. 프로파일링 도구로 포트 접근 횟수를 측정하고, 핫패스를 최적화하는 것이 중요합니다.
현대 하드웨어는 대부분 MMIO를 지원하므로 장기적으로 MMIO 기반 드라이버로 전환하는 것이 바람직합니다.
실전 팁
💡 perf 같은 프로파일러로 포트 접근 오버헤드를 측정하세요. perf stat -e instructions,cycles로 IPC(Instructions Per Cycle)를 확인하면 파이프라인 정지를 감지할 수 있습니다.
💡 연속된 포트 읽기는 rep insb 같은 스트링 명령어를 사용하세요. 인라인 어셈블리로 asm!("rep insb", ...)를 구현하면 단일 명령어로 배치 전송이 가능합니다.
💡 폴링 대신 인터럽트를 사용하세요. 포트를 계속 읽는 것보다 하드웨어가 준비되면 인터럽트를 보내는 것이 CPU 효율이 훨씬 좋습니다.
💡 MMIO 영역을 WC(Write-Combining)로 매핑하면 연속 쓰기가 버스트로 합쳐져 대역폭이 향상됩니다. PAT를 사용하여 페이지 속성을 조정하세요.
💡 포트 읽기 결과를 레지스터에 유지하세요. 스택이나 메모리에 즉시 쓰면 추가 레이턴시가 발생합니다. let value = port.read(); process(value); 대신 process_inline(port.read())처럼 인라인화하세요.