이미지 로딩 중...
AI Generated
2025. 11. 14. · 6 Views
Rust로 만드는 나만의 OS PS/2 키보드 드라이버 구현
OS 개발의 핵심인 PS/2 키보드 드라이버를 Rust로 직접 구현해봅니다. 하드웨어 인터럽트 처리부터 스캔코드 변환, 키보드 입력 처리까지 실제 작동하는 드라이버를 단계별로 만들어봅니다. 시스템 프로그래밍의 깊이 있는 이해와 실전 노하우를 얻을 수 있습니다.
목차
- PS/2 키보드 컨트롤러 기초 - 하드웨어와 통신하는 첫 걸음
- 인터럽트 핸들러 등록 - 키 입력을 자동으로 감지하기
- 스캔코드 디코딩 - 바이트를 키로 변환하기
- 키보드 입력 버퍼 구현 - 입력 손실 방지하기
- 모디파이어 키 관리 - Shift, Ctrl, Alt 상태 추적
- 특수 키 처리 - Enter, Backspace, 화살표 키
- 키보드 LED 제어 - NumLock, CapsLock, ScrollLock 표시
- 키 반복(Key Repeat) 구현 - 자동 반복 입력
- 에러 처리 및 복구 - 하드웨어 장애 대응
- 멀티코어 동기화 - 안전한 동시 접근
1. PS/2 키보드 컨트롤러 기초 - 하드웨어와 통신하는 첫 걸음
시작하며
여러분이 운영체제를 만들면서 키보드 입력을 받아야 하는데, 아무리 키를 눌러도 아무 반응이 없는 상황을 겪어본 적 있나요? 키보드는 분명히 연결되어 있고, 하드웨어도 정상인데 말이죠.
이런 문제는 키보드 컨트롤러와의 통신 방법을 모르기 때문에 발생합니다. PS/2 키보드는 특정 I/O 포트를 통해 데이터를 주고받는데, 이 포트의 주소와 통신 프로토콜을 정확히 알아야 합니다.
바로 이럴 때 필요한 것이 PS/2 키보드 컨트롤러의 기본 구조와 통신 방법입니다. 0x60과 0x64 포트를 통해 키보드와 대화하는 방법을 배우면, 키 입력을 성공적으로 받을 수 있습니다.
개요
간단히 말해서, PS/2 키보드 컨트롤러는 키보드 하드웨어와 CPU 사이의 중개자 역할을 하는 칩입니다. 실무 관점에서 보면, 이 컨트롤러는 두 개의 주요 I/O 포트를 제공합니다.
0x60 포트는 데이터 포트로 실제 스캔코드를 읽고, 0x64 포트는 상태/명령 포트로 컨트롤러의 상태를 확인하고 명령을 보냅니다. 예를 들어, 키보드 초기화나 LED 제어 같은 작업을 할 때 매우 유용합니다.
기존에는 BIOS 인터럽트를 사용해서 키 입력을 받았다면, 이제는 직접 하드웨어 레벨에서 포트를 제어할 수 있습니다. 핵심 특징은 첫째, 상태 레지스터의 비트 0(출력 버퍼 상태)을 확인해야 데이터를 안전하게 읽을 수 있습니다.
둘째, 비동기 방식으로 작동하므로 폴링이나 인터럽트 방식으로 처리해야 합니다. 셋째, 키 누름과 떼기가 각각 다른 스캔코드를 생성합니다.
이러한 특징들이 안정적인 키보드 입력 처리의 기반이 됩니다.
코드 예제
use x86_64::instructions::port::Port;
// PS/2 키보드 컨트롤러 구조체
pub struct PS2Keyboard {
data_port: Port<u8>, // 0x60: 데이터 읽기/쓰기
status_port: Port<u8>, // 0x64: 상태 확인
}
impl PS2Keyboard {
pub const fn new() -> Self {
Self {
data_port: Port::new(0x60),
status_port: Port::new(0x64),
}
}
// 출력 버퍼에 데이터가 있는지 확인
unsafe fn data_available(&mut self) -> bool {
self.status_port.read() & 0x01 != 0
}
// 스캔코드 읽기
pub unsafe fn read_scancode(&mut self) -> Option<u8> {
if self.data_available() {
Some(self.data_port.read())
} else {
None
}
}
}
설명
이것이 하는 일: PS/2 키보드 컨트롤러와 안전하게 통신하기 위한 기본 구조를 만듭니다. 첫 번째로, 두 개의 I/O 포트를 구조체 필드로 정의합니다.
data_port(0x60)는 키보드에서 보낸 스캔코드를 읽거나 키보드로 명령을 보낼 때 사용합니다. status_port(0x64)는 읽기 시 상태 레지스터를, 쓰기 시 명령 레지스터로 동작합니다.
x86_64 크레이트의 Port 타입을 사용하면 타입 안전한 포트 I/O를 구현할 수 있습니다. 그 다음으로, data_available 메서드가 실행되면서 상태 레지스터의 비트 0을 검사합니다.
이 비트가 1이면 출력 버퍼에 읽을 데이터가 있다는 의미입니다. 비트 마스킹(& 0x01)을 통해 다른 상태 비트는 무시하고 오직 출력 버퍼 상태만 확인합니다.
이 검사를 하지 않고 무작정 읽으면 쓰레기 값을 읽거나 시스템이 불안정해질 수 있습니다. 마지막으로, read_scancode 메서드가 데이터 가용성을 먼저 확인한 후 안전하게 스캔코드를 읽어 Option으로 반환합니다.
데이터가 없으면 None을, 있으면 Some(scancode)를 반환하여 Rust의 타입 시스템을 활용한 안전한 API를 제공합니다. 여러분이 이 코드를 사용하면 하드웨어 상태를 확인하지 않아 발생하는 데이터 손실이나 잘못된 읽기를 방지할 수 있습니다.
또한 unsafe 블록을 명시적으로 표시하여 포트 I/O의 위험성을 코드 레벨에서 관리할 수 있고, Option 타입으로 데이터 유무를 명확히 표현하여 버그를 줄일 수 있습니다.
실전 팁
💡 상태 레지스터의 비트 1(입력 버퍼 상태)도 확인하세요. 키보드로 명령을 보낼 때는 이 비트가 0이 될 때까지 기다려야 합니다. 그렇지 않으면 명령이 무시됩니다.
💡 타임아웃 메커니즘을 구현하세요. 하드웨어 문제로 데이터가 영원히 오지 않을 수 있으므로, 일정 시간 대기 후 에러를 반환하는 것이 안전합니다.
💡 초기화 시 0xAA(Self Test) 명령을 0x64 포트로 보내 컨트롤러가 정상 작동하는지 확인하세요. 정상이면 0x55를 반환합니다.
💡 Port<u8> 대신 Mutex로 감싸서 멀티코어 환경에서 동시 접근을 방지하세요. 두 개의 코어가 동시에 포트에 접근하면 데이터가 꼬일 수 있습니다.
💡 QEMU나 실제 하드웨어에서 테스트하세요. 일부 에뮬레이터는 PS/2를 완벽히 구현하지 않아 예상과 다르게 동작할 수 있습니다.
2. 인터럽트 핸들러 등록 - 키 입력을 자동으로 감지하기
시작하며
여러분이 키보드 입력을 받기 위해 무한 루프를 돌며 계속 포트를 확인하는 폴링 방식을 사용하고 있나요? CPU 사용률이 불필요하게 높아지고, 다른 작업을 처리할 시간이 부족한 상황이 발생합니다.
이런 문제는 키보드 입력이 언제 발생할지 예측할 수 없기 때문에, CPU가 계속 확인해야 한다는 잘못된 접근에서 비롯됩니다. 폴링은 간단하지만 비효율적이며, 키 입력을 놓칠 수도 있습니다.
바로 이럴 때 필요한 것이 인터럽트 방식입니다. PS/2 키보드는 IRQ 1(인터럽트 벡터 33)을 통해 키가 눌릴 때마다 CPU에 신호를 보냅니다.
이 인터럽트를 처리하는 핸들러를 등록하면, CPU는 다른 일을 하다가 키 입력이 있을 때만 반응할 수 있습니다.
개요
간단히 말해서, 키보드 인터럽트 핸들러는 키가 눌리거나 떼어질 때 자동으로 호출되는 함수입니다. 실무 관점에서 보면, IDT(Interrupt Descriptor Table)에 핸들러 함수를 등록하고, PIC(Programmable Interrupt Controller)를 설정해야 합니다.
x86 아키텍처에서 PS/2 키보드는 IRQ 1을 사용하며, 이는 인터럽트 벡터 33번(0x21)에 매핑됩니다. 예를 들어, 사용자가 키를 누르는 순간 하드웨어가 인터럽트를 발생시키고, CPU는 현재 작업을 멈추고 핸들러를 실행한 후 다시 원래 작업으로 돌아갑니다.
기존에는 폴링으로 반복적으로 확인했다면, 이제는 이벤트 기반으로 필요할 때만 처리할 수 있습니다. 핵심 특징은 첫째, 인터럽트 핸들러는 최대한 빨리 실행되어야 합니다.
길게 실행되면 다른 인터럽트를 처리할 수 없습니다. 둘째, 핸들러 내에서는 힙 할당이나 복잡한 연산을 피해야 합니다.
셋째, PIC에 EOI(End Of Interrupt) 신호를 보내야 다음 인터럽트를 받을 수 있습니다. 이러한 특징들이 반응성 좋은 시스템을 만드는 핵심입니다.
코드 예제
use x86_64::structures::idt::{InterruptDescriptorTable, InterruptStackFrame};
use crate::print;
use lazy_static::lazy_static;
use spin::Mutex;
lazy_static! {
static ref KEYBOARD: Mutex<PS2Keyboard> = Mutex::new(PS2Keyboard::new());
}
// IDT에 키보드 인터럽트 핸들러 등록
pub fn init_idt(idt: &mut InterruptDescriptorTable) {
idt[33].set_handler_fn(keyboard_interrupt_handler);
}
// 키보드 인터럽트 핸들러
extern "x86-interrupt" fn keyboard_interrupt_handler(
_stack_frame: InterruptStackFrame
) {
// 스캔코드 읽기
let mut keyboard = KEYBOARD.lock();
if let Some(scancode) = unsafe { keyboard.read_scancode() } {
// 스캔코드 처리 (다음 카드에서 상세히)
print!("Scancode: {:02x} ", scancode);
}
// PIC에 EOI 신호 전송 (필수!)
unsafe {
PICS.lock().notify_end_of_interrupt(33);
}
}
설명
이것이 하는 일: 키보드 입력이 발생할 때 자동으로 실행될 인터럽트 핸들러를 시스템에 등록합니다. 첫 번째로, lazy_static을 사용해 전역 키보드 인스턴스를 생성합니다.
인터럽트 핸들러는 정적 함수여야 하므로, 키보드 컨트롤러에 접근하려면 전역 변수가 필요합니다. Mutex로 감싸서 여러 인터럽트가 동시에 접근하는 것을 방지합니다.
lazy_static은 첫 접근 시 초기화되므로, 부팅 시 오버헤드를 줄일 수 있습니다. 그 다음으로, init_idt 함수가 IDT의 33번 엔트리에 핸들러를 설정합니다.
x86 아키텍처에서 하드웨어 인터럽트는 인터럽트 벡터 32번부터 시작하고, IRQ 1은 33번에 매핑됩니다. set_handler_fn은 컴파일 타임에 함수 시그니처를 검증하므로, 잘못된 핸들러를 등록하는 실수를 방지합니다.
마지막으로, keyboard_interrupt_handler가 실제 인터럽트 발생 시 호출됩니다. "x86-interrupt" 호출 규약을 사용하여 인터럽트 컨텍스트에서 안전하게 실행됩니다.
핸들러는 스캔코드를 읽고, 처리한 후, 반드시 PIC에 EOI(End Of Interrupt) 신호를 보내야 합니다. EOI를 보내지 않으면 PIC가 다음 인터럽트를 전달하지 않아 키보드가 먹통이 됩니다.
여러분이 이 코드를 사용하면 CPU 낭비 없이 효율적으로 키 입력을 처리할 수 있습니다. 폴링 대비 CPU 사용률을 극적으로 줄일 수 있고, 키 입력 응답 속도가 빨라지며, 다른 작업과 병행 처리가 가능해집니다.
실전 팁
💡 인터럽트 핸들러에서 panic!을 사용하지 마세요. 인터럽트 컨텍스트에서 패닉하면 시스템 전체가 멈출 수 있습니다. 대신 에러를 로그하고 무시하세요.
💡 핸들러 내에서 긴 작업을 하지 마세요. 스캔코드를 큐에 넣고 나중에 처리하는 이단계 접근(top-half/bottom-half)을 사용하세요.
💡 PIC 초기화를 잊지 마세요. PICS.lock().initialize()를 호출해야 하드웨어 인터럽트가 활성화됩니다. 초기화 없이는 인터럽트가 발생하지 않습니다.
💡 디버깅 시 인터럽트를 일시적으로 비활성화하려면 x86_64::instructions::interrupts::without_interrupts를 사용하세요. 프린트 도중 재진입을 방지할 수 있습니다.
💡 벡터 번호를 하드코딩하지 말고 상수로 정의하세요. const KEYBOARD_INTERRUPT: u8 = 33;처럼 명명하면 코드 가독성이 높아집니다.
3. 스캔코드 디코딩 - 바이트를 키로 변환하기
시작하며
여러분이 키보드에서 'A'를 눌렀을 때, 0x1E라는 바이트를 받았습니다. 그런데 이 숫자를 어떻게 'A'로 바꿔야 할까요?
더 복잡한 것은 같은 키를 떼면 0x9E를 받는다는 점입니다. 이런 문제는 PS/2 키보드가 ASCII나 유니코드를 직접 보내지 않고, 스캔코드라는 특수한 형식을 사용하기 때문에 발생합니다.
스캔코드 세트 1(가장 흔함)에서는 키 누름과 떼기가 다른 코드를 가지며, 일부 키는 여러 바이트로 표현됩니다. 바로 이럴 때 필요한 것이 스캔코드 디코더입니다.
스캔코드를 키 이벤트로 변환하고, Shift나 Ctrl 같은 모디파이어를 추적하여 최종적으로 문자를 생성합니다.
개요
간단히 말해서, 스캔코드 디코딩은 원시 바이트를 의미 있는 키 이벤트와 문자로 변환하는 과정입니다. 실무 관점에서 보면, 스캔코드 세트 1을 기준으로 0x00-0x58 범위는 키 누름을, 0x80을 더한 값은 키 떼기를 나타냅니다.
0xE0으로 시작하는 확장 스캔코드도 처리해야 하는데, 화살표 키나 Page Up/Down 같은 키들이 여기에 해당합니다. 예를 들어, 오른쪽 화살표는 0xE0, 0x4D 두 바이트로 전송됩니다.
기존에는 BIOS가 이 변환을 대신 해줬다면, 이제는 직접 룩업 테이블과 상태 머신을 구현해야 합니다. 핵심 특징은 첫째, 상태를 유지해야 합니다.
이전 바이트가 0xE0이었는지, Shift가 눌려 있는지 등을 추적해야 합니다. 둘째, 키 누름과 떼기를 구분해야 합니다.
최상위 비트(0x80)로 구분할 수 있습니다. 셋째, 룩업 테이블로 스캔코드를 문자로 매핑합니다.
이러한 특징들이 정확한 키보드 입력 처리의 기반입니다.
코드 예제
use pc_keyboard::{layouts, DecodedKey, HandleControl, Keyboard, ScancodeSet1};
use spin::Mutex;
lazy_static! {
static ref KEYBOARD_DECODER: Mutex<Keyboard<layouts::Us104Key, ScancodeSet1>> =
Mutex::new(Keyboard::new(
layouts::Us104Key,
ScancodeSet1,
HandleControl::Ignore
));
}
// 스캔코드를 디코딩하여 키 이벤트 생성
pub fn process_scancode(scancode: u8) {
let mut keyboard = KEYBOARD_DECODER.lock();
// 스캔코드를 키 이벤트로 변환
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) => {
// 특수 키 처리 (Enter, Backspace 등)
match key {
layouts::KeyCode::Enter => print!("\n"),
layouts::KeyCode::Backspace => print!("\x08"),
_ => {} // 다른 특수 키 무시
}
}
}
}
}
}
설명
이것이 하는 일: 원시 스캔코드 바이트를 받아서 실제 키 이벤트와 문자로 변환합니다. 첫 번째로, pc_keyboard 크레이트의 Keyboard 구조체를 사용합니다.
이것은 상태 머신을 내부적으로 관리하며, 키보드 레이아웃(Us104Key), 스캔코드 세트(ScancodeSet1), 제어 문자 처리 방식(HandleControl::Ignore)을 설정합니다. 직접 스캔코드 테이블을 만드는 것보다 검증된 라이브러리를 사용하는 것이 안전하고 유지보수가 쉽습니다.
그 다음으로, add_byte 메서드가 스캔코드를 받아 내부 상태를 업데이트합니다. 확장 스캔코드(0xE0)를 만나면 다음 바이트를 기다리고, 일반 스캔코드면 즉시 키 이벤트를 생성합니다.
키 누름인지 떼기인지도 자동으로 판단합니다. Result<Option<KeyEvent>>를 반환하여 에러나 불완전한 입력을 안전하게 처리합니다.
마지막으로, process_keyevent가 키 이벤트를 DecodedKey로 변환합니다. DecodedKey는 두 가지 변형을 가집니다: Unicode는 'a', '1', ' ' 같은 일반 문자를, RawKey는 Enter, Backspace, F1 같은 특수 키를 나타냅니다.
모디파이어(Shift, Ctrl)는 자동으로 처리되어 Shift+A는 'A'로 디코딩됩니다. 여러분이 이 코드를 사용하면 스캔코드의 복잡한 세부사항을 신경 쓰지 않고 문자 입력을 받을 수 있습니다.
키보드 레이아웃 변경도 쉽고, 확장 스캔코드나 모디파이어 조합도 자동으로 처리되며, 안정성이 검증된 라이브러리를 활용하여 버그를 줄일 수 있습니다.
실전 팁
💡 다른 키보드 레이아웃을 지원하려면 layouts::Us104Key 대신 layouts::Uk105Key나 다른 레이아웃을 사용하세요. 런타임에 변경하려면 Keyboard 인스턴스를 재생성해야 합니다.
💡 HandleControl::MapLettersToUnicode를 사용하면 Ctrl+C 같은 제어 문자를 유니코드로 받을 수 있습니다. 쉘 구현 시 유용합니다.
💡 스캔코드 세트 2를 사용하는 키보드도 있습니다. 0xF0 0x1C 같은 패턴이 보이면 세트 2일 가능성이 높으니, ScancodeSet2로 변경하세요.
💡 키 반복(key repeat)을 구현하려면 키 누름 시각을 추적하고 타이머로 일정 시간 후 문자를 재전송하세요. 보통 500ms 대기 후 33ms 간격으로 반복합니다.
💡 디버깅 시 모든 스캔코드를 로그로 출력하세요. 특이한 키보드나 에뮬레이터는 예상과 다른 코드를 보낼 수 있습니다.
4. 키보드 입력 버퍼 구현 - 입력 손실 방지하기
시작하며
여러분이 매우 빠르게 타이핑할 때, 일부 문자가 사라지는 경험을 한 적 있나요? 특히 인터럽트 핸들러에서 바로 처리하려고 할 때 이런 문제가 자주 발생합니다.
이런 문제는 인터럽트 핸들러가 실행되는 동안 새로운 키 입력이 오면, 이전 스캔코드를 처리하느라 새 입력을 놓치기 때문에 발생합니다. PS/2 컨트롤러의 내부 버퍼는 매우 작아서(1-2바이트) 빠른 타이핑을 감당할 수 없습니다.
바로 이럴 때 필요한 것이 소프트웨어 링 버퍼입니다. 인터럽트 핸들러는 스캔코드를 빠르게 버퍼에 넣기만 하고, 메인 루프에서 여유 있게 처리하는 이단계 접근법을 사용합니다.
개요
간단히 말해서, 키보드 입력 버퍼는 스캔코드를 임시 저장하는 고정 크기 큐입니다. 실무 관점에서 보면, 링 버퍼(circular buffer)를 사용하여 읽기와 쓰기 인덱스를 관리합니다.
인터럽트 핸들러는 쓰기 인덱스를 증가시키며 데이터를 추가하고, 메인 코드는 읽기 인덱스를 증가시키며 데이터를 꺼냅니다. 예를 들어, 사용자가 초당 10타를 치면 약 20개의 스캔코드(누름+떼기)가 발생하는데, 버퍼가 없으면 이를 실시간으로 처리해야 합니다.
기존에는 인터럽트 핸들러에서 직접 처리했다면, 이제는 생산자-소비자 패턴으로 역할을 분리할 수 있습니다. 핵심 특징은 첫째, lock-free 구현이 이상적입니다.
인터럽트 핸들러에서 락을 잡으면 데드락 위험이 있습니다. 둘째, 버퍼 크기는 충분히 커야 합니다.
32-128 바이트면 대부분의 경우 충분합니다. 셋째, 오버플로 처리 전략이 필요합니다.
버퍼가 가득 차면 오래된 데이터를 버리거나 새 데이터를 거부할 수 있습니다. 이러한 특징들이 입력 손실 없는 안정적인 시스템을 만듭니다.
코드 예제
use crossbeam_queue::ArrayQueue;
use lazy_static::lazy_static;
use conquer_once::spin::OnceCell;
// 전역 스캔코드 큐 (lock-free)
static SCANCODE_QUEUE: OnceCell<ArrayQueue<u8>> = OnceCell::uninit();
// 큐 초기화
pub fn init_keyboard_buffer() {
SCANCODE_QUEUE.init_once(|| ArrayQueue::new(128));
}
// 인터럽트 핸들러에서 호출 (생산자)
pub fn add_scancode(scancode: u8) {
if let Ok(queue) = SCANCODE_QUEUE.try_get() {
if let Err(_) = queue.push(scancode) {
// 버퍼 오버플로 - 오래된 데이터는 유지하고 새 데이터 무시
println!("Warning: Scancode buffer full!");
}
}
}
// 메인 루프에서 호출 (소비자)
pub fn process_keyboard_input() {
if let Ok(queue) = SCANCODE_QUEUE.try_get() {
while let Some(scancode) = queue.pop() {
// 스캔코드 디코딩 및 처리
process_scancode(scancode);
}
}
}
설명
이것이 하는 일: lock-free 큐를 사용하여 인터럽트 핸들러와 메인 코드 사이에서 안전하게 스캔코드를 전달합니다. 첫 번째로, crossbeam-queue의 ArrayQueue를 사용합니다.
이것은 고정 크기의 lock-free 큐로, 인터럽트 컨텍스트에서 안전하게 사용할 수 있습니다. OnceCell로 감싸서 런타임에 한 번만 초기화하도록 보장합니다.
128 바이트 크기는 대부분의 타이핑 속도를 감당할 수 있으며, 필요하면 더 크게 설정할 수 있습니다. 그 다음으로, add_scancode 함수가 인터럽트 핸들러에서 호출됩니다.
try_get으로 큐에 안전하게 접근하고, push로 스캔코드를 추가합니다. push가 Err를 반환하면 큐가 가득 찬 것이므로, 경고를 출력하고 새 데이터를 무시합니다.
이 함수는 매우 빠르게 실행되어(수 마이크로초) 인터럽트 지연을 최소화합니다. 마지막으로, process_keyboard_input이 메인 루프에서 주기적으로 호출됩니다.
pop으로 큐에서 스캔코드를 하나씩 꺼내며, 큐가 빌 때까지 반복합니다. 이 함수에서는 시간이 오래 걸리는 디코딩, 화면 출력, 애플리케이션 로직 처리 등을 수행할 수 있습니다.
인터럽트 컨텍스트가 아니므로 힙 할당이나 복잡한 연산도 안전합니다. 여러분이 이 코드를 사용하면 빠른 타이핑이나 키 반복 시에도 입력을 하나도 놓치지 않습니다.
인터럽트 핸들러가 빠르게 반환되어 다른 인터럽트도 신속히 처리할 수 있고, lock-free 구조로 데드락 위험이 없으며, 생산자-소비자 분리로 코드 구조가 깔끔해집니다.
실전 팁
💡 버퍼 크기는 사용 사례에 맞게 조정하세요. 게임처럼 실시간 입력이 중요하면 작게(32), 텍스트 편집기처럼 모든 입력을 보존해야 하면 크게(256) 설정하세요.
💡 오버플로 정책을 선택하세요. 새 데이터를 버리는(drop-newest) 대신 오래된 데이터를 버리는(drop-oldest) 정책도 고려해보세요. SegQueue를 사용하면 가능합니다.
💡 통계를 수집하세요. 오버플로 발생 횟수, 최대 큐 사용량 등을 추적하면 버퍼 크기를 최적화할 수 있습니다.
💡 배치 처리를 고려하세요. process_keyboard_input에서 한 번에 여러 개(예: 16개)만 처리하고 나머지는 다음 호출에 맡기면, 다른 작업에 CPU 시간을 더 공평하게 분배할 수 있습니다.
💡 테스트 시 의도적으로 느린 처리를 시뮬레이션하여 버퍼 동작을 검증하세요. process_scancode에 지연을 추가하고 빠르게 타이핑해보세요.
5. 모디파이어 키 관리 - Shift, Ctrl, Alt 상태 추적
시작하며
여러분이 Shift를 누른 채로 'a'를 입력했을 때 'A'를 받고 싶은데, 두 개의 독립적인 키 이벤트('Shift 누름', 'a 누름')만 받는 상황에 직면한 적 있나요? 이 두 이벤트를 어떻게 조합해야 할까요?
이런 문제는 모디파이어 키가 일반 키와 달리 상태를 유지해야 하기 때문에 발생합니다. Shift를 누르는 순간부터 떼는 순간까지 모든 키 입력에 영향을 미칩니다.
또한 왼쪽 Shift와 오른쪽 Shift를 별도로 추적해야 할 수도 있습니다. 바로 이럴 때 필요한 것이 모디파이어 상태 관리자입니다.
각 모디파이어 키의 눌림 상태를 추적하고, 문자 변환 시 이 정보를 활용합니다.
개요
간단히 말해서, 모디파이어 상태 관리는 Shift, Ctrl, Alt 같은 특수 키의 눌림/떼기 상태를 추적하는 메커니즘입니다. 실무 관점에서 보면, 비트 플래그나 구조체 필드로 각 모디파이어의 상태를 저장합니다.
키 누름 이벤트에서 해당 플래그를 설정하고, 키 떼기 이벤트에서 해제합니다. 예를 들어, Shift+Ctrl+C를 눌렀을 때, Shift와 Ctrl 플래그가 모두 켜진 상태에서 'c' 키 이벤트를 처리하여 복사 명령으로 해석할 수 있습니다.
기존에는 pc_keyboard 크레이트가 자동으로 처리해줬다면, 이제는 커스텀 기능(단축키, 매크로 등)을 위해 직접 관리할 수 있습니다. 핵심 특징은 첫째, 왼쪽/오른쪽 모디파이어를 구분할 수 있어야 합니다.
일부 애플리케이션은 이를 다르게 처리합니다. 둘째, CapsLock이나 NumLock 같은 토글 키도 고려해야 합니다.
이들은 누를 때마다 상태가 반전됩니다. 셋째, 모디파이어 조합을 비트마스크로 표현하면 빠르게 비교할 수 있습니다.
이러한 특징들이 풍부한 키보드 인터페이스를 만드는 기반입니다.
코드 예제
use bitflags::bitflags;
bitflags! {
// 모디파이어 상태 비트 플래그
pub struct Modifiers: u8 {
const LEFT_SHIFT = 0b00000001;
const RIGHT_SHIFT = 0b00000010;
const LEFT_CTRL = 0b00000100;
const RIGHT_CTRL = 0b00001000;
const LEFT_ALT = 0b00010000;
const RIGHT_ALT = 0b00100000;
const CAPS_LOCK = 0b01000000;
const NUM_LOCK = 0b10000000;
}
}
pub struct KeyboardState {
modifiers: Modifiers,
}
impl KeyboardState {
pub fn new() -> Self {
Self {
modifiers: Modifiers::empty(),
}
}
// 키 이벤트로 모디파이어 상태 업데이트
pub fn update_modifiers(&mut self, key: KeyCode, pressed: bool) {
let modifier = match key {
KeyCode::LShift => Modifiers::LEFT_SHIFT,
KeyCode::RShift => Modifiers::RIGHT_SHIFT,
KeyCode::LCtrl => Modifiers::LEFT_CTRL,
KeyCode::RCtrl => Modifiers::RIGHT_CTRL,
KeyCode::LAlt => Modifiers::LEFT_ALT,
KeyCode::RAlt => Modifiers::RIGHT_ALT,
KeyCode::CapsLock if pressed => {
// CapsLock은 토글
self.modifiers.toggle(Modifiers::CAPS_LOCK);
return;
},
_ => return,
};
self.modifiers.set(modifier, pressed);
}
// Shift 눌림 여부 확인
pub fn shift_pressed(&self) -> bool {
self.modifiers.intersects(Modifiers::LEFT_SHIFT | Modifiers::RIGHT_SHIFT)
}
// Ctrl+C 같은 단축키 확인
pub fn is_shortcut(&self, key: char, ctrl: bool, shift: bool) -> bool {
// 구현 생략
false
}
}
설명
이것이 하는 일: 모든 모디파이어 키의 상태를 효율적으로 추적하고, 키 조합을 감지합니다. 첫 번째로, bitflags 크레이트로 모디파이어를 비트 플래그로 정의합니다.
8개의 모디파이어를 u8 하나로 표현할 수 있어 메모리 효율적입니다. 각 비트는 특정 모디파이어의 상태(0=떼짐, 1=눌림)를 나타냅니다.
왼쪽/오른쪽을 구분하여 더 세밀한 제어가 가능합니다. 그 다음으로, update_modifiers 메서드가 키 이벤트를 받아 상태를 업데이트합니다.
일반 모디파이어(Shift, Ctrl, Alt)는 pressed 값에 따라 플래그를 설정하거나 해제합니다. CapsLock 같은 토글 키는 누를 때마다 toggle()로 상태를 반전시킵니다.
match 표현식으로 키코드를 플래그에 매핑하며, 모디파이어가 아닌 키는 조기 반환합니다. 마지막으로, shift_pressed 같은 헬퍼 메서드가 현재 상태를 쉽게 확인할 수 있게 합니다.
intersects는 왼쪽이나 오른쪽 Shift 중 하나라도 눌려 있으면 true를 반환합니다. 이를 활용하여 'a' 키를 'A'나 'a'로 변환할지 결정하거나, Ctrl+C를 감지하여 복사 기능을 실행할 수 있습니다.
여러분이 이 코드를 사용하면 복잡한 키 조합을 정확하게 처리할 수 있습니다. 단축키 시스템을 구현할 수 있고, 대소문자 변환이 정확해지며, 비트 연산으로 빠른 상태 확인이 가능합니다.
실전 팁
💡 AltGr(오른쪽 Alt)는 유럽 키보드에서 특수 문자 입력에 사용됩니다. Ctrl+Alt 조합으로 에뮬레이션되기도 하므로 주의하세요.
💡 Windows 키나 Command 키도 모디파이어로 추가할 수 있습니다. 비트가 부족하면 u16으로 확장하세요.
💡 ScrollLock도 토글 키이지만 현대 시스템에서는 거의 사용되지 않습니다. 필요하면 추가하세요.
💡 모디파이어 상태를 파일이나 네트워크로 전송할 때는 bits() 메서드로 u8을 추출하고, from_bits_truncate()로 복원하세요.
💡 디버깅 시 모디파이어 상태를 화면 상단에 표시하면 유용합니다. "SHIFT CTRL"처럼 눌린 키만 표시하세요.
6. 특수 키 처리 - Enter, Backspace, 화살표 키
시작하며
여러분이 텍스트 편집기나 쉘을 만들 때, 일반 문자는 잘 처리되는데 Enter를 눌렀을 때 줄바꿈이 안 되거나, Backspace를 눌렀을 때 문자가 지워지지 않는 경험을 한 적 있나요? 이런 문제는 특수 키가 유니코드 문자로 변환되지 않고 KeyCode로만 제공되기 때문에 발생합니다.
또한 각 특수 키는 고유한 동작을 해야 합니다. Enter는 줄바꿈, Backspace는 삭제, 화살표는 커서 이동 등입니다.
바로 이럴 때 필요한 것이 특수 키 전용 처리 로직입니다. DecodedKey::RawKey를 받았을 때 각 키코드에 맞는 동작을 구현해야 합니다.
개요
간단히 말해서, 특수 키 처리는 문자가 아닌 키(Enter, Backspace, 화살표 등)에 대한 커스텀 동작을 정의하는 것입니다. 실무 관점에서 보면, 특수 키는 애플리케이션 컨텍스트에 따라 다르게 동작합니다.
텍스트 편집기에서 화살표 키는 커서를 이동하지만, 게임에서는 플레이어를 움직입니다. 예를 들어, 쉘 구현 시 Up 화살표는 명령 히스토리를 탐색하고, Tab은 자동완성을 실행하며, Ctrl+C는 현재 프로세스를 종료합니다.
기존에는 단순히 문자만 받았다면, 이제는 풍부한 인터랙티브 인터페이스를 만들 수 있습니다. 핵심 특징은 첫째, 특수 키는 애플리케이션 상태를 변경합니다.
문자 입력과 달리 부가 작용(side effect)이 있습니다. 둘째, 일부 특수 키는 제어 시퀀스로 변환됩니다.
VT100 터미널 에뮬레이션 시 화살표 키는 ESC[A 같은 시퀀스로 표현됩니다. 셋째, 모디파이어와 조합될 수 있습니다.
Shift+Tab, Ctrl+Left 같은 조합도 처리해야 합니다. 이러한 특징들이 사용자 친화적인 인터페이스를 만듭니다.
코드 예제
use pc_keyboard::{DecodedKey, KeyCode};
pub fn handle_key_input(key: DecodedKey, terminal: &mut Terminal) {
match key {
DecodedKey::Unicode(character) => {
// 일반 문자 입력
terminal.write_char(character);
},
DecodedKey::RawKey(keycode) => {
// 특수 키 처리
match keycode {
KeyCode::Enter => {
// 줄바꿈 및 명령 실행
terminal.write_char('\n');
terminal.execute_command();
},
KeyCode::Backspace => {
// 마지막 문자 삭제
terminal.delete_char();
},
KeyCode::ArrowLeft => {
// 커서 왼쪽 이동
terminal.move_cursor(-1, 0);
},
KeyCode::ArrowRight => {
terminal.move_cursor(1, 0);
},
KeyCode::ArrowUp => {
// 명령 히스토리에서 이전 명령
terminal.history_prev();
},
KeyCode::ArrowDown => {
terminal.history_next();
},
KeyCode::Home => {
// 줄의 시작으로 이동
terminal.move_cursor_to_line_start();
},
KeyCode::End => {
terminal.move_cursor_to_line_end();
},
KeyCode::PageUp | KeyCode::PageDown => {
// 스크롤 처리
terminal.scroll(if keycode == KeyCode::PageUp { -10 } else { 10 });
},
_ => {
// 처리하지 않는 키는 무시
}
}
}
}
}
설명
이것이 하는 일: 특수 키 입력을 감지하고 애플리케이션 로직에 맞는 동작을 실행합니다. 첫 번째로, DecodedKey를 매칭하여 일반 문자와 특수 키를 구분합니다.
Unicode 변형은 그대로 터미널에 출력하면 되지만, RawKey 변형은 추가 처리가 필요합니다. 이 분기가 입력 처리의 핵심입니다.
그 다음으로, 중첩된 match로 각 KeyCode를 개별 동작에 매핑합니다. Enter는 줄바꿈 문자를 출력하고 execute_command()로 입력된 명령을 실행합니다.
Backspace는 delete_char()로 버퍼에서 마지막 문자를 제거하고 화면에서도 지웁니다. 화살표 키는 move_cursor()로 커서 위치를 변경하며, 텍스트 편집 시 필수적입니다.
마지막으로, 고급 기능도 구현합니다. Home/End는 줄의 양 끝으로 빠르게 이동하고, PageUp/Down은 여러 줄을 한 번에 스크롤하며, Up/Down 화살표는 쉘 히스토리를 탐색합니다.
처리하지 않는 키(F1-F12 등)는 와일드카드 패턴으로 무시하여, 예상치 못한 키가 들어와도 프로그램이 멈추지 않습니다. 여러분이 이 코드를 사용하면 전문적인 수준의 터미널 인터페이스를 만들 수 있습니다.
사용자는 익숙한 키보드 단축키를 사용할 수 있고, 명령 히스토리로 반복 작업이 편해지며, 커서 이동으로 정확한 텍스트 편집이 가능해집니다.
실전 팁
💡 Ctrl+키 조합도 처리하세요. HandleControl::MapLettersToUnicode로 Ctrl+C는 '\x03'(ETX), Ctrl+D는 '\x04'(EOT)로 받을 수 있습니다.
💡 Delete 키와 Backspace를 구분하세요. Delete는 커서 앞의 문자를, Backspace는 커서 뒤의 문자를 삭제합니다.
💡 Insert 키로 삽입/덮어쓰기 모드를 토글할 수 있습니다. 플래그를 유지하고 문자 입력 시 다르게 처리하세요.
💡 Escape 키는 모드 종료(Vim 스타일)나 취소 동작에 유용합니다. 긴 입력을 빠르게 버릴 수 있습니다.
💡 F1-F12 키는 애플리케이션별 기능에 할당하세요. F1은 도움말, F5는 새로고침 같은 관례를 따르면 사용자가 쉽게 배울 수 있습니다.
7. 키보드 LED 제어 - NumLock, CapsLock, ScrollLock 표시
시작하며
여러분이 CapsLock을 눌렀을 때, 내부 상태는 변경되는데 키보드의 LED가 켜지지 않아 혼란스러웠던 경험이 있나요? 사용자는 LED를 보고 현재 상태를 확인하는데 말이죠.
이런 문제는 PS/2 키보드의 LED가 자동으로 켜지지 않고, 운영체제가 명령을 보내야만 제어되기 때문에 발생합니다. 키 누름을 감지하는 것과 LED를 제어하는 것은 별개의 작업입니다.
바로 이럴 때 필요한 것이 LED 제어 명령입니다. 0xED 명령과 LED 상태 바이트를 키보드로 전송하여 LED를 켜거나 끌 수 있습니다.
개요
간단히 말해서, 키보드 LED 제어는 0x60 데이터 포트를 통해 명령을 보내 NumLock, CapsLock, ScrollLock LED의 상태를 변경하는 것입니다. 실무 관점에서 보면, LED 제어는 두 단계로 이루어집니다.
먼저 0xED(Set LEDs) 명령을 보내고, 키보드가 ACK(0xFA)를 응답하면 LED 상태 바이트를 전송합니다. 예를 들어, CapsLock만 켜려면 0x04를 보내고, CapsLock과 NumLock을 모두 켜려면 0x06(0x04 | 0x02)을 보냅니다.
기존에는 BIOS가 자동으로 처리해줬다면, 이제는 직접 하드웨어를 제어하여 사용자 경험을 향상시킬 수 있습니다. 핵심 특징은 첫째, 비동기 프로토콜입니다.
명령을 보낸 후 ACK를 기다려야 하며, 타임아웃도 고려해야 합니다. 둘째, 비트 플래그로 LED를 제어합니다.
비트 0은 ScrollLock, 비트 1은 NumLock, 비트 2는 CapsLock입니다. 셋째, 명령 실패 시 재시도 로직이 필요할 수 있습니다.
이러한 특징들이 일관된 사용자 피드백을 제공합니다.
코드 예제
impl PS2Keyboard {
// LED 상태 플래그
const LED_SCROLL_LOCK: u8 = 0b001;
const LED_NUM_LOCK: u8 = 0b010;
const LED_CAPS_LOCK: u8 = 0b100;
// 키보드로 명령 전송 (입력 버퍼가 비어있을 때까지 대기)
unsafe fn send_command(&mut self, command: u8) -> Result<(), ()> {
// 입력 버퍼가 비어있는지 확인 (상태 레지스터 비트 1)
for _ in 0..1000 {
if self.status_port.read() & 0x02 == 0 {
self.data_port.write(command);
return Ok(());
}
// 짧은 대기
core::hint::spin_loop();
}
Err(()) // 타임아웃
}
// ACK 응답 대기
unsafe fn wait_ack(&mut self) -> Result<(), ()> {
for _ in 0..1000 {
if let Some(response) = self.read_scancode() {
if response == 0xFA { // ACK
return Ok(());
} else if response == 0xFE { // Resend
return Err(());
}
}
core::hint::spin_loop();
}
Err(()) // 타임아웃
}
// LED 상태 업데이트
pub unsafe fn update_leds(&mut self, caps: bool, num: bool, scroll: bool) -> Result<(), ()> {
// Set LEDs 명령 전송
self.send_command(0xED)?;
self.wait_ack()?;
// LED 상태 바이트 생성
let mut led_state = 0u8;
if scroll { led_state |= Self::LED_SCROLL_LOCK; }
if num { led_state |= Self::LED_NUM_LOCK; }
if caps { led_state |= Self::LED_CAPS_LOCK; }
// LED 상태 전송
self.send_command(led_state)?;
self.wait_ack()?;
Ok(())
}
}
설명
이것이 하는 일: PS/2 키보드와 명령-응답 프로토콜로 통신하여 LED 상태를 동기화합니다. 첫 번째로, send_command 메서드가 명령을 안전하게 전송합니다.
키보드로 데이터를 보내기 전에 상태 레지스터의 비트 1(입력 버퍼 상태)을 확인해야 합니다. 이 비트가 1이면 이전 명령이 아직 처리 중이므로 기다려야 합니다.
최대 1000번 반복하며 타임아웃을 구현하여, 하드웨어 문제 시 무한 대기를 방지합니다. 그 다음으로, wait_ack 메서드가 키보드의 응답을 확인합니다.
명령을 받은 키보드는 0xFA(ACK)를 보내 성공을 알리거나, 0xFE(Resend)를 보내 재전송을 요청합니다. read_scancode로 응답을 읽고, ACK면 성공 반환, Resend나 타임아웃이면 에러 반환합니다.
이 프로토콜을 따르지 않으면 키보드가 불안정해집니다. 마지막으로, update_leds 메서드가 전체 시퀀스를 조율합니다.
먼저 0xED 명령을 보내고 ACK를 기다린 후, LED 상태 바이트를 비트 OR로 조합하여 전송합니다. 각 불린 매개변수는 특정 LED에 대응하며, 비트 플래그로 변환됩니다.
두 단계 모두 성공하면 LED가 실제로 변경됩니다. 여러분이 이 코드를 사용하면 키보드 상태와 LED가 항상 일치하여 사용자 혼란을 방지할 수 있습니다.
CapsLock이나 NumLock 상태를 시각적으로 확인할 수 있고, 에러 처리로 하드웨어 문제에도 안정적으로 동작하며, 타임아웃으로 시스템 멈춤을 방지합니다.
실전 팁
💡 LED 업데이트를 너무 자주 하지 마세요. 각 업데이트는 수 밀리초가 걸리므로, 상태가 실제로 변경될 때만 호출하세요.
💡 부팅 시 모든 LED를 순서대로 켜고 끄는 테스트를 실행하면, 키보드가 제대로 작동하는지 확인할 수 있습니다.
💡 일부 키보드는 0xFE(Resend) 대신 다른 응답을 보낼 수 있습니다. 예상치 못한 응답은 로그로 남기고 무시하세요.
💡 가상 머신에서 LED 제어가 작동하지 않을 수 있습니다. QEMU의 경우 -device usb-kbd 대신 -device i8042를 사용하세요.
💡 재시도 로직을 추가하세요. 첫 시도가 실패하면 최대 3번까지 재시도한 후 포기하는 것이 안정적입니다.
8. 키 반복(Key Repeat) 구현 - 자동 반복 입력
시작하며
여러분이 텍스트 편집기에서 'a' 키를 길게 누르고 있을 때, 'aaaaaaa'가 계속 입력되는 기능을 기대했는데 한 글자만 입력되는 상황을 겪어본 적 있나요? 이런 문제는 키 반복(key repeat) 기능을 구현하지 않았기 때문에 발생합니다.
하드웨어는 키 누름과 떼기만 알려주며, 얼마나 오래 눌렀는지는 소프트웨어가 추적해야 합니다. 바로 이럴 때 필요한 것이 타이머 기반 키 반복 시스템입니다.
키가 눌린 시각을 기록하고, 일정 시간(보통 500ms) 후 반복을 시작하며, 그 이후로는 짧은 간격(보통 33ms)으로 키 이벤트를 재생성합니다.
개요
간단히 말해서, 키 반복은 키가 눌린 채로 유지될 때 동일한 키 이벤트를 주기적으로 생성하는 기능입니다. 실무 관점에서 보면, 두 가지 타이밍 파라미터가 있습니다.
첫 번째 반복까지의 지연(repeat delay, 보통 500ms)과 반복 간격(repeat rate, 보통 33ms = 30Hz)입니다. 예를 들어, 사용자가 'a'를 누르면 즉시 'a'가 입력되고, 0.5초 후부터 1초에 30번씩 'a'가 계속 입력됩니다.
화살표 키로 커서를 이동할 때 특히 유용합니다. 기존에는 매번 키를 눌러야 했다면, 이제는 한 번 누른 채로 연속 입력할 수 있습니다.
핵심 특징은 첫째, 타이머가 필요합니다. 시스템 틱 카운터나 타임스탬프로 시간을 측정해야 합니다.
둘째, 현재 눌린 키를 추적해야 합니다. 여러 키가 동시에 눌릴 수 있지만, 보통 마지막 키만 반복합니다.
셋째, 모디파이어 키는 반복하지 않습니다. Shift를 길게 눌러도 아무 일이 일어나지 않아야 합니다.
이러한 특징들이 자연스러운 타이핑 경험을 만듭니다.
코드 예제
use crate::time::get_ticks; // 시스템 틱 카운터 (예: 1ms마다 증가)
pub struct KeyRepeatState {
last_key: Option<(DecodedKey, u64)>, // (키, 누른 시각)
repeat_started: bool,
repeat_delay: u64, // 첫 반복까지 지연 (틱 단위)
repeat_rate: u64, // 반복 간격 (틱 단위)
}
impl KeyRepeatState {
pub fn new() -> Self {
Self {
last_key: None,
repeat_started: false,
repeat_delay: 500, // 500ms
repeat_rate: 33, // 33ms (약 30Hz)
}
}
// 키 눌림 이벤트 처리
pub fn on_key_press(&mut self, key: DecodedKey) {
let now = get_ticks();
self.last_key = Some((key, now));
self.repeat_started = false;
}
// 키 떼기 이벤트 처리
pub fn on_key_release(&mut self) {
self.last_key = None;
self.repeat_started = false;
}
// 주기적으로 호출 (타이머 인터럽트나 메인 루프)
pub fn update(&mut self) -> Option<DecodedKey> {
if let Some((key, press_time)) = self.last_key {
let now = get_ticks();
let elapsed = now - press_time;
if !self.repeat_started {
// 첫 반복 대기 중
if elapsed >= self.repeat_delay {
self.repeat_started = true;
self.last_key = Some((key, now)); // 시각 리셋
return Some(key);
}
} else {
// 반복 중
if elapsed >= self.repeat_rate {
self.last_key = Some((key, now));
return Some(key);
}
}
}
None
}
}
설명
이것이 하는 일: 키가 눌린 시간을 측정하고 적절한 타이밍에 키 이벤트를 자동으로 생성합니다. 첫 번째로, KeyRepeatState 구조체가 반복에 필요한 모든 상태를 유지합니다.
last_key는 현재 눌린 키와 누른 시각을 Option으로 저장합니다. None이면 아무 키도 안 눌림, Some이면 특정 키가 눌린 상태입니다.
repeat_started 플래그는 첫 반복 지연을 통과했는지 추적하여, 두 단계 타이밍을 구현합니다. 그 다음으로, on_key_press와 on_key_release가 키 이벤트를 받아 상태를 업데이트합니다.
키를 누르면 현재 시각과 함께 저장하고 repeat_started를 리셋합니다. 키를 떼면 last_key를 None으로 설정하여 반복을 중단합니다.
새 키를 누르면 이전 키의 반복은 자동으로 취소됩니다. 마지막으로, update 메서드가 타이머 인터럽트나 메인 루프에서 주기적으로(예: 10ms마다) 호출됩니다.
키가 눌린 상태면 경과 시간을 계산하고, 아직 반복이 시작되지 않았으면 repeat_delay와 비교하여 첫 반복 타이밍을 체크합니다. 반복 중이면 repeat_rate 간격으로 키를 재생성합니다.
시각을 리셋하여 다음 반복까지 정확한 간격을 유지합니다. 여러분이 이 코드를 사용하면 데스크톱 운영체제와 같은 자연스러운 키보드 동작을 구현할 수 있습니다.
긴 텍스트나 반복 문자를 빠르게 입력할 수 있고, 화살표 키로 부드럽게 탐색할 수 있으며, 타이밍 파라미터를 조정하여 사용자 선호도를 반영할 수 있습니다.
실전 팁
💡 모디파이어 키(Shift, Ctrl)는 반복하지 마세요. on_key_press에서 DecodedKey::RawKey가 모디파이어면 last_key를 설정하지 않으세요.
💡 repeat_delay와 repeat_rate를 설정 파일이나 UEFI 변수에서 읽어 사용자 정의 가능하게 만드세요.
💡 일부 키는 반복하지 않는 것이 좋습니다. Enter나 Escape는 한 번만 처리되어야 하므로, 반복 제외 목록을 만드세요.
💡 고정밀 타이머를 사용하세요. 1ms 단위가 정확하지 않으면 반복 타이밍이 불규칙해집니다. TSC(Time Stamp Counter)나 HPET을 고려하세요.
💡 여러 키 동시 누름을 지원하려면, HashMap으로 각 키의 상태를 개별 추적하세요. 게임에서 WASD를 동시에 누를 때 유용합니다.
9. 에러 처리 및 복구 - 하드웨어 장애 대응
시작하며
여러분이 키보드 드라이버를 실행하다가 갑자기 모든 키 입력이 먹통이 되거나, 이상한 스캔코드가 계속 반복되는 상황을 겪어본 적 있나요? 시스템을 재부팅하기 전까지 복구가 안 됩니다.
이런 문제는 하드웨어 오류나 프로토콜 위반을 제대로 처리하지 않아 발생합니다. PS/2 컨트롤러가 에러를 보고하거나, 타임아웃이 발생하거나, 출력 버퍼가 오버플로우할 때 적절히 대응하지 않으면 전체 시스템이 불안정해집니다.
바로 이럴 때 필요한 것이 포괄적인 에러 처리 및 복구 메커니즘입니다. 상태 레지스터의 에러 비트를 확인하고, 타임아웃을 설정하며, 리셋 프로토콜을 구현하여 시스템을 복구합니다.
개요
간단히 말해서, 에러 처리는 하드웨어 장애를 감지하고, 로그를 남기며, 가능하면 자동으로 복구하는 방어적 프로그래밍 기법입니다. 실무 관점에서 보면, PS/2 상태 레지스터의 비트 6(타임아웃 에러)과 비트 7(패리티 에러)을 확인해야 합니다.
또한 키보드가 보내는 에러 코드(0x00, 0xFF)도 감지해야 합니다. 예를 들어, 0xFC(Basic Assurance Test Fail)를 받으면 키보드 하드웨어가 불량이거나 연결이 불안정한 것입니다.
기존에는 에러를 무시하고 넘어갔다면, 이제는 문제를 조기에 발견하고 대응하여 시스템 안정성을 높일 수 있습니다. 핵심 특징은 첫째, 다단계 에러 감지입니다.
하드웨어 레벨(패리티 에러), 프로토콜 레벨(타임아웃), 논리 레벨(잘못된 스캔코드)에서 모두 체크합니다. 둘째, 우아한 성능 저하(graceful degradation)를 목표로 합니다.
일부 기능이 실패해도 핵심 기능은 유지합니다. 셋째, 자동 복구를 시도합니다.
키보드 리셋, 재초기화, 데이터 버퍼 비우기 등의 기법을 사용합니다. 이러한 특징들이 견고한 프로덕션 시스템을 만듭니다.
코드 예제
#[derive(Debug)]
pub enum KeyboardError {
Timeout,
ParityError,
HardwareFailure,
InvalidResponse,
BufferOverflow,
}
impl PS2Keyboard {
// 에러 상태 확인
unsafe fn check_errors(&mut self) -> Result<(), KeyboardError> {
let status = self.status_port.read();
// 비트 7: 패리티 에러
if status & 0x80 != 0 {
return Err(KeyboardError::ParityError);
}
// 비트 6: 타임아웃 에러
if status & 0x40 != 0 {
return Err(KeyboardError::Timeout);
}
Ok(())
}
// 안전한 스캔코드 읽기 (에러 처리 포함)
pub unsafe fn read_scancode_safe(&mut self) -> Result<Option<u8>, KeyboardError> {
// 에러 체크
self.check_errors()?;
if self.data_available() {
let scancode = self.data_port.read();
// 에러 코드 확인
match scancode {
0x00 | 0xFF => Err(KeyboardError::InvalidResponse),
0xFC => Err(KeyboardError::HardwareFailure),
_ => Ok(Some(scancode)),
}
} else {
Ok(None)
}
}
// 키보드 리셋 및 재초기화
pub unsafe fn reset(&mut self) -> Result<(), KeyboardError> {
// 리셋 명령 전송 (0xFF)
self.send_command(0xFF)?;
// ACK 대기
self.wait_ack()?;
// BAT 완료 대기 (0xAA = 성공)
for _ in 0..10000 {
if let Some(response) = self.read_scancode() {
if response == 0xAA {
return Ok(());
} else if response == 0xFC {
return Err(KeyboardError::HardwareFailure);
}
}
core::hint::spin_loop();
}
Err(KeyboardError::Timeout)
}
// 데이터 버퍼 비우기 (복구 시 사용)
pub unsafe fn flush_buffer(&mut self) {
while self.data_available() {
self.data_port.read(); // 읽고 버림
}
}
}
설명
이것이 하는 일: 하드웨어 에러를 조기에 감지하고, 다양한 복구 전략으로 시스템 안정성을 유지합니다. 첫 번째로, KeyboardError 열거형이 발생 가능한 모든 에러 타입을 정의합니다.
Timeout은 키보드가 응답하지 않음, ParityError는 데이터 전송 오류, HardwareFailure는 물리적 결함을 나타냅니다. Result 타입으로 감싸서 에러를 명시적으로 전파하고, 호출자가 적절히 처리하도록 강제합니다.
그 다음으로, check_errors 메서드가 상태 레지스터의 에러 비트를 검사합니다. 비트 7(패리티)이나 비트 6(타임아웃)이 설정되어 있으면 즉시 에러를 반환합니다.
read_scancode_safe는 데이터를 읽기 전에 이 검사를 수행하여, 손상된 데이터를 받지 않도록 합니다. 또한 특수 스캔코드(0x00, 0xFF, 0xFC)를 에러로 판단하여 처리합니다.
마지막으로, reset 메서드가 키보드를 완전히 재초기화합니다. 0xFF 명령을 보내면 키보드가 자체 테스트(Basic Assurance Test)를 실행하고, 성공 시 0xAA를 반환합니다.
이 과정은 수백 밀리초가 걸릴 수 있으므로 긴 타임아웃을 설정합니다. flush_buffer는 버퍼에 남은 쓰레기 데이터를 제거하여 깨끗한 상태로 만듭니다.
여러분이 이 코드를 사용하면 일시적인 하드웨어 문제로 전체 시스템이 멈추는 것을 방지할 수 있습니다. 에러가 발생해도 자동으로 복구를 시도하고, 로그로 문제를 추적할 수 있으며, 사용자에게 키보드 교체 같은 명확한 조치를 안내할 수 있습니다.
실전 팁
💡 에러 카운터를 유지하세요. 일정 횟수(예: 10번) 이상 실패하면 키보드를 비활성화하고 사용자에게 알리세요. 계속 리셋하면 시스템이 느려집니다.
💡 워치독 타이머를 설정하세요. 인터럽트 핸들러가 일정 시간(예: 1초) 동안 호출되지 않으면 키보드 리셋을 시도하세요.
💡 USB 키보드 풀백을 준비하세요. PS/2가 실패하면 USB HID 드라이버로 전환하여 최소한의 입력 기능을 유지하세요.
💡 부팅 시 키보드 테스트를 실행하세요. 리셋 명령과 몇 가지 스캔코드를 보내 정상 작동을 확인한 후 드라이버를 활성화하세요.
💡 에러 로그를 영구 저장소에 기록하세요. 간헐적인 하드웨어 문제를 추적하는 데 필수적입니다. 타임스탬프와 에러 타입을 함께 저장하세요.
10. 멀티코어 동기화 - 안전한 동시 접근
시작하며
여러분이 멀티코어 시스템에서 키보드 드라이버를 실행할 때, 한 코어에서 인터럽트 핸들러가 실행되는 동시에 다른 코어에서 LED를 업데이트하려고 하면 어떻게 될까요? 데이터 레이스가 발생하여 예측 불가능한 동작을 하게 됩니다.
이런 문제는 PS/2 컨트롤러가 단일 하드웨어 리소스이고, 여러 코어가 동시에 접근할 수 있기 때문에 발생합니다. 포트 I/O는 원자적이지 않으며, 명령-응답 시퀀스 중간에 다른 코어가 끼어들면 프로토콜이 깨집니다.
바로 이럴 때 필요한 것이 뮤텍스나 스핀락을 사용한 동기화입니다. 한 번에 하나의 코어만 키보드 컨트롤러에 접근하도록 보장해야 합니다.
개요
간단히 말해서, 멀티코어 동기화는 여러 실행 컨텍스트(코어, 인터럽트 핸들러)가 공유 리소스에 동시에 접근하는 것을 방지하는 기법입니다. 실무 관점에서 보면, spin::Mutex를 사용하여 키보드 컨트롤러를 보호합니다.
스핀락은 락을 얻을 때까지 반복해서 시도하므로, 짧은 크리티컬 섹션에 적합합니다. 예를 들어, 코어 0이 LED를 업데이트하는 동안 코어 1의 인터럽트 핸들러는 락이 해제될 때까지 기다립니다.
기존에는 싱글코어에서 인터럽트만 비활성화하면 됐다면, 이제는 멀티코어 환경에서 원자성을 보장해야 합니다. 핵심 특징은 첫째, 데드락을 피해야 합니다.
인터럽트 핸들러에서 락을 잡으면, 같은 코어에서 이미 락을 잡고 있을 수 있습니다. 둘째, 우선순위 역전을 고려해야 합니다.
낮은 우선순위 태스크가 락을 잡고 있으면 높은 우선순위 태스크도 기다립니다. 셋째, 크리티컬 섹션을 최소화해야 합니다.
락을 오래 잡으면 다른 코어가 대기하여 성능이 떨어집니다. 이러한 특징들이 안전한 병렬 처리를 가능하게 합니다.
코드 예제
use spin::Mutex;
use lazy_static::lazy_static;
lazy_static! {
// 전역 키보드 컨트롤러 (뮤텍스로 보호)
static ref KEYBOARD: Mutex<PS2Keyboard> = Mutex::new(PS2Keyboard::new());
}
// 인터럽트 핸들러 (코어 간 동시 호출 가능)
extern "x86-interrupt" fn keyboard_interrupt_handler(
_stack_frame: InterruptStackFrame
) {
// 락 획득 (다른 코어가 사용 중이면 대기)
let mut keyboard = KEYBOARD.lock();
// 크리티컬 섹션: 최대한 짧게 유지
if let Ok(Some(scancode)) = unsafe { keyboard.read_scancode_safe() } {
// 스캔코드를 큐에 추가 (lock-free 큐 사용)
add_scancode(scancode);
}
// 락 자동 해제 (드롭)
// EOI는 락 밖에서 전송
unsafe {
PICS.lock().notify_end_of_interrupt(33);
}
}
// 메인 스레드에서 LED 업데이트 (다른 코어에서 실행 가능)
pub fn update_keyboard_leds(caps: bool, num: bool, scroll: bool) -> Result<(), KeyboardError> {
// 인터럽트 비활성화하여 데드락 방지
x86_64::instructions::interrupts::without_interrupts(|| {
let mut keyboard = KEYBOARD.lock();
unsafe { keyboard.update_leds(caps, num, scroll) }
})
}
// 디버깅: 락 경합 감지
pub fn check_lock_contention() -> bool {
// try_lock은 락을 얻을 수 없으면 즉시 None 반환
KEYBOARD.try_lock().is_none()
}
설명
이것이 하는 일: 스핀락을 사용하여 멀티코어 시스템에서 키보드 컨트롤러에 대한 배타적 접근을 보장합니다. 첫 번째로, lazy_static과 Mutex로 전역 키보드 인스턴스를 생성합니다.
Mutex는 내부적으로 원자적 연산을 사용하여, 한 번에 하나의 코어만 lock()을 성공하도록 합니다. 다른 코어는 스핀(바쁜 대기)하며 락이 해제되기를 기다립니다.
이는 운영체제 스케줄러가 없는 베어메탈 환경에 적합합니다. 그 다음으로, 인터럽트 핸들러가 락을 획득하고 스캔코드를 읽습니다.
크리티컬 섹션(락을 잡고 있는 구간)에서는 최소한의 작업만 수행합니다. 스캔코드를 lock-free 큐에 넣고 즉시 락을 해제합니다.
디코딩이나 출력 같은 느린 작업은 락 밖에서 처리하여, 다른 코어의 대기 시간을 줄입니다. 마지막으로, update_keyboard_leds가 without_interrupts로 감싸져 데드락을 방지합니다.
만약 이 함수가 실행 중에 같은 코어에서 키보드 인터럽트가 발생하면, 인터럽트 핸들러가 같은 락을 얻으려고 해서 무한 대기에 빠집니다. without_interrupts는 인터럽트를 일시적으로 비활성화하여 재진입을 막습니다.
여러분이 이 코드를 사용하면 멀티코어 시스템에서도 안전하게 키보드 드라이버를 실행할 수 있습니다. 데이터 레이스가 없어 예측 가능한 동작을 보장하고, 데드락 방지 기법으로 시스템이 멈추지 않으며, 스핀락의 낮은 오버헤드로 성능을 유지합니다.
실전 팁
💡 락 순서를 일관되게 유지하세요. 여러 뮤텍스를 사용할 때 항상 같은 순서로 획득하면 데드락을 방지할 수 있습니다.
💡 try_lock을 활용하세요. 락을 얻을 수 없으면 나중에 재시도하거나, 다른 작업을 먼저 처리하여 대기 시간을 줄이세요.
💡 락 프리 자료구조를 우선 고려하세요. 큐나 스택은 crossbeam-queue로 락 없이 구현하면 성능이 훨씬 좋습니다.
💡 락 경합을 모니터링하세요. try_lock 실패 횟수를 세어 병목 지점을 찾고, 크리티컬 섹션을 더 줄이거나 락을 분리하세요.
💡 Read-Write 락을 고려하세요. 읽기가 많고 쓰기가 적으면(예: 모디파이어 상태 확인) RwLock이 더 효율적일 수 있습니다.