이미지 로딩 중...

Rust로 만드는 나만의 OS 3 AHCI/IDE 디스크 드라이버 - 슬라이드 1/11
A

AI Generated

2025. 11. 13. · 4 Views

Rust로 만드는 나만의 OS 3 AHCI/IDE 디스크 드라이버

운영체제의 핵심인 디스크 드라이버를 Rust로 직접 구현해봅니다. AHCI와 IDE 컨트롤러의 동작 원리부터 실제 하드웨어 제어까지, 시스템 프로그래밍의 깊이 있는 세계를 경험해보세요.


목차

  1. AHCI 컨트롤러 초기화 - PCI 장치 탐색과 메모리 매핑
  2. HBA 메모리 구조체 정의 - AHCI 레지스터 매핑
  3. 포트 초기화 및 상태 확인 - 디스크 연결 감지
  4. Command List 및 FIS 메모리 할당 - DMA 버퍼 설정
  5. Command Header 및 PRDT 구성 - 읽기 명령 생성
  6. Command FIS 작성 - ATA READ_DMA_EXT 명령
  7. 명령 발행 및 완료 대기 - 폴링과 인터럽트
  8. IDE 컨트롤러 초기화 - 레거시 하드웨어 지원
  9. IDE PIO 읽기 - 폴링 기반 데이터 전송
  10. 에러 처리 및 디바이스 리셋 - 견고한 드라이버 구현

1. AHCI 컨트롤러 초기화 - PCI 장치 탐색과 메모리 매핑

시작하며

여러분이 자체 OS를 만들면서 "어떻게 하드디스크와 통신하지?"라는 질문에 막힌 적 있나요? 실제로 많은 시스템 프로그래밍 입문자들이 파일 시스템을 구현하려다가 이 단계에서 좌절합니다.

디스크 드라이버가 없으면 운영체제는 아무것도 할 수 없습니다. 부팅 이후 프로그램을 로드하거나, 데이터를 저장하거나, 심지어 로그를 남기는 것조차 불가능합니다.

하드웨어와 직접 대화할 수 있는 드라이버가 필수적인 이유입니다. 바로 이럴 때 필요한 것이 AHCI(Advanced Host Controller Interface) 컨트롤러 초기화입니다.

PCI 버스를 통해 SATA 컨트롤러를 찾아내고, 메모리 매핑을 통해 하드웨어와 직접 통신할 수 있는 기반을 마련합니다.

개요

간단히 말해서, AHCI 컨트롤러 초기화는 운영체제가 SATA 디스크와 통신하기 위한 첫 번째 단계입니다. PCI 버스에서 AHCI 컨트롤러를 찾아내고, 해당 컨트롤러의 레지스터에 접근할 수 있도록 메모리 매핑하는 과정입니다.

왜 이 과정이 필요한가요? 현대의 모든 SATA 디스크는 AHCI 표준을 통해 제어됩니다.

BIOS나 UEFI가 디스크를 인식하는 것과 달리, OS 커널에서는 직접 하드웨어 레지스터를 읽고 쓰면서 디스크 명령을 내려야 합니다. 예를 들어, 특정 섹터의 데이터를 읽거나 쓰는 작업은 모두 AHCI 컨트롤러를 통해 이루어집니다.

전통적인 IDE 방식과 비교하면 차이가 명확합니다. 기존 IDE는 포트 I/O를 사용해 간단하지만 느리고 비효율적이었다면, AHCI는 메모리 매핑 I/O를 사용해 훨씬 빠르고 DMA를 통한 직접 메모리 전송이 가능합니다.

AHCI의 핵심 특징은 크게 세 가지입니다. 첫째, NCQ(Native Command Queuing)를 지원해 여러 명령을 동시에 처리할 수 있습니다.

둘째, Hot-plugging을 지원해 시스템 가동 중에도 디스크를 추가/제거할 수 있습니다. 셋째, 표준화된 레지스터 구조로 모든 제조사의 컨트롤러를 동일한 방식으로 제어할 수 있습니다.

이러한 특징들이 현대 운영체제에서 AHCI를 필수로 만드는 이유입니다.

코드 예제

// PCI 버스에서 AHCI 컨트롤러 찾기
use core::ptr::{read_volatile, write_volatile};

const PCI_VENDOR_ID: u16 = 0x8086; // Intel
const PCI_DEVICE_ID: u16 = 0x2922; // AHCI Controller

fn find_ahci_controller() -> Option<usize> {
    // PCI 설정 공간 스캔 (버스 0, 디바이스 0-31)
    for bus in 0..256 {
        for device in 0..32 {
            let base_addr = pci_config_read(bus, device, 0, 0x10);
            let vendor = pci_config_read(bus, device, 0, 0x00) as u16;

            // AHCI 컨트롤러 발견
            if vendor == PCI_VENDOR_ID && (base_addr & 0x1) == 0 {
                let ahci_base = (base_addr & !0xF) as usize;
                println!("AHCI found at: 0x{:x}", ahci_base);
                return Some(ahci_base);
            }
        }
    }
    None
}

설명

이것이 하는 일: PCI 버스에 연결된 모든 장치를 순회하면서 AHCI 컨트롤러를 찾아내고, 해당 컨트롤러의 메모리 매핑 주소를 반환합니다. 첫 번째로, PCI 버스를 스캔하는 부분을 살펴봅시다.

PCI는 계층적 구조로 되어 있어서 최대 256개의 버스, 각 버스당 32개의 디바이스를 가질 수 있습니다. pci_config_read 함수는 특정 버스와 디바이스의 설정 레지스터를 읽어옵니다.

0x00 오프셋은 Vendor ID, 0x10 오프셋은 Base Address Register(BAR)를 의미합니다. 그 다음으로, Vendor ID를 확인하고 BAR에서 메모리 주소를 추출합니다.

base_addr & 0x1로 I/O 공간인지 메모리 공간인지 확인하고(0이면 메모리 공간), base_addr & !0xF로 하위 4비트를 마스킹해 실제 물리 주소를 얻어냅니다. 이 주소가 바로 AHCI 컨트롤러의 레지스터들이 매핑된 메모리 영역입니다.

마지막으로, 찾은 주소를 Option으로 반환합니다. 이 주소를 사용해 HBA(Host Bus Adapter) 메모리 구조체에 접근하고, 포트 상태를 확인하며, 명령을 전송할 수 있습니다.

실제로 이 주소 + 0x00은 Generic Host Control, +0x100부터는 각 포트의 레지스터가 위치합니다. 여러분이 이 코드를 사용하면 AHCI 컨트롤러를 찾아 직접 제어할 수 있는 출발점을 마련할 수 있습니다.

디스크 읽기/쓰기는 물론, 포트 핫플러깅 감지, NCQ 명령 큐 관리 등 모든 고급 기능의 기반이 됩니다. 무엇보다 x86_64 시스템의 거의 모든 SATA 컨트롤러가 이 방식으로 작동하기 때문에, 한 번 구현하면 다양한 하드웨어에서 동작합니다.

실전 팁

💡 PCI 설정 공간 접근 시 반드시 read_volatile/write_volatile을 사용하세요. 컴파일러 최적화로 인해 하드웨어 레지스터 읽기가 스킵될 수 있습니다.

💡 BAR의 타입을 확인하지 않고 사용하면 시스템이 즉시 크래시됩니다. 비트 0으로 I/O 공간(1) vs 메모리 공간(0)을 반드시 체크하세요.

💡 AHCI 컨트롤러가 여러 개 있을 수 있습니다. 실제 프로덕션 코드에서는 모든 컨트롤러를 찾아 벡터로 관리하는 것이 좋습니다.

💡 QEMU에서 테스트할 때는 -device ahci,id=ahci 옵션으로 명시적으로 AHCI 컨트롤러를 추가해야 합니다. 기본 IDE 모드에서는 찾을 수 없습니다.

💡 메모리 매핑된 주소는 물리 주소입니다. 페이징이 활성화된 상태라면 반드시 해당 영역을 identity mapping하거나 적절히 매핑해야 합니다.


2. HBA 메모리 구조체 정의 - AHCI 레지스터 매핑

시작하며

여러분이 AHCI 컨트롤러의 메모리 주소를 찾았다면, 다음 질문은 "이 메모리를 어떻게 해석하지?"입니다. 물리 메모리 주소만으로는 아무것도 할 수 없습니다.

그 메모리에 어떤 레지스터가 어떤 오프셋에 위치하는지 알아야 합니다. 이 문제는 실제로 많은 OS 개발자들이 직면하는 난관입니다.

AHCI 스펙 문서는 수백 페이지에 달하고, 레지스터 오프셋과 비트 필드를 일일이 계산하다 보면 버그가 필연적으로 발생합니다. 잘못된 오프셋에 쓰기 작업을 하면 컨트롤러가 멈추거나 예상치 못한 동작을 합니다.

바로 이럴 때 필요한 것이 HBA(Host Bus Adapter) 메모리 구조체입니다. Rust의 repr(C) 속성과 정확한 메모리 레이아웃을 사용해 AHCI 레지스터를 안전하게 매핑하고, 타입 안전성을 확보하면서 하드웨어를 제어할 수 있습니다.

개요

간단히 말해서, HBA 메모리 구조체는 AHCI 컨트롤러의 하드웨어 레지스터들을 Rust 구조체로 표현한 것입니다. 메모리 매핑된 주소를 이 구조체로 캐스팅하면, 각 필드가 정확히 해당 레지스터에 대응됩니다.

왜 이것이 중요할까요? C나 어셈블리에서는 포인터 연산과 매직 넘버로 레지스터에 접근했지만, Rust에서는 타입 시스템을 활용해 컴파일 타임에 오류를 잡을 수 있습니다.

예를 들어, 32비트 레지스터에 64비트 값을 쓰려고 하면 컴파일 에러가 발생합니다. 또한 필드 이름으로 접근하기 때문에 코드 가독성이 크게 향상됩니다.

전통적인 방법과 비교해봅시다. 기존에는 *(base_addr + 0x04 as *mut u32) = value처럼 포인터 연산으로 접근했다면, 이제는 hba.ghc.write(value)처럼 명확한 이름으로 접근할 수 있습니다.

오프셋 계산 실수도 없고, 어떤 레지스터에 접근하는지 명확합니다. HBA 구조체의 핵심 특징은 세 가지입니다.

첫째, repr(C) 속성으로 C ABI를 따라 필드 순서와 패딩이 보장됩니다. 둘째, volatile 접근을 위한 래퍼를 사용해 컴파일러 최적화를 방지합니다.

셋째, 비트 필드는 별도의 메서드로 추상화해 안전하게 조작할 수 있습니다. 이런 특징들이 안전하고 유지보수 가능한 드라이버 코드를 만들어줍니다.

코드 예제

#[repr(C)]
pub struct HbaMemory {
    // 0x00 - 0x2B: Generic Host Control
    pub cap: Volatile<u32>,      // Host Capabilities
    pub ghc: Volatile<u32>,      // Global Host Control
    pub is: Volatile<u32>,       // Interrupt Status
    pub pi: Volatile<u32>,       // Ports Implemented
    pub vs: Volatile<u32>,       // Version
    pub ccc_ctl: Volatile<u32>,  // Command Completion Coalescing Control
    pub ccc_ports: Volatile<u32>,// Command Completion Coalescing Ports
    pub em_loc: Volatile<u32>,   // Enclosure Management Location
    pub em_ctl: Volatile<u32>,   // Enclosure Management Control
    pub cap2: Volatile<u32>,     // Extended Capabilities
    pub bohc: Volatile<u32>,     // BIOS/OS Handoff Control and Status

    _reserved: [u8; 0xA0 - 0x2C], // 예약된 영역
    _vendor: [u8; 0x100 - 0xA0],  // 벤더 전용

    // 0x100 - 0x10FF: Port Control Registers (최대 32 포트)
    pub ports: [HbaPort; 32],
}

설명

이것이 하는 일: AHCI 컨트롤러의 물리 메모리 레이아웃을 Rust 구조체로 정확히 표현하여, 타입 안전한 방식으로 하드웨어 레지스터에 접근할 수 있게 합니다. 첫 번째로, Generic Host Control 영역을 살펴봅시다.

cap 레지스터는 컨트롤러가 지원하는 기능(NCQ, 64비트 주소 등)을 나타내고, ghc는 전역 제어(인터럽트 활성화, AHCI 모드 전환 등)를 담당합니다. pi는 비트마스크로 어떤 포트가 구현되어 있는지 알려줍니다.

각 필드는 Volatile<u32> 타입으로 래핑되어 있어, 컴파일러가 읽기/쓰기를 최적화로 제거하지 못하게 합니다. 그 다음으로, 예약된 영역과 패딩을 주목하세요.

_reserved_vendor 필드는 실제로 사용하지 않지만, AHCI 스펙에 정의된 메모리 레이아웃을 정확히 맞추기 위해 필요합니다. 0x2C부터 0x100까지는 빈 공간이지만, 이를 생략하면 ports 배열이 잘못된 오프셋에 위치하게 됩니다.

repr(C)는 필드 재배치를 하지 않지만, 크기는 정확히 맞춰야 합니다. 마지막으로, ports 배열이 0x100 오프셋부터 시작됩니다.

각 포트는 128바이트(0x80)의 레지스터 공간을 가지며, 최대 32개 포트를 지원합니다. 실제로는 pi 레지스터로 구현된 포트만 사용합니다.

예를 들어 pi = 0x3이면 포트 0과 1만 사용 가능합니다. 여러분이 이 구조체를 사용하면 하드웨어 레지스터 접근이 매우 직관적이고 안전해집니다.

let hba = unsafe { &mut *(ahci_base as *mut HbaMemory) }로 캐스팅한 후, hba.ghc.write(1 << 31)처럼 명확하게 레지스터를 조작할 수 있습니다. 잘못된 오프셋이나 타입 불일치는 컴파일 단계에서 잡히며, 코드 리뷰 시에도 어떤 레지스터를 다루는지 한눈에 파악됩니다.

실전 팁

💡 repr(C)를 사용할 때 반드시 전체 크기를 확인하세요. core::mem::size_of::<HbaMemory>()로 0x1100 (4352바이트)인지 검증해야 합니다.

💡 Volatile 타입은 직접 산술 연산을 할 수 없습니다. hba.ghc.read() | (1 << 31)로 읽고, 계산한 후, write()로 써야 합니다.

💡 실제 포트 수는 pi 레지스터의 비트 카운트로 확인하세요. 32개를 모두 순회하지 말고, 설정된 비트만 체크하면 성능이 향상됩니다.

💡 디버깅 시 구조체에 Debug trait을 구현하면 편리하지만, Volatile 타입 때문에 자동 derive가 안 됩니다. 커스텀 구현이 필요합니다.

💡 BIOS/UEFI가 AHCI를 사용 중이라면 bohc 레지스터로 OS에게 제어권을 넘겨야 합니다. 이를 생략하면 BIOS와 충돌이 발생할 수 있습니다.


3. 포트 초기화 및 상태 확인 - 디스크 연결 감지

시작하며

여러분이 AHCI 컨트롤러를 찾고 메모리 구조체를 매핑했다면, 이제 "어느 포트에 디스크가 연결되어 있지?"라는 질문에 답해야 합니다. 컨트롤러는 최대 32개의 포트를 지원하지만, 실제로 디스크가 연결된 포트는 일부입니다.

이 과정을 건너뛰고 모든 포트에 명령을 보내면 어떻게 될까요? 연결되지 않은 포트는 타임아웃으로 시간을 낭비하고, 잘못된 상태의 포트는 예상치 못한 에러를 발생시킵니다.

더 심각한 경우, 초기화되지 않은 포트에 DMA 주소를 설정하면 메모리 손상이 일어날 수 있습니다. 바로 이럴 때 필요한 것이 포트 초기화 및 상태 확인입니다.

각 포트의 ssts 레지스터로 디스크 연결 상태를 파악하고, sig 레지스터로 장치 타입을 식별하며, 명령 리스트와 FIS 수신 영역을 설정하여 실제 데이터 전송을 준비합니다.

개요

간단히 말해서, 포트 초기화는 물리적으로 연결된 디스크를 찾아내고, 해당 포트를 사용 가능한 상태로 만드는 과정입니다. SATA 링크 상태 확인, 장치 타입 식별, 메모리 버퍼 할당, 포트 시작 등의 단계를 거칩니다.

왜 이 과정이 복잡할까요? SATA는 핫플러깅을 지원하기 때문에 포트 상태가 동적으로 변할 수 있습니다.

부팅 시점에 연결되어 있던 디스크가 사라질 수도 있고, 새로운 디스크가 추가될 수도 있습니다. 예를 들어, 서버 환경에서 RAID 디스크를 교체할 때 시스템을 재부팅하지 않고 진행하는 것이 가능한 이유입니다.

전통적인 IDE 방식과는 접근이 완전히 다릅니다. IDE는 마스터/슬레이브 설정으로 고정된 2개의 장치만 지원했지만, AHCI는 각 포트가 독립적으로 동작하며 상태를 개별적으로 관리합니다.

또한 DMA 버퍼를 위한 메모리 할당이 필수적입니다. 포트 초기화의 핵심 특징은 다음과 같습니다.

첫째, ssts 레지스터의 DET 필드(비트 0-3)로 물리 계층 연결을 확인합니다(0x3 = 연결됨). 둘째, sig 레지스터로 SATA 하드디스크인지 ATAPI CD-ROM인지 구분합니다.

셋째, Command List Base Address와 FIS Base Address를 설정해 DMA 전송을 위한 메모리를 지정합니다. 이런 단계들이 안정적인 디스크 I/O의 기반이 됩니다.

코드 예제

const SATA_SIG_ATA: u32 = 0x00000101;   // SATA 드라이브
const SATA_SIG_ATAPI: u32 = 0xEB140101; // SATAPI 드라이브

fn probe_port(port: &mut HbaPort) -> Option<DeviceType> {
    // SATA 링크 상태 확인
    let ssts = port.ssts.read();
    let det = ssts & 0xF;  // Device Detection
    let ipm = (ssts >> 8) & 0xF;  // Interface Power Management

    // 디바이스가 연결되고 활성 상태인지 확인
    if det != 0x3 || ipm != 0x1 {
        return None; // 디스크 없음
    }

    // 장치 시그니처 확인
    let sig = port.sig.read();
    let device_type = match sig {
        SATA_SIG_ATA => DeviceType::SATA,
        SATA_SIG_ATAPI => DeviceType::SATAPI,
        _ => return None, // 알 수 없는 장치
    };

    // 포트 시작 (ST, FRE 비트 설정)
    port.cmd.write(port.cmd.read() | (1 << 0) | (1 << 4));

    Some(device_type)
}

설명

이것이 하는 일: 특정 포트에 SATA 디스크가 물리적으로 연결되어 있는지 확인하고, 연결되어 있다면 장치 타입을 반환하며 포트를 사용 가능한 상태로 활성화합니다. 첫 번째로, SATA 링크 상태를 확인하는 부분입니다.

ssts 레지스터는 32비트이지만 중요한 것은 하위 4비트(DET)와 8-11비트(IPM)입니다. DET가 0x3이면 PHY 통신이 성공적으로 수립된 상태이고, IPM이 0x1이면 디바이스가 활성 전원 상태입니다.

이 두 조건을 모두 만족해야 디스크가 정상적으로 연결된 것으로 판단합니다. 그 다음으로, 장치 시그니처를 읽어 디바이스 타입을 식별합니다.

AHCI 컨트롤러는 포트 초기화 중에 디바이스의 IDENTIFY 명령 응답을 sig 레지스터에 자동으로 캐시합니다. 0x00000101은 표준 SATA 하드디스크, 0xEB140101은 ATAPI(광학 드라이브) 시그니처입니다.

SSD도 SATA 시그니처를 사용하므로 동일하게 처리됩니다. 마지막으로, cmd 레지스터의 ST(Start) 비트와 FRE(FIS Receive Enable) 비트를 설정해 포트를 시작합니다.

ST 비트를 1로 설정하면 Command List 처리가 시작되고, FRE 비트를 1로 설정하면 FIS 수신 엔진이 활성화됩니다. 이 두 비트가 설정되어야 실제로 명령을 보내고 응답을 받을 수 있습니다.

여러분이 이 코드를 사용하면 시스템에 연결된 모든 SATA 디스크를 자동으로 감지하고 사용할 수 있습니다. 부팅 시 모든 포트를 순회하며 probe_port를 호출하면, 연결된 디스크 목록을 얻을 수 있습니다.

또한 런타임에 주기적으로 호출하면 핫플러그된 디스크를 감지할 수도 있습니다. 에러 처리도 명확해서, None이 반환되면 해당 포트를 건너뛰면 됩니다.

실전 팁

💡 포트를 시작하기 전에 반드시 Command List Base Address와 FIS Base Address를 설정해야 합니다. 이를 생략하면 DMA가 임의의 메모리에 쓰여 시스템이 크래시됩니다.

💡 ssts 레지스터는 읽기만 가능합니다. 상태를 변경하려면 sctl 레지스터를 사용해 포트 리셋을 수행해야 합니다.

💡 포트를 중지할 때는 ST 비트를 0으로 설정한 후, cmd.read() & (1 << 15) (CR 비트)가 0이 될 때까지 대기해야 합니다. 즉시 중지되지 않습니다.

💡 일부 하드웨어에서는 부팅 직후 sig 레지스터가 0xFFFFFFFF로 초기화되어 있을 수 있습니다. 포트 리셋 후 재확인이 필요합니다.

💡 핫플러그 감지를 위해 Interrupt Status Register의 CPDS(Cold Port Detect Status) 비트를 모니터링하세요. 이벤트 기반으로 처리하면 폴링보다 효율적입니다.


4. Command List 및 FIS 메모리 할당 - DMA 버퍼 설정

시작하며

여러분이 포트를 찾고 활성화했다면, 다음 단계는 "명령과 데이터를 어떻게 주고받지?"입니다. AHCI는 PIO(Programmed I/O) 방식이 아닌 DMA(Direct Memory Access)를 사용하기 때문에, 미리 정해진 메모리 구조에 명령을 작성하면 컨트롤러가 알아서 처리합니다.

이 메모리 구조를 제대로 설정하지 않으면 어떻게 될까요? 컨트롤러는 정의되지 않은 메모리 영역에 접근하려고 시도하고, 페이지 폴트나 메모리 손상이 발생합니다.

더 나쁜 경우, DMA가 커널 코드 영역을 덮어쓰면 즉시 시스템이 다운됩니다. 바로 이럴 때 필요한 것이 Command List와 FIS 메모리 할당입니다.

각 포트마다 최대 32개의 명령 슬롯을 위한 Command List를 할당하고, 디바이스로부터 받을 FIS(Frame Information Structure)를 위한 256바이트 버퍼를 준비해야 합니다.

개요

간단히 말해서, Command List와 FIS 메모리 할당은 AHCI 컨트롤러가 DMA로 접근할 메모리 영역을 미리 확보하고 포트 레지스터에 등록하는 과정입니다. 이 메모리는 물리적으로 연속되어야 하며, 특정 정렬 요구사항을 만족해야 합니다.

왜 이것이 중요할까요? AHCI 컨트롤러는 CPU와 독립적으로 동작하는 하드웨어입니다.

CPU가 메모리에 명령을 쓰면, 컨트롤러가 DMA로 그 메모리를 읽어 디스크에 전달합니다. 반대로 디스크의 응답은 FIS 버퍼에 DMA로 쓰여집니다.

예를 들어, 섹터 읽기 명령을 내리면 컨트롤러가 알아서 데이터를 지정된 메모리에 채워넣습니다. 전통적인 PIO 방식과 비교하면 효율성이 극명하게 다릅니다.

PIO는 CPU가 직접 데이터를 바이트 단위로 읽고 써야 해서 CPU 시간을 많이 소모했지만, DMA는 컨트롤러가 독립적으로 메모리 전송을 처리하므로 CPU는 다른 작업을 할 수 있습니다. 메모리 할당의 핵심 요구사항은 세 가지입니다.

첫째, Command List는 1KB(1024바이트) 정렬되어야 하고 1KB 크기를 가져야 합니다. 둘째, FIS 수신 버퍼는 256바이트 정렬되어야 하고 256바이트 크기를 가져야 합니다.

셋째, Command Table은 128바이트 정렬이 필요합니다. 이런 정렬 요구사항을 만족하지 않으면 하드웨어가 오작동합니다.

코드 예제

use core::alloc::{alloc, Layout};

const CMD_LIST_SIZE: usize = 1024;  // 32 슬롯 * 32 바이트
const FIS_SIZE: usize = 256;

fn allocate_port_memory(port: &mut HbaPort) -> Result<(), &'static str> {
    // Command List 할당 (1KB 정렬)
    let clb_layout = Layout::from_size_align(CMD_LIST_SIZE, 1024)
        .map_err(|_| "Invalid layout")?;
    let clb = unsafe { alloc(clb_layout) as usize };

    // FIS 수신 영역 할당 (256B 정렬)
    let fb_layout = Layout::from_size_align(FIS_SIZE, 256)
        .map_err(|_| "Invalid layout")?;
    let fb = unsafe { alloc(fb_layout) as usize };

    // 포트 레지스터에 물리 주소 설정
    port.clb.write(clb as u32);          // Command List Base (하위 32비트)
    port.clbu.write((clb >> 32) as u32); // 상위 32비트 (64비트 시스템)
    port.fb.write(fb as u32);            // FIS Base (하위 32비트)
    port.fbu.write((fb >> 32) as u32);   // 상위 32비트

    // 메모리 초기화
    unsafe {
        core::ptr::write_bytes(clb as *mut u8, 0, CMD_LIST_SIZE);
        core::ptr::write_bytes(fb as *mut u8, 0, FIS_SIZE);
    }

    Ok(())
}

설명

이것이 하는 일: AHCI 포트가 DMA로 사용할 메모리 영역을 할당하고, 정렬 요구사항을 만족시키며, 포트 레지스터에 물리 주소를 설정합니다. 첫 번째로, Layout을 사용해 정렬된 메모리를 할당합니다.

Rust의 전역 할당자는 기본적으로 정렬을 지원하지만, Layout::from_size_align으로 명시적으로 지정해야 합니다. 1024바이트 정렬은 대부분의 할당자가 지원하지만, 커스텀 구현이 필요할 수도 있습니다.

AHCI 스펙은 이 정렬이 필수이므로, 정렬되지 않은 메모리를 사용하면 하드웨어가 하위 비트를 무시해 잘못된 주소에 접근합니다. 그 다음으로, 할당된 가상 주소를 물리 주소로 변환해야 합니다.

위 코드는 identity mapping(가상 주소 = 물리 주소)를 가정하지만, 실제로는 페이지 테이블을 조회하거나 커널 힙을 물리 메모리에 직접 매핑해야 합니다. clbclbu는 64비트 주소를 두 개의 32비트 레지스터로 나눈 것입니다.

32비트 시스템에서는 clbu를 0으로 설정합니다. 마지막으로, 할당된 메모리를 0으로 초기화합니다.

AHCI 컨트롤러는 메모리의 특정 비트로 명령 완료 여부를 판단하기 때문에, 쓰레기 값이 있으면 이미 완료된 명령으로 오인할 수 있습니다. write_bytes는 unsafe하지만 DMA 버퍼 초기화에는 필수적입니다.

여러분이 이 코드를 사용하면 포트가 명령을 받을 준비가 완료됩니다. 이제 Command List의 슬롯에 명령을 작성하고, 포트의 CI(Command Issue) 레지스터에 비트를 설정하면 컨트롤러가 자동으로 명령을 실행합니다.

데이터 읽기의 경우, FIS 버퍼에 응답이 쓰이고 인터럽트가 발생하여 OS에 알립니다. 메모리 누수를 방지하려면 포트 종료 시 dealloc를 호출해야 합니다.

실전 팁

💡 64비트 주소 지원 여부는 HBA의 CAP 레지스터 비트 31(S64A)로 확인하세요. 지원하지 않으면 clbu와 fbu를 0으로 설정해야 합니다.

💡 메모리 할당 실패 시 패닉하지 말고 Result로 처리하세요. 부팅 초기에는 메모리가 부족할 수 있습니다.

💡 Command Table도 각 Command Header마다 별도로 할당해야 합니다. 슬롯당 최대 8KB(PRDT 포함)가 필요하므로, 32개 슬롯이면 256KB입니다.

💡 물리 메모리가 4GB를 넘는 경우, 일부 구형 AHCI 컨트롤러는 64비트 주소를 지원하지 않습니다. 4GB 이하 영역에 할당하거나 bounce buffer를 사용하세요.

💡 디버깅 시 할당된 주소가 정렬되었는지 확인하세요. clb & 0x3FF == 0이면 1KB 정렬, fb & 0xFF == 0이면 256B 정렬입니다.


5. Command Header 및 PRDT 구성 - 읽기 명령 생성

시작하며

여러분이 메모리를 할당하고 포트를 준비했다면, 이제 "어떻게 실제 읽기 명령을 만들지?"라는 핵심 질문에 도달했습니다. AHCI에서 디스크 읽기는 단순히 함수 호출이 아니라, 복잡한 메모리 구조를 정확히 채워 넣는 작업입니다.

이 과정에서 실수하면 어떻게 될까요? 잘못된 섹터 번호는 엉뚱한 데이터를 읽어오고, 잘못된 PRDT(Physical Region Descriptor Table)는 데이터를 잘못된 메모리에 쓰거나 페이지 폴트를 일으킵니다.

심지어 명령 길이를 잘못 설정하면 컨트롤러가 멈춰버릴 수도 있습니다. 바로 이럴 때 필요한 것이 Command Header와 PRDT 구성입니다.

Command Header는 명령의 메타데이터(FIS 길이, 쓰기/읽기 방향, PRDT 엔트리 수)를 담고, PRDT는 데이터를 저장할 물리 메모리 영역들을 나열합니다. 이 두 구조를 정확히 설정하면 컨트롤러가 알아서 데이터 전송을 완료합니다.

개요

간단히 말해서, Command Header는 명령의 설정 정보를 담는 32바이트 구조체이고, PRDT는 데이터를 저장할 메모리 영역들의 목록입니다. 하나의 명령은 하나의 Command Header와 여러 개의 PRDT 엔트리로 구성됩니다.

왜 이렇게 복잡한 구조가 필요할까요? AHCI는 scatter-gather DMA를 지원하기 때문입니다.

하나의 읽기 명령으로 여러 개의 비연속적인 메모리 영역에 데이터를 채울 수 있습니다. 예를 들어, 16개 섹터를 읽어서 4KB씩 4개의 서로 다른 메모리 페이지에 분산 저장할 수 있습니다.

이는 가상 메모리 시스템에서 물리 페이지가 연속적이지 않을 때 유용합니다. 전통적인 단일 버퍼 방식과 비교해봅시다.

기존에는 큰 연속 버퍼를 할당하고 나중에 데이터를 복사해야 했다면, PRDT를 사용하면 최종 목적지에 직접 DMA할 수 있어 복사 오버헤드가 없습니다. Command Header의 핵심 필드는 다음과 같습니다.

첫째, CFL(Command FIS Length)은 FIS의 길이를 DWORD 단위로 표현합니다(READ_DMA_EXT는 5). 둘째, W(Write) 비트는 0이면 읽기, 1이면 쓰기입니다.

셋째, PRDTL(PRDT Length)은 PRDT 엔트리 개수를 나타냅니다. 넷째, CTBA(Command Table Base Address)는 Command Table의 물리 주소입니다.

이런 필드들을 정확히 설정해야 명령이 정상 동작합니다.

코드 예제

#[repr(C)]
struct CommandHeader {
    flags: u16,        // CFL, A, W, P, R, B, C, PRDTL 등
    prdtl: u16,        // PRDT 엔트리 개수
    prdbc: u32,        // 전송된 바이트 수 (컨트롤러가 업데이트)
    ctba: u32,         // Command Table Base Address (하위 32비트)
    ctbau: u32,        // 상위 32비트
    reserved: [u32; 4],
}

#[repr(C)]
struct PrdtEntry {
    dba: u32,          // Data Base Address (하위 32비트)
    dbau: u32,         // 상위 32비트
    reserved: u32,
    dbc: u32,          // Data Byte Count (비트 0-21) | I (비트 31)
}

fn build_read_command(slot: usize, lba: u64, count: u16, buffer: usize) {
    let cmd_header = &mut cmd_list[slot];
    cmd_header.flags = 5 | (0 << 15); // CFL=5, W=0 (읽기)
    cmd_header.prdtl = 1;              // PRDT 엔트리 1개

    let cmd_table = allocate_command_table();
    cmd_header.ctba = cmd_table as u32;

    // PRDT 설정: 데이터를 buffer에 저장
    let prdt = &mut cmd_table.prdt[0];
    prdt.dba = buffer as u32;
    prdt.dbc = (count as u32 * 512) - 1; // 바이트 수 - 1
}

설명

이것이 하는 일: 특정 Command List 슬롯에 디스크 읽기 명령을 생성하여, LBA 주소에서 count개의 섹터를 읽어 buffer에 저장하도록 컨트롤러에게 지시합니다. 첫 번째로, Command Header의 flags 필드를 설정합니다.

하위 5비트는 CFL(Command FIS Length)로, READ_DMA_EXT FIS는 5 DWORD(20바이트)이므로 5를 설정합니다. 비트 15는 W(Write) 플래그로, 읽기 작업이므로 0입니다.

prdtl은 PRDT 엔트리 개수인데, 단일 연속 버퍼를 사용하므로 1입니다. 여러 페이지에 걸친 읽기라면 페이지 개수만큼 설정합니다.

그 다음으로, Command Table을 할당하고 주소를 설정합니다. Command Table은 Command FIS(64바이트) + ATAPI 명령(16바이트) + PRDT(가변 크기)로 구성됩니다.

128바이트 정렬이 필요하며, 각 슬롯마다 독립적으로 할당해야 합니다. ctbactbau에 64비트 물리 주소를 설정합니다.

마지막으로, PRDT 엔트리를 채웁니다. dbadbau는 데이터를 저장할 버퍼의 64비트 물리 주소입니다.

dbc는 전송할 바이트 수 - 1을 설정하는데, 주의할 점은 실제 크기에서 1을 빼야 한다는 것입니다. 예를 들어 512바이트를 전송하려면 511을 설정합니다.

비트 31(I)은 인터럽트 플래그로, 이 엔트리 전송 완료 시 인터럽트를 발생시키려면 1로 설정합니다. 여러분이 이 코드를 사용하면 디스크 읽기 명령의 뼈대가 완성됩니다.

이제 Command Table의 Command FIS 영역에 실제 ATA 명령(READ_DMA_EXT)과 LBA 주소를 채워 넣고, 포트의 CI 레지스터에 슬롯 번호를 설정하면 읽기가 시작됩니다. 컨트롤러는 DMA로 디스크에서 데이터를 읽어 buffer에 채우고, 완료되면 prdbc 필드를 업데이트하여 실제 전송된 바이트 수를 알려줍니다.

에러가 발생하면 포트의 IS 레지스터에 에러 비트가 설정됩니다.

실전 팁

💡 PRDT 엔트리의 최대 크기는 4MB(비트 0-21)입니다. 그보다 큰 데이터를 전송하려면 여러 엔트리로 분할해야 합니다.

💡 dbc 필드는 반드시 짝수여야 합니다. 홀수 바이트 전송은 지원하지 않으므로, 섹터 단위(512바이트)로 정렬하세요.

💡 Command Header의 prdbc는 읽기 전용입니다. 컨트롤러가 전송 완료 후 업데이트하므로, 전송 성공 여부를 확인할 때 사용하세요.

💡 여러 슬롯을 동시에 사용할 때, Command Table은 슬롯마다 별도로 할당해야 합니다. 공유하면 경쟁 조건이 발생합니다.

💡 NCQ를 사용하지 않는 경우, 하나의 명령이 완료될 때까지 다음 명령을 발행하지 마세요. CI 레지스터를 확인해 슬롯이 비었는지 검증하세요.


6. Command FIS 작성 - ATA READ_DMA_EXT 명령

시작하며

여러분이 Command Header와 PRDT를 설정했다면, 이제 "실제 ATA 명령은 어떻게 작성하지?"라는 마지막 퍼즐 조각이 남았습니다. AHCI는 ATA 명령을 FIS(Frame Information Structure) 형식으로 캡슐화하여 전송합니다.

FIS를 잘못 작성하면 어떤 일이 벌어질까요? 잘못된 명령 코드는 디스크가 이해하지 못해 에러를 반환하고, 잘못된 LBA 주소는 엉뚱한 섹터를 읽거나 데이터를 손상시킬 수 있습니다.

심지어 FIS 타입을 잘못 설정하면 컨트롤러가 명령 자체를 무시합니다. 바로 이럴 때 필요한 것이 Command FIS 작성입니다.

Register H2D(Host to Device) FIS 형식으로 ATA 명령과 파라미터를 정확히 인코딩하여, 디스크가 이해할 수 있는 형태로 전달해야 합니다. 특히 48비트 LBA를 지원하는 READ_DMA_EXT 명령은 필드 배치가 복잡합니다.

개요

간단히 말해서, Command FIS는 ATA 명령을 SATA 프로토콜에 맞게 패키징한 데이터 구조입니다. Register H2D FIS는 총 20바이트(5 DWORD)로 구성되며, ATA 명령 코드, LBA 주소, 섹터 카운트 등의 파라미터를 담습니다.

왜 FIS라는 별도 구조가 필요할까요? SATA는 시리얼 인터페이스이므로 병렬 ATA와 달리 패킷 형태로 데이터를 전송합니다.

FIS는 이러한 패킷의 헤더 역할을 하며, 타입 필드로 명령인지 데이터인지 상태인지를 구분합니다. 예를 들어, H2D Register FIS(타입 0x27)는 호스트가 디바이스에게 명령을 보내는 FIS입니다.

전통적인 병렬 ATA와 비교하면, 레지스터에 직접 쓰는 대신 메모리 구조체를 채우는 방식으로 변경되었습니다. 기존에는 각 ATA 레지스터에 포트 I/O로 값을 썼다면, SATA는 모든 레지스터 값을 FIS에 담아 한 번에 전송합니다.

READ_DMA_EXT 명령의 핵심 특징은 다음과 같습니다. 첫째, 48비트 LBA를 지원해 2TB 이상의 대용량 디스크를 접근할 수 있습니다.

둘째, 16비트 섹터 카운트로 최대 32MB를 한 번에 읽을 수 있습니다(65536 섹터 * 512바이트). 셋째, DMA 모드로 전송하므로 CPU 개입 없이 고속 데이터 전송이 가능합니다.

이런 특징들이 현대 디스크 I/O의 표준이 된 이유입니다.

코드 예제

const FIS_TYPE_REG_H2D: u8 = 0x27;
const ATA_CMD_READ_DMA_EXT: u8 = 0x25;

#[repr(C, packed)]
struct FisRegH2D {
    fis_type: u8,      // 0x27 = Register H2D
    flags: u8,         // 비트 7: C (Command), 비트 6-4: PMPort
    command: u8,       // ATA 명령 코드
    featurel: u8,      // Features (하위 8비트)

    lba0: u8,          // LBA [7:0]
    lba1: u8,          // LBA [15:8]
    lba2: u8,          // LBA [23:16]
    device: u8,        // Device 레지스터 (비트 6: LBA 모드)

    lba3: u8,          // LBA [31:24]
    lba4: u8,          // LBA [39:32]
    lba5: u8,          // LBA [47:40]
    featureh: u8,      // Features (상위 8비트)

    countl: u8,        // Count (하위 8비트)
    counth: u8,        // Count (상위 8비트)
    icc: u8,           // Isochronous Command Completion
    control: u8,       // Control 레지스터

    reserved: [u8; 4],
}

fn write_read_fis(cmd_table: &mut CommandTable, lba: u64, count: u16) {
    let fis = &mut cmd_table.cfis as *mut _ as *mut FisRegH2D;
    unsafe {
        (*fis).fis_type = FIS_TYPE_REG_H2D;
        (*fis).flags = 1 << 7;  // C 비트 = 1 (명령 전송)
        (*fis).command = ATA_CMD_READ_DMA_EXT;
        (*fis).device = 1 << 6; // LBA 모드

        // 48비트 LBA 설정
        (*fis).lba0 = (lba & 0xFF) as u8;
        (*fis).lba1 = ((lba >> 8) & 0xFF) as u8;
        (*fis).lba2 = ((lba >> 16) & 0xFF) as u8;
        (*fis).lba3 = ((lba >> 24) & 0xFF) as u8;
        (*fis).lba4 = ((lba >> 32) & 0xFF) as u8;
        (*fis).lba5 = ((lba >> 40) & 0xFF) as u8;

        // 섹터 카운트 설정
        (*fis).countl = (count & 0xFF) as u8;
        (*fis).counth = ((count >> 8) & 0xFF) as u8;
    }
}

설명

이것이 하는 일: Command Table의 Command FIS 영역에 ATA READ_DMA_EXT 명령을 작성하여, 지정된 LBA 주소에서 count개의 섹터를 읽도록 디스크에게 지시합니다. 첫 번째로, FIS 헤더를 설정합니다.

fis_type을 0x27로 설정해 Register H2D FIS임을 표시하고, flags의 비트 7(C 비트)을 1로 설정해 명령 레지스터 업데이트임을 나타냅니다. C 비트가 0이면 단순히 제어 레지스터 업데이트로 해석되어 명령이 실행되지 않습니다.

device 필드의 비트 6을 1로 설정하면 LBA 모드가 활성화됩니다(0이면 구식 CHS 모드). 그 다음으로, 48비트 LBA 주소를 6개의 바이트로 분할합니다.

lba0부터 lba5까지 순서대로 하위 바이트부터 상위 바이트까지 설정합니다. 이렇게 하면 최대 256TB(2^48 * 512바이트)의 디스크를 접근할 수 있습니다.

예를 들어 LBA 0x123456789ABC를 읽으려면, lba0=0xBC, lba1=0x9A, ..., lba5=0x12가 됩니다. 마지막으로, 섹터 카운트를 설정합니다.

countlcounth로 16비트 값을 표현하여 최대 65535개 섹터(32MB)를 한 번에 읽을 수 있습니다. 0을 설정하면 65536개 섹터로 해석됩니다.

featurel/h는 DMA 모드에서는 사용하지 않으므로 0으로 둡니다. 여러분이 이 코드를 사용하면 디스크 읽기 명령이 완성됩니다.

Command FIS를 작성한 후, 앞서 설정한 Command Header와 PRDT와 결합되어 완전한 명령 구조가 됩니다. 이제 포트의 ci 레지스터에 슬롯 비트를 설정하면(예: port.ci.write(1 << slot)), 컨트롤러가 이 FIS를 디스크에 전송하고 DMA로 데이터를 읽어옵니다.

명령 완료는 ci 레지스터의 해당 비트가 0으로 클리어되거나 인터럽트로 확인할 수 있습니다.

실전 팁

💡 28비트 LBA만 필요하다면 READ_DMA(0xC8) 명령을 사용할 수 있지만, 호환성을 위해 항상 READ_DMA_EXT를 사용하는 것이 좋습니다.

💡 device 레지스터의 하위 4비트(비트 0-3)는 CHS 모드에서만 사용됩니다. LBA 모드에서는 0으로 설정하세요.

💡 일부 디스크는 섹터 카운트 0을 256개로 해석하고, 일부는 65536개로 해석합니다. 명시적으로 카운트를 설정하는 것이 안전합니다.

💡 쓰기 명령(WRITE_DMA_EXT, 0x35)을 사용할 때는 Command Header의 W 비트를 1로 설정하고 PRDT에 쓸 데이터의 주소를 넣어야 합니다.

💡 NCQ를 사용하려면 READ_FPDMA_QUEUED(0x60) 명령을 사용하고, featurel/h에 섹터 카운트를, lba 필드에 LBA와 태그를 설정해야 합니다.


7. 명령 발행 및 완료 대기 - 폴링과 인터럽트

시작하며

여러분이 완벽한 명령 구조를 만들었다면, 마지막 단계는 "어떻게 명령을 실행하고 완료를 확인하지?"입니다. 명령이 준비되었다고 해서 자동으로 실행되는 것이 아니라, 명시적으로 컨트롤러에게 알려야 합니다.

명령 완료를 제대로 확인하지 않으면 어떻게 될까요? 데이터가 아직 도착하지 않았는데 버퍼를 읽으면 쓰레기 값을 얻게 되고, 명령이 실패했는데 성공으로 처리하면 파일 시스템이 손상됩니다.

타임아웃 없이 무한정 대기하면 시스템 전체가 멈출 수도 있습니다. 바로 이럴 때 필요한 것이 명령 발행 및 완료 대기 메커니즘입니다.

CI(Command Issue) 레지스터에 슬롯 비트를 설정해 명령을 발행하고, 폴링이나 인터럽트로 완료를 감지하며, IS(Interrupt Status) 레지스터로 에러 여부를 확인해야 합니다.

개요

간단히 말해서, 명령 발행은 포트의 CI 레지스터에 슬롯 번호에 해당하는 비트를 설정하는 것이고, 완료 대기는 해당 비트가 0으로 클리어될 때까지 기다리는 것입니다. 폴링 방식은 루프로 반복 확인하고, 인터럽트 방식은 하드웨어 알림을 받습니다.

왜 두 가지 방식이 존재할까요? 폴링은 구현이 간단하고 레이턴시가 낮아 작은 I/O에 적합합니다.

반면 인터럽트는 CPU를 다른 작업에 사용할 수 있어 대량 I/O나 다중 작업 환경에 유리합니다. 예를 들어, 부팅 중 단일 섹터를 읽을 때는 폴링이 빠르지만, 운영 중 여러 프로세스가 I/O를 요청할 때는 인터럽트가 효율적입니다.

전통적인 IDE 드라이버와 비교하면, AHCI는 NCQ로 여러 명령을 동시에 발행할 수 있습니다. IDE는 하나의 명령이 완료될 때까지 다음 명령을 보낼 수 없었지만, AHCI는 32개 슬롯을 모두 사용해 병렬 처리가 가능합니다.

명령 완료 확인의 핵심 포인트는 세 가지입니다. 첫째, CI 레지스터의 해당 비트가 0이 되면 명령이 완료된 것입니다.

둘째, IS 레지스터의 에러 비트를 확인해 성공/실패를 판단해야 합니다. 셋째, 타임아웃을 설정해 무한정 대기를 방지해야 합니다.

이런 체크가 안정적인 드라이버의 필수 요소입니다.

코드 예제

const TIMEOUT_MS: usize = 5000; // 5초 타임아웃

fn issue_command(port: &mut HbaPort, slot: usize) -> Result<(), &'static str> {
    // 슬롯이 비어있는지 확인
    if (port.ci.read() & (1 << slot)) != 0 {
        return Err("Slot busy");
    }

    // 명령 발행
    port.ci.write(1 << slot);

    // 완료 대기 (폴링 방식)
    let start = timer::now_ms();
    loop {
        let ci = port.ci.read();

        // 명령 완료 확인
        if (ci & (1 << slot)) == 0 {
            break;
        }

        // 타임아웃 확인
        if timer::now_ms() - start > TIMEOUT_MS {
            return Err("Command timeout");
        }

        // CPU 양보
        core::hint::spin_loop();
    }

    // 에러 확인
    let is = port.is.read();
    if (is & (1 << 30)) != 0 {  // TFES: Task File Error
        port.is.write(is);  // 인터럽트 상태 클리어
        return Err("Task file error");
    }

    // 인터럽트 상태 클리어
    port.is.write(is);
    Ok(())
}

설명

이것이 하는 일: 준비된 명령을 AHCI 컨트롤러에게 실행하도록 지시하고, 완료될 때까지 대기하며, 성공 또는 에러를 반환합니다. 첫 번째로, 슬롯 사용 가능 여부를 확인합니다.

CI 레지스터의 해당 비트가 1이면 이미 진행 중인 명령이 있다는 뜻입니다. NCQ를 사용하지 않는 경우 보통 슬롯 0만 사용하므로, 비트 0을 체크합니다.

슬롯이 비어 있으면 port.ci.write(1 << slot)로 명령을 발행합니다. 이 순간 컨트롤러가 Command List에서 해당 슬롯의 Command Header를 읽고 처리를 시작합니다.

그 다음으로, 폴링 루프로 완료를 기다립니다. CI 레지스터를 반복해서 읽어 해당 비트가 0이 되는지 확인합니다.

컨트롤러는 명령 처리가 끝나면 자동으로 이 비트를 클리어합니다. spin_loop 힌트는 CPU에게 바쁜 대기 중임을 알려 전력 소비를 줄이고, 하이퍼스레딩 환경에서 다른 스레드에게 실행 기회를 줍니다.

타임아웃 체크는 하드웨어 오류나 버그로 인한 무한 대기를 방지합니다. 마지막으로, 에러 상태를 확인합니다.

IS 레지스터의 비트 30(TFES)은 디스크가 에러를 반환했음을 나타냅니다. 이 경우 serr(SATA Error) 레지스터와 tfd(Task File Data) 레지스터를 읽어 구체적인 에러 원인을 파악할 수 있습니다.

IS 레지스터는 쓰기로 클리어하는 방식(W1C: Write 1 to Clear)이므로, 읽은 값을 다시 써서 모든 인터럽트 플래그를 클리어합니다. 여러분이 이 코드를 사용하면 동기적으로 디스크 I/O를 수행할 수 있습니다.

함수가 반환되면 데이터가 이미 버퍼에 준비되어 있으므로, 곧바로 사용할 수 있습니다. 인터럽트 기반 비동기 방식으로 전환하려면, 폴링 루프를 제거하고 GHC 레지스터로 인터럽트를 활성화한 후, ISR(Interrupt Service Routine)에서 완료를 처리하면 됩니다.

멀티태스킹 OS에서는 완료 시 대기 중인 프로세스를 깨우는 방식으로 구현합니다.

실전 팁

💡 타임아웃 값은 디스크 타입에 따라 조정하세요. SSD는 밀리초 단위지만, 회전식 HDD는 수 초가 걸릴 수 있습니다.

💡 인터럽트 방식을 사용하려면 GHC 레지스터의 IE(Interrupt Enable) 비트와 포트의 IE 레지스터를 설정해야 합니다. ISR에서는 IS를 읽고 클리어하세요.

💡 NCQ를 사용할 때는 SACT(SActive) 레지스터에 태그를 설정하고, CI가 아닌 SACT가 클리어되는지 확인해야 합니다.

💡 에러 발생 시 포트를 리셋하려면 cmd 레지스터의 ST 비트를 0으로 하고, sctl 레지스터로 COMRESET을 수행한 후, 다시 초기화해야 합니다.

💡 디버깅 시 tfd 레지스터의 Status와 Error 필드를 확인하면 ATA 에러 코드를 볼 수 있습니다. 예: 0x51 = 섹터를 찾을 수 없음.


8. IDE 컨트롤러 초기화 - 레거시 하드웨어 지원

시작하며

여러분이 AHCI를 완벽히 구현했다면, "구형 시스템은 어떻게 하지?"라는 질문이 떠오를 수 있습니다. 2000년대 초반 이전 시스템이나 일부 임베디드 보드는 AHCI를 지원하지 않고 여전히 IDE(Integrated Drive Electronics)를 사용합니다.

IDE를 무시하면 어떻게 될까요? 가상 머신 환경(특히 기본 설정의 QEMU)이나 구형 서버에서 OS가 부팅 후 디스크를 전혀 인식하지 못하게 됩니다.

교육용이나 호환성 테스트에서 IDE 지원은 여전히 중요합니다. 바로 이럴 때 필요한 것이 IDE 컨트롤러 초기화입니다.

고정된 I/O 포트를 통해 Primary/Secondary 채널을 초기화하고, 마스터/슬레이브 디바이스를 감지하며, PIO 또는 DMA 모드를 설정해야 합니다. AHCI보다 단순하지만 여전히 주의가 필요합니다.

개요

간단히 말해서, IDE 컨트롤러 초기화는 표준 I/O 포트(Primary: 0x1F0, Secondary: 0x170)를 통해 ATA 레지스터에 접근하여 연결된 디스크를 감지하고 사용 가능하게 만드는 과정입니다. 왜 IDE가 여전히 필요할까요?

많은 레거시 시스템, 일부 임베디드 장치, 그리고 기본 설정의 가상 머신들이 IDE를 기본으로 사용합니다. 또한 AHCI 컨트롤러가 없거나 BIOS에서 비활성화된 경우 폴백 옵션으로 IDE가 필요합니다.

예를 들어, VMware나 VirtualBox에서 "IDE 컨트롤러" 모드를 선택하면 AHCI 드라이버로는 디스크를 인식할 수 없습니다. AHCI와 비교하면 IDE는 훨씬 단순합니다.

메모리 매핑 대신 포트 I/O를 사용하고, Command List나 FIS 대신 직접 레지스터에 값을 쓰며, DMA 대신 PIO로 데이터를 전송합니다. 하지만 이런 단순함이 오히려 성능 저하와 CPU 오버헤드를 유발합니다.

IDE 초기화의 핵심 단계는 다음과 같습니다. 첫째, 소프트웨어 리셋으로 컨트롤러를 초기화합니다.

둘째, IDENTIFY DEVICE 명령으로 마스터/슬레이브 디바이스를 감지합니다. 셋째, 디바이스 정보(섹터 수, LBA 지원 여부)를 파싱합니다.

넷째, DMA 사용 가능 여부를 확인하고 PIO/DMA 모드를 선택합니다. 이런 단계들이 IDE 드라이버의 기본 구조입니다.

코드 예제

const IDE_PRIMARY_IO: u16 = 0x1F0;
const IDE_PRIMARY_CTRL: u16 = 0x3F6;
const IDE_SECONDARY_IO: u16 = 0x170;
const IDE_SECONDARY_CTRL: u16 = 0x376;

const ATA_REG_DATA: u16 = 0;
const ATA_REG_ERROR: u16 = 1;
const ATA_REG_SECCOUNT: u16 = 2;
const ATA_REG_LBA_LO: u16 = 3;
const ATA_REG_LBA_MID: u16 = 4;
const ATA_REG_LBA_HI: u16 = 5;
const ATA_REG_DEVICE: u16 = 6;
const ATA_REG_STATUS: u16 = 7;
const ATA_REG_COMMAND: u16 = 7;

fn ide_reset(base: u16, ctrl: u16) {
    // 소프트웨어 리셋
    outb(ctrl, 0x04);  // SRST 비트 설정
    io_wait();
    outb(ctrl, 0x00);  // SRST 비트 해제

    // BSY 클리어 대기
    while (inb(base + ATA_REG_STATUS) & 0x80) != 0 {
        io_wait();
    }
}

fn ide_identify(base: u16, master: bool) -> Option<[u16; 256]> {
    // 디바이스 선택 (마스터: 0xA0, 슬레이브: 0xB0)
    outb(base + ATA_REG_DEVICE, if master { 0xA0 } else { 0xB0 });
    io_wait();

    // IDENTIFY 명령
    outb(base + ATA_REG_COMMAND, 0xEC);
    io_wait();

    // 응답 확인
    let status = inb(base + ATA_REG_STATUS);
    if status == 0 {
        return None; // 디바이스 없음
    }

    // 데이터 읽기 (256 워드 = 512 바이트)
    let mut data = [0u16; 256];
    for i in 0..256 {
        data[i] = inw(base + ATA_REG_DATA);
    }
    Some(data)
}

설명

이것이 하는 일: IDE 컨트롤러를 소프트웨어 리셋하여 초기 상태로 만들고, IDENTIFY 명령으로 마스터 또는 슬레이브 디바이스의 정보를 읽어 반환합니다. 첫 번째로, 소프트웨어 리셋을 수행합니다.

Control 레지스터(Primary: 0x3F6, Secondary: 0x376)의 비트 2(SRST)를 1로 설정했다가 0으로 해제하면 컨트롤러가 리셋됩니다. 이 과정에서 진행 중이던 모든 명령이 취소되고 레지스터가 초기화됩니다.

리셋 후에는 Status 레지스터의 BSY(Busy) 비트가 0이 될 때까지 대기해야 컨트롤러가 명령을 받을 준비가 됩니다. 그 다음으로, 디바이스를 선택하고 IDENTIFY 명령을 보냅니다.

Device 레지스터에 0xA0(마스터) 또는 0xB0(슬레이브)을 쓰면 해당 디바이스가 선택됩니다. 그 후 Command 레지스터에 0xEC(IDENTIFY DEVICE)를 쓰면, 디바이스가 자신의 정보를 Data 레지스터에 준비합니다.

Status가 0이면 디바이스가 연결되지 않았다는 뜻입니다. 마지막으로, Data 레지스터에서 256개의 16비트 워드(총 512바이트)를 읽습니다.

inw는 16비트 포트 입력 함수입니다. 이 데이터에는 디스크 모델명(워드 27-46), 시리얼 번호(워드 10-19), 총 섹터 수(워드 60-61 또는 100-103), LBA 지원 여부(워드 49) 등이 포함됩니다.

바이트 순서가 스왑되어 있으므로 문자열은 2바이트씩 바꿔서 해석해야 합니다. 여러분이 이 코드를 사용하면 IDE 디스크를 감지하고 기본 정보를 얻을 수 있습니다.

Primary 마스터, Primary 슬레이브, Secondary 마스터, Secondary 슬레이브 총 4개 위치를 순회하며 ide_identify를 호출하면 시스템의 모든 IDE 디스크를 찾을 수 있습니다. IDENTIFY 데이터의 워드 83 비트 10을 확인하면 48비트 LBA 지원 여부를 알 수 있고, 워드 49 비트 9로 LBA 모드 지원을 확인할 수 있습니다.

실전 팁

💡 io_wait 함수는 보통 0x80 포트로 더미 읽기를 수행해 I/O 지연을 만듭니다. inb(0x80)으로 충분합니다.

💡 ATAPI 장치(CD-ROM)는 IDENTIFY 명령 대신 IDENTIFY PACKET DEVICE(0xA1)를 사용해야 합니다. LBA_MID와 LBA_HI가 0x14, 0xEB면 ATAPI입니다.

💡 일부 디바이스는 IDENTIFY 후 IRQ를 발생시킵니다. 인터럽트 핸들러가 없다면 Control 레지스터의 nIEN 비트(비트 1)를 1로 설정해 인터럽트를 비활성화하세요.

💡 QEMU에서 IDE를 테스트하려면 -drive file=disk.img,format=raw,if=ide로 IDE 드라이브를 추가하세요.

💡 Status 레지스터 읽기는 인터럽트를 클리어하는 부작용이 있습니다. 인터럽트 기반 드라이버에서는 주의해서 읽어야 합니다.


9. IDE PIO 읽기 - 폴링 기반 데이터 전송

시작하며

여러분이 IDE 디스크를 감지했다면, 이제 "어떻게 실제 데이터를 읽지?"라는 실용적인 문제에 직면합니다. IDE는 AHCI처럼 DMA가 기본이 아니라, PIO(Programmed I/O)라는 CPU가 직접 데이터를 전송하는 방식을 사용합니다.

PIO는 비효율적이지만 단순합니다. 그러나 구현을 잘못하면 어떻게 될까요?

디바이스가 준비되지 않았는데 읽으면 쓰레기 데이터를 얻고, DRQ(Data Request) 비트를 확인하지 않으면 타이밍 오류가 발생하며, 섹터 수를 잘못 계산하면 데이터가 잘리거나 넘칩니다. 바로 이럴 때 필요한 것이 IDE PIO 읽기 구현입니다.

READ_SECTORS 명령으로 섹터 읽기를 시작하고, Status 레지스터의 DRQ 비트를 폴링하며, Data 레지스터에서 256개의 워드를 읽어 버퍼에 저장하는 과정을 정확히 수행해야 합니다.

개요

간단히 말해서, IDE PIO 읽기는 ATA 레지스터에 READ_SECTORS 명령과 LBA 주소를 쓰고, 디바이스가 데이터를 준비할 때까지 기다린 후, Data 레지스터에서 워드 단위로 데이터를 읽어오는 과정입니다. 왜 PIO가 여전히 사용될까요?

DMA는 별도의 설정이 필요하고 모든 IDE 컨트롤러가 지원하지 않지만, PIO는 ATA 스펙의 필수 기능으로 모든 디바이스가 지원합니다. 또한 단순한 부트로더나 펌웨어 환경에서는 DMA 설정이 복잡해 PIO를 선호합니다.

예를 들어, MBR 부트로더는 512바이트만 읽으면 되므로 PIO가 충분합니다. AHCI DMA와 비교하면 성능 차이가 큽니다.

DMA는 컨트롤러가 독립적으로 메모리를 채우지만, PIO는 CPU가 루프를 돌며 워드마다 inw를 호출해야 하므로 CPU 시간을 많이 소모합니다. 그러나 작은 데이터나 임베디드 환경에서는 PIO의 단순함이 장점입니다.

PIO 읽기의 핵심 단계는 다음과 같습니다. 첫째, LBA 주소와 섹터 수를 해당 레지스터에 설정합니다.

둘째, READ_SECTORS 명령(28비트 LBA) 또는 READ_SECTORS_EXT 명령(48비트 LBA)을 보냅니다. 셋째, Status 레지스터를 폴링해 DRQ 비트가 1이 될 때까지 기다립니다.

넷째, Data 레지스터에서 256 워드를 읽어 버퍼에 저장하고, 다음 섹터로 반복합니다. 이런 단계들이 PIO 읽기의 전체 흐름입니다.

코드 예제

const ATA_CMD_READ_PIO: u8 = 0x20;       // 28비트 LBA
const ATA_CMD_READ_PIO_EXT: u8 = 0x24;  // 48비트 LBA

const ATA_SR_BSY: u8 = 0x80;   // Busy
const ATA_SR_DRQ: u8 = 0x08;   // Data Request
const ATA_SR_ERR: u8 = 0x01;   // Error

fn ide_read_sectors(base: u16, master: bool, lba: u32, count: u8, buffer: &mut [u8])
    -> Result<(), &'static str> {
    // 디바이스 선택 및 LBA 상위 4비트
    outb(base + ATA_REG_DEVICE,
         0xE0 | (if master { 0 } else { 0x10 }) | ((lba >> 24) & 0x0F) as u8);

    // 섹터 수 및 LBA 주소 설정
    outb(base + ATA_REG_SECCOUNT, count);
    outb(base + ATA_REG_LBA_LO, (lba & 0xFF) as u8);
    outb(base + ATA_REG_LBA_MID, ((lba >> 8) & 0xFF) as u8);
    outb(base + ATA_REG_LBA_HI, ((lba >> 16) & 0xFF) as u8);

    // READ_SECTORS 명령
    outb(base + ATA_REG_COMMAND, ATA_CMD_READ_PIO);

    // 각 섹터마다 읽기
    for i in 0..count as usize {
        // DRQ 대기
        loop {
            let status = inb(base + ATA_REG_STATUS);
            if (status & ATA_SR_ERR) != 0 {
                return Err("IDE read error");
            }
            if (status & ATA_SR_BSY) == 0 && (status & ATA_SR_DRQ) != 0 {
                break;
            }
        }

        // 256 워드 읽기
        let offset = i * 512;
        for j in 0..256 {
            let word = inw(base + ATA_REG_DATA);
            buffer[offset + j * 2] = (word & 0xFF) as u8;
            buffer[offset + j * 2 + 1] = (word >> 8) as u8;
        }
    }

    Ok(())
}

설명

이것이 하는 일: IDE 디바이스에서 지정된 LBA 주소의 섹터들을 PIO 모드로 읽어 제공된 버퍼에 저장합니다. 첫 번째로, 디바이스와 LBA 주소를 설정합니다.

Device 레지스터의 비트 7-5는 항상 0b111, 비트 6은 LBA 모드(1), 비트 4는 슬레이브 선택(0=마스터, 1=슬레이브), 비트 3-0은 LBA의 비트 27-24입니다. 따라서 마스터 디바이스는 0xE0, 슬레이브는 0xF0이 기본값입니다.

28비트 LBA는 이 4비트 + LBA_LO(8비트) + LBA_MID(8비트) + LBA_HI(8비트)로 구성됩니다. 그 다음으로, READ_SECTORS 명령을 보내고 각 섹터의 준비를 기다립니다.

명령을 보내면 디바이스가 디스크에서 데이터를 읽어 내부 버퍼에 준비합니다. 준비가 완료되면 Status 레지스터의 DRQ 비트가 1로 설정됩니다.

BSY 비트는 디바이스가 바쁠 때 1이므로, BSY == 0 && DRQ == 1이 데이터 읽기 가능 상태입니다. ERR 비트가 1이면 에러가 발생한 것이므로 즉시 중단해야 합니다.

마지막으로, Data 레지스터에서 256개의 워드를 읽습니다. 각 섹터는 512바이트 = 256 워드이므로, 루프를 256번 돌며 inw로 읽습니다.

16비트 워드를 두 개의 바이트로 분리해 버퍼에 저장하는데, 리틀 엔디안이므로 하위 바이트가 먼저입니다. 모든 워드를 읽은 후에는 자동으로 DRQ가 0이 되고, 다음 섹터가 있다면 다시 DRQ가 1로 설정됩니다.

여러분이 이 코드를 사용하면 IDE 디스크에서 데이터를 읽을 수 있습니다. 부트로더에서 커널을 로드하거나, 간단한 파일 시스템을 구현하거나, 디버깅 중에 디스크 내용을 확인하는 등의 작업이 가능합니다.

성능은 AHCI DMA에 비해 떨어지지만, 구현이 간단하고 하드웨어 호환성이 높아 초기 OS 개발이나 레거시 시스템 지원에 유용합니다. 48비트 LBA가 필요하면 ATA_CMD_READ_PIO_EXT를 사용하고 LBA를 두 번에 나눠 써야 합니다.

실전 팁

💡 섹터 카운트 0은 256개 섹터를 의미합니다. 명시적으로 1-255를 사용하는 것이 혼란을 줄입니다.

💡 일부 에뮬레이터에서는 Status 읽기 전에 400ns 지연이 필요합니다. Alternate Status 레지스터(Control 레지스터 주소)를 먼저 읽어 지연을 만드세요.

💡 여러 섹터를 읽을 때 중간에 에러가 나면 이미 읽은 데이터는 유효합니다. 부분 성공 처리 로직을 구현하면 복구 가능성이 높아집니다.

💡 PIO 쓰기(WRITE_SECTORS)는 DRQ를 먼저 확인한 후 데이터를 쓰고, 마지막에 BSY가 0이 될 때까지 대기해야 합니다. 읽기와 순서가 다릅니다.

💡 성능 향상을 위해 REP INSW 어셈블리 명령을 사용하면 루프 오버헤드를 줄일 수 있습니다. 256번 루프 대신 한 번에 256 워드를 읽습니다.


10. 에러 처리 및 디바이스 리셋 - 견고한 드라이버 구현

시작하며

여러분이 완벽한 읽기 함수를 만들었다고 해도, "디스크가 응답하지 않으면 어떻게 하지?"라는 현실적인 문제가 남아있습니다. 하드웨어는 언제나 실패할 수 있습니다.

불량 섹터, 케이블 문제, 펌웨어 버그 등이 원인입니다. 에러 처리 없이 드라이버를 운영하면 어떻게 될까요?

하나의 불량 섹터가 전체 시스템을 멈추게 하고, 일시적인 타임아웃이 영구적인 디스크 장애로 오인되며, 복구 가능한 에러도 재부팅 없이는 해결할 수 없게 됩니다. 바로 이럴 때 필요한 것이 체계적인 에러 처리와 디바이스 리셋입니다.

에러 레지스터를 읽어 구체적인 원인을 파악하고, 재시도 로직으로 일시적 에러를 극복하며, 최종 수단으로 디바이스 리셋을 수행하여 시스템 안정성을 확보해야 합니다.

개요

간단히 말해서, 에러 처리는 디스크 드라이버가 예외 상황에서도 시스템을 보호하고 가능한 복구를 시도하는 메커니즘입니다. 에러 감지, 원인 분석, 재시도, 리셋, 상위 계층 통지 등의 단계로 구성됩니다.

왜 에러 처리가 이렇게 중요할까요? 디스크는 기계적 부품(HDD)이나 전자 부품(SSD)으로 구성되어 언제든 고장날 수 있습니다.

게다가 전원 손실, 케이블 불량, 펌웨어 버그 등 외부 요인도 많습니다. 예를 들어, 서버가 운영 중에 SATA 케이블이 느슨해지면 일시적으로 통신이 끊어졌다가 복구될 수 있는데, 이때 재시도 없이 포기하면 불필요하게 서비스가 중단됩니다.

단순히 에러를 반환하는 것과 체계적 처리의 차이는 무엇일까요? 단순 반환은 상위 계층에 책임을 전가하지만, 체계적 처리는 드라이버 레벨에서 복구를 시도하고 실패 시 상세한 정보를 제공합니다.

또한 에러 통계를 수집해 디스크 건강 상태를 모니터링할 수 있습니다. 에러 처리의 핵심 전략은 다음과 같습니다.

첫째, 타임아웃과 재시도 카운트를 설정해 무한 대기를 방지합니다. 둘째, 에러 레지스터를 읽어 복구 가능 여부를 판단합니다(ABRT는 명령 에러, UNC는 불량 섹터 등).

셋째, 소프트 리셋과 하드 리셋을 단계적으로 적용합니다. 넷째, 에러 로그를 남겨 사후 분석을 가능하게 합니다.

이런 전략들이 프로덕션 레벨 드라이버의 필수 요소입니다.

코드 예제

const MAX_RETRIES: usize = 3;

// ATA 에러 코드
const ATA_ERR_AMNF: u8 = 0x01;  // Address Mark Not Found

#Rust#AHCI#IDE#디스크드라이버#OS개발#시스템프로그래밍

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.