이미지 로딩 중...

Rust로 만드는 나만의 OS PCI 디바이스 탐색 - 슬라이드 1/10
A

AI Generated

2025. 11. 14. · 5 Views

Rust로 만드는 나만의 OS PCI 디바이스 탐색

OS 개발에서 핵심이 되는 PCI 디바이스 탐색 방법을 Rust로 구현합니다. PCI 버스 스캐닝부터 디바이스 식별, Configuration Space 접근까지 실전 예제와 함께 깊이 있게 다룹니다.


목차

  1. PCI 버스 구조와 Configuration Space 이해
  2. PCI 버스 전체 스캔 알고리즘
  3. Vendor ID와 Device ID로 디바이스 식별
  4. BAR 레지스터와 메모리 매핑
  5. Class Code로 디바이스 타입 분류
  6. PCI Capability 리스트 순회
  7. PCI Command 레지스터로 디바이스 활성화
  8. 인터럽트 설정 - INTx부터 MSI-X까지
  9. DMA 버퍼 할당과 IOMMU 고려사항

1. PCI 버스 구조와 Configuration Space 이해

시작하며

여러분이 OS 커널을 개발하면서 그래픽 카드나 네트워크 카드를 인식하려고 할 때 막막했던 경험이 있나요? 하드웨어가 분명히 연결되어 있는데 어떻게 찾아야 할지, 어떤 정보를 읽어야 할지 감이 오지 않았을 겁니다.

이런 문제는 PCI(Peripheral Component Interconnect) 버스의 구조를 이해하지 못해서 발생합니다. PCI는 CPU와 주변 장치들이 통신하는 표준화된 방법인데, 모든 디바이스가 고유한 주소 공간에 Configuration Space를 가지고 있어서 이를 통해 디바이스 정보를 얻을 수 있습니다.

바로 이럴 때 필요한 것이 PCI Configuration Space 접근 메커니즘입니다. 표준 I/O 포트(0xCF8, 0xCFC)를 통해 모든 PCI 디바이스의 설정 정보를 읽고 쓸 수 있어, OS가 시스템에 연결된 하드웨어를 자동으로 인식하고 초기화할 수 있게 됩니다.

개요

간단히 말해서, PCI Configuration Space는 각 PCI 디바이스가 가진 256바이트(PCI Express는 4KB)의 메모리 영역으로, 디바이스의 신원과 기능을 담고 있는 "신분증"입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, OS 부팅 시 시스템에 어떤 하드웨어가 있는지 자동으로 감지하고 적절한 드라이버를 로드해야 합니다.

예를 들어, NVMe SSD가 연결되어 있다면 Vendor ID와 Device ID를 읽어 해당 디바이스임을 확인하고, BAR(Base Address Register)를 통해 메모리 매핑 주소를 얻어 실제 데이터 통신을 시작할 수 있습니다. 기존 x86 아키텍처에서는 각 디바이스마다 고정된 I/O 포트를 사용했다면, PCI는 버스/디바이스/펑션 번호로 체계적으로 주소를 지정하여 최대 256개 버스 × 32개 디바이스 × 8개 펑션 = 65,536개의 장치를 관리할 수 있습니다.

Configuration Space의 핵심 특징은 첫 64바이트가 표준 헤더로 정의되어 있고, Vendor ID(2바이트), Device ID(2바이트), Class Code(3바이트), BAR 레지스터(6개) 등 중요한 정보가 고정된 오프셋에 위치한다는 점입니다. 이러한 표준화 덕분에 OS는 제조사나 디바이스 종류에 관계없이 일관된 방법으로 모든 PCI 디바이스를 다룰 수 있습니다.

코드 예제

// PCI Configuration Space 접근을 위한 I/O 포트 정의
const CONFIG_ADDRESS: u16 = 0xCF8;
const CONFIG_DATA: u16 = 0xCFC;

// PCI 디바이스 주소 생성 (버스:디바이스:펑션:오프셋)
fn make_config_address(bus: u8, device: u8, function: u8, offset: u8) -> u32 {
    // Enable bit (31) | Reserved (30-24) | Bus (23-16) | Device (15-11) | Function (10-8) | Offset (7-2)
    let enable: u32 = 1 << 31;
    ((enable) |
     ((bus as u32) << 16) |
     ((device as u32) << 11) |
     ((function as u32) << 8) |
     ((offset & 0xFC) as u32))
}

// Configuration Space에서 32비트 값 읽기
unsafe fn pci_read_config(bus: u8, device: u8, function: u8, offset: u8) -> u32 {
    let address = make_config_address(bus, device, function, offset);
    outl(CONFIG_ADDRESS, address);  // 주소 레지스터에 쓰기
    inl(CONFIG_DATA)  // 데이터 레지스터에서 읽기
}

설명

이것이 하는 일: PCI Configuration Space 접근 메커니즘은 CPU가 I/O 포트 두 개(주소 포트와 데이터 포트)를 사용해 시스템의 모든 PCI 디바이스 정보를 읽고 쓸 수 있게 합니다. 이는 OS가 부팅할 때 플러그 앤 플레이 방식으로 하드웨어를 자동 감지하는 기반이 됩니다.

첫 번째 단계로, make_config_address 함수는 32비트 주소 값을 생성합니다. 최상위 비트(31번)는 반드시 1이어야 하는 Enable 비트이고, 23-16비트는 버스 번호(0-255), 15-11비트는 디바이스 번호(0-31), 10-8비트는 펑션 번호(0-7), 7-2비트는 레지스터 오프셋(4바이트 정렬)을 나타냅니다.

이렇게 비트 시프트와 OR 연산으로 조합하는 이유는 PCI 명세가 정한 표준 포맷이기 때문입니다. 그 다음으로, pci_read_config 함수가 실행되면서 먼저 0xCF8 포트에 생성한 주소를 씁니다.

이 동작은 PCI 컨트롤러에게 "이 주소의 Configuration Space를 준비하라"고 알리는 것입니다. PCI 컨트롤러는 내부적으로 해당 버스와 디바이스를 선택하고 요청한 오프셋의 데이터를 준비합니다.

마지막으로, 0xCFC 포트에서 32비트 값을 읽으면 PCI 컨트롤러가 준비해둔 Configuration Space의 내용이 반환됩니다. 예를 들어 오프셋 0x00에서 읽으면 하위 16비트는 Vendor ID, 상위 16비트는 Device ID가 담겨 있어 "이 디바이스가 Intel 제품인지, AMD 제품인지" 같은 정보를 즉시 알 수 있습니다.

여러분이 이 코드를 사용하면 모든 PCI 슬롯을 순회하면서 연결된 디바이스를 찾고, 각 디바이스의 종류(네트워크 카드, 그래픽 카드, 스토리지 컨트롤러 등)를 Class Code로 판별하며, BAR 레지스터를 읽어 메모리 매핑 I/O 주소를 얻을 수 있습니다. 실무에서는 이 정보로 동적으로 드라이버를 연결하고, 인터럽트 벡터를 설정하며, DMA 버퍼를 할당하는 등 모든 디바이스 초기화 작업의 출발점이 됩니다.

실전 팁

💡 Configuration Space 읽기는 원자적 연산이 아니므로, 멀티코어 환경에서는 0xCF8/0xCFC 접근을 스핀락으로 보호해야 합니다. 두 개의 코어가 동시에 다른 주소를 쓰면 데이터가 섞일 수 있습니다.

💡 디바이스가 존재하지 않는 슬롯을 읽으면 Vendor ID가 0xFFFF로 반환되므로, 이를 체크하여 빈 슬롯을 스킵하면 스캔 시간을 크게 줄일 수 있습니다.

💡 PCI Express는 MMIO 기반의 Enhanced Configuration Mechanism(ECAM)을 지원하므로, ACPI의 MCFG 테이블을 확인하여 더 빠른 접근 방법을 사용하는 것이 좋습니다.

💡 BAR 레지스터의 크기를 알아내려면 해당 레지스터에 0xFFFFFFFF를 쓴 후 읽어서 마스크를 확인해야 하는데, 이때 원래 값을 백업했다가 복원해야 디바이스가 망가지지 않습니다.

💡 멀티펑션 디바이스인지 확인하려면 펑션 0의 Header Type(오프셋 0x0E)의 최상위 비트를 체크하세요. 이 비트가 1이면 펑션 1-7도 스캔해야 합니다.


2. PCI 버스 전체 스캔 알고리즘

시작하며

여러분이 OS 부팅 과정에서 "시스템에 어떤 디바이스들이 연결되어 있는지 전부 찾아야 하는데, 어디서부터 시작해야 할까?"라는 고민을 해본 적 있나요? 256개의 버스, 각 버스마다 32개의 디바이스, 각 디바이스마다 8개의 펑션이 있을 수 있으니 이론적으로는 65,536개를 모두 확인해야 합니다.

이런 문제는 비효율적인 전수조사 방식으로 접근하면 부팅 시간이 길어지고, 존재하지 않는 슬롯에 접근하면서 불필요한 I/O 연산이 발생합니다. 실제로는 대부분의 슬롯이 비어있고, 멀티펑션 디바이스도 드물기 때문에 똑똑한 스캔 전략이 필요합니다.

바로 이럴 때 필요한 것이 재귀적 버스 스캔 알고리즘입니다. 버스 0부터 시작하여 실제로 디바이스가 있는 경우만 체크하고, PCI-to-PCI 브릿지를 만나면 하위 버스를 재귀적으로 탐색하여 효율적으로 전체 PCI 트리를 순회합니다.

개요

간단히 말해서, PCI 버스 스캔은 루트 버스(보통 버스 0)에서 시작해 모든 디바이스를 찾고, 브릿지를 발견하면 그 뒤에 연결된 하위 버스까지 탐색하는 트리 순회 알고리즘입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 현대 시스템은 PCIe 스위치와 여러 레벨의 브릿지로 복잡하게 구성되어 있어서 단순히 버스 0만 스캔해서는 모든 디바이스를 찾을 수 없습니다.

예를 들어, GPU가 PCIe 스위치 뒤의 버스 3에 연결되어 있다면, 버스 0의 브릿지를 통해 버스 3까지 찾아가야 GPU를 인식할 수 있습니다. 기존에는 65,536개 슬롯을 모두 체크하는 브루트포스 방식을 사용했다면, 이제는 Vendor ID가 0xFFFF인 빈 슬롯은 스킵하고, 멀티펑션이 아니면 펑션 0만 체크하며, 브릿지를 발견했을 때만 하위 버스로 내려가는 최적화된 방식을 사용합니다.

핵심 특징은 첫째, Vendor ID 체크로 빈 슬롯을 조기 종료하여 I/O 연산을 90% 이상 줄이고, 둘째, Header Type의 멀티펑션 비트로 불필요한 펑션 스캔을 방지하며, 셋째, Class Code가 0x0604(PCI-to-PCI 브릿지)인 경우 Secondary Bus Number를 읽어 재귀 탐색하는 것입니다. 이러한 특징들이 부팅 시간을 수 초에서 수백 밀리초로 단축시킵니다.

코드 예제

// PCI 디바이스 정보를 담는 구조체
struct PciDevice {
    bus: u8,
    device: u8,
    function: u8,
    vendor_id: u16,
    device_id: u16,
    class_code: u8,
    subclass: u8,
}

// 단일 펑션 확인
fn check_function(bus: u8, device: u8, function: u8, devices: &mut Vec<PciDevice>) {
    let vendor_device = unsafe { pci_read_config(bus, device, function, 0x00) };
    let vendor_id = (vendor_device & 0xFFFF) as u16;

    if vendor_id == 0xFFFF { return; }  // 빈 슬롯

    let device_id = (vendor_device >> 16) as u16;
    let class_info = unsafe { pci_read_config(bus, device, function, 0x08) };
    let class_code = ((class_info >> 24) & 0xFF) as u8;
    let subclass = ((class_info >> 16) & 0xFF) as u8;

    devices.push(PciDevice { bus, device, function, vendor_id, device_id, class_code, subclass });

    // PCI-to-PCI 브릿지면 하위 버스 스캔
    if class_code == 0x06 && subclass == 0x04 {
        let bus_info = unsafe { pci_read_config(bus, device, function, 0x18) };
        let secondary_bus = ((bus_info >> 8) & 0xFF) as u8;
        scan_bus(secondary_bus, devices);  // 재귀 호출
    }
}

// 버스 전체 스캔 (재귀적)
fn scan_bus(bus: u8, devices: &mut Vec<PciDevice>) {
    for device in 0..32 {
        let vendor = unsafe { pci_read_config(bus, device, 0, 0x00) } as u16;
        if vendor == 0xFFFF { continue; }  // 디바이스 없음

        check_function(bus, device, 0, devices);

        // 멀티펑션 디바이스 체크
        let header_type = unsafe { pci_read_config(bus, device, 0, 0x0C) };
        if (header_type >> 16) & 0x80 != 0 {  // 멀티펑션 비트
            for function in 1..8 {
                check_function(bus, device, function, devices);
            }
        }
    }
}

// 전체 PCI 디바이스 열거
pub fn enumerate_pci_devices() -> Vec<PciDevice> {
    let mut devices = Vec::new();
    scan_bus(0, &mut devices);  // 루트 버스부터 시작
    devices
}

설명

이것이 하는 일: PCI 버스 스캔 알고리즘은 시스템의 모든 PCI 디바이스를 자동으로 찾아내어 리스트로 만들어줍니다. 루트 버스에서 시작해 브릿지를 따라 하위 버스까지 재귀적으로 탐색하면서, 각 디바이스의 신원 정보를 수집합니다.

첫 번째 단계로, scan_bus 함수는 주어진 버스의 32개 디바이스 슬롯을 순회합니다. 각 슬롯마다 펑션 0의 Vendor ID를 먼저 읽어보고, 0xFFFF이면 디바이스가 없는 것이므로 continue로 다음 슬롯으로 넘어갑니다.

이 조기 종료 최적화가 없다면 존재하지 않는 수천 개의 슬롯에 대해 불필요한 I/O를 수행하게 됩니다. 그 다음으로, 디바이스가 존재하면 check_function을 호출하여 상세 정보를 읽습니다.

이 함수는 오프셋 0x00에서 Vendor ID와 Device ID를, 오프셋 0x08에서 Class Code와 Subclass를 읽어 PciDevice 구조체에 저장합니다. 여기서 중요한 점은 Class Code가 0x06(브릿지)이고 Subclass가 0x04(PCI-to-PCI)인 경우를 특별히 처리한다는 것입니다.

세 번째 단계에서 브릿지를 발견하면, 오프셋 0x18의 Secondary Bus Number를 읽어 그 버스에 대해 scan_bus를 재귀 호출합니다. 예를 들어 버스 0의 디바이스 1이 브릿지이고 Secondary Bus가 3이라면, 버스 3을 다시 스캔하면서 그 뒤에 연결된 모든 디바이스를 찾아냅니다.

이 재귀 과정이 PCIe 스위치나 멀티레벨 브릿지 구조를 완벽하게 탐색하는 핵심입니다. 마지막으로, 멀티펑션 디바이스 처리를 위해 펑션 0의 Header Type 레지스터(오프셋 0x0C)의 비트 7을 체크합니다.

이 비트가 1이면 같은 디바이스 번호에 펑션 1-7도 별도의 기능을 가진 독립적인 디바이스로 존재할 수 있으므로, 추가로 7번 더 check_function을 호출합니다. 예를 들어 네트워크 카드가 펑션 0은 이더넷, 펑션 1은 관리 인터페이스로 나뉘어 있을 수 있습니다.

여러분이 이 코드를 사용하면 부팅 시 자동으로 GPU, NVMe SSD, USB 컨트롤러, 사운드 카드 등 모든 하드웨어를 발견할 수 있습니다. 실무에서는 발견된 디바이스 리스트를 Vendor ID/Device ID 데이터베이스와 대조하여 드라이버를 매칭하고, Class Code로 디바이스 종류별 초기화 순서를 결정하며, 버스 토폴로지를 파악하여 전력 관리나 핫플러그 이벤트 처리에 활용합니다.

실전 팁

💡 스캔 중에 발견한 디바이스를 즉시 초기화하지 말고 전체 스캔을 완료한 후 Class Code 순서(스토리지 → 네트워크 → 그래픽)로 초기화하세요. 의존성 문제를 방지할 수 있습니다.

💡 PCI Express의 Extended Configuration Space(4KB)는 MMIO로만 접근 가능하므로, ACPI MCFG 테이블을 먼저 파싱하여 ECAM 베이스 주소를 얻어두면 나중에 고급 기능(MSI-X, AER 등)을 쉽게 사용할 수 있습니다.

💡 디바이스 스캔 결과를 커널 부트 로그에 상세히 출력하면 디버깅이 월등히 쉬워집니다. "Bus 0, Device 2, Function 0: Intel NVMe Controller (8086:A804)" 같은 형식으로 기록하세요.

💡 일부 시스템은 버스 0 이외의 루트 버스를 가질 수 있으므로, ACPI _SEG와 _BBN 메서드를 체크하여 모든 루트 버스를 스캔해야 완벽합니다.

💡 핫플러그를 지원하려면 PCIe 루트 포트의 Slot Status 레지스터를 모니터링하고, Presence Detect Changed 인터럽트가 오면 해당 버스를 다시 스캔하는 핸들러를 구현하세요.


3. Vendor ID와 Device ID로 디바이스 식별

시작하며

여러분이 PCI 스캔으로 디바이스를 발견했는데, "이 디바이스가 정확히 무엇인지, 어떤 드라이버를 로드해야 하는지" 어떻게 알 수 있을까요? 똑같이 생긴 네트워크 카드라도 Realtek, Intel, Broadcom 제품이 모두 다른 드라이버와 초기화 절차를 필요로 합니다.

이런 문제는 하드웨어 제조사와 모델이 수천 가지이고, 육안으로는 구분할 수 없기 때문에 발생합니다. 같은 PCIe 슬롯에 꽂혀 있어도 내부 칩셋과 펌웨어가 완전히 다르므로, OS는 소프트웨어적으로 디바이스를 식별할 방법이 필요합니다.

바로 이럴 때 필요한 것이 Vendor ID와 Device ID 기반 식별 시스템입니다. PCI-SIG가 각 제조사에 고유한 16비트 Vendor ID를 할당하고, 제조사가 각 제품에 Device ID를 부여하여, 이 조합으로 세상의 모든 PCI 디바이스를 유일하게 식별할 수 있습니다.

개요

간단히 말해서, Vendor ID는 PCI-SIG가 하드웨어 제조사에게 할당한 고유 번호(예: Intel=0x8086, AMD=0x1022)이고, Device ID는 해당 제조사가 특정 제품에 부여한 모델 번호로, 이 둘의 조합이 디바이스의 "지문"이 됩니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, OS가 부팅 시 발견한 디바이스에 맞는 드라이버를 자동으로 로드하려면 정확한 식별이 필수입니다.

예를 들어, Vendor ID 0x8086, Device ID 0x1533인 디바이스를 발견하면 이는 Intel I210 기가비트 이더넷 컨트롤러이므로, e1000e 드라이버를 로드하고 레지스터 맵 0x1533에 맞는 초기화 루틴을 실행해야 합니다. 기존에는 BIOS 인터럽트나 고정된 I/O 주소로 특정 디바이스를 찾았다면, PCI는 표준화된 ID 시스템으로 플러그 앤 플레이를 가능하게 했습니다.

같은 슬롯에 다른 제품을 꽂아도 OS가 자동으로 인식하고 적절히 대응할 수 있습니다. 핵심 특징은 첫째, Vendor ID는 전 세계적으로 유일하여 충돌이 없고, 둘째, Device ID는 제조사 내부에서만 유일하면 되므로 제조사가 자유롭게 관리하며, 셋째, Revision ID(오프셋 0x08)와 Subsystem ID(오프셋 0x2C)를 추가로 확인하면 같은 칩셋의 다른 리비전이나 OEM 변형까지 구분할 수 있다는 것입니다.

이러한 특징들이 하드웨어 생태계의 다양성을 소프트웨어가 자동으로 처리하게 만듭니다.

코드 예제

// 잘 알려진 Vendor ID 상수
const VENDOR_INTEL: u16 = 0x8086;
const VENDOR_AMD: u16 = 0x1022;
const VENDOR_NVIDIA: u16 = 0x10DE;
const VENDOR_REALTEK: u16 = 0x10EC;

// 디바이스 식별 정보 읽기
fn identify_device(bus: u8, device: u8, function: u8) -> Option<DeviceInfo> {
    let vendor_device = unsafe { pci_read_config(bus, device, function, 0x00) };
    let vendor_id = (vendor_device & 0xFFFF) as u16;

    if vendor_id == 0xFFFF { return None; }

    let device_id = (vendor_device >> 16) as u16;

    // Revision ID와 Class Code 읽기
    let class_rev = unsafe { pci_read_config(bus, device, function, 0x08) };
    let revision_id = (class_rev & 0xFF) as u8;
    let class_code = ((class_rev >> 24) & 0xFF) as u8;

    // Subsystem ID 읽기 (Type 0 헤더만)
    let header_type = unsafe { pci_read_config(bus, device, function, 0x0C) };
    let subsystem = if (header_type >> 16) & 0x7F == 0 {
        let subsys = unsafe { pci_read_config(bus, device, function, 0x2C) };
        Some(((subsys >> 16) as u16, (subsys & 0xFFFF) as u16))
    } else {
        None
    };

    Some(DeviceInfo {
        vendor_id,
        device_id,
        revision_id,
        class_code,
        subsystem_vendor: subsystem.map(|s| s.0),
        subsystem_device: subsystem.map(|s| s.1),
    })
}

// 디바이스 이름 추론 (데이터베이스 없이 간단히)
fn get_vendor_name(vendor_id: u16) -> &'static str {
    match vendor_id {
        VENDOR_INTEL => "Intel Corporation",
        VENDOR_AMD => "Advanced Micro Devices",
        VENDOR_NVIDIA => "NVIDIA Corporation",
        VENDOR_REALTEK => "Realtek Semiconductor",
        _ => "Unknown Vendor",
    }
}

설명

이것이 하는 일: 디바이스 식별 코드는 PCI Configuration Space의 표준 오프셋에서 제조사와 제품 정보를 읽어, OS가 "이 디바이스가 무엇인지" 정확히 판단할 수 있게 합니다. 이 정보는 드라이버 매칭의 핵심 키가 됩니다.

첫 번째 단계로, identify_device 함수는 오프셋 0x00에서 32비트를 읽어 하위 16비트를 Vendor ID로, 상위 16비트를 Device ID로 분리합니다. Vendor ID가 0xFFFF이면 디바이스가 없는 것이므로 None을 반환하여 호출자가 처리를 중단할 수 있게 합니다.

이는 빈 슬롯을 효율적으로 걸러내는 첫 번째 관문입니다. 그 다음으로, 오프셋 0x08에서 Class/Revision 레지스터를 읽습니다.

최하위 8비트는 Revision ID로 칩셋의 스테핑 버전을 나타냅니다(예: A0, B1 같은 실리콘 리비전). 최상위 8비트는 Class Code로 디바이스의 일반 카테고리(0x01=스토리지, 0x02=네트워크, 0x03=디스플레이 등)를 알려줍니다.

Revision ID는 같은 칩셋의 버그 수정 버전을 구분할 때 중요하고, 특정 리비전에만 존재하는 하드웨어 에라타를 우회하는 코드를 작성할 수 있습니다. 세 번째 단계에서 Header Type을 확인합니다.

Type 0(일반 디바이스)인 경우에만 오프셋 0x2C에 Subsystem Vendor ID와 Subsystem Device ID가 존재합니다. 이 값들은 OEM 제조사(예: Dell, HP)가 같은 칩셋을 다른 보드에 탑재할 때 차별화하는 데 사용됩니다.

예를 들어 Intel 네트워크 칩은 똑같은데 Dell 서버용과 HP 서버용이 다른 펌웨어나 설정을 가질 때, Subsystem ID로 구분하여 서로 다른 초기화 파라미터를 적용할 수 있습니다. 마지막으로, get_vendor_name 같은 헬퍼 함수로 Vendor ID를 사람이 읽을 수 있는 이름으로 변환합니다.

실무에서는 pci.ids 데이터베이스(수만 개의 Vendor/Device 매핑)를 파싱하여 정확한 제품명을 표시하지만, 커널 부팅 초기에는 메모리가 부족하므로 주요 벤더만 하드코딩하여 사용합니다. 여러분이 이 코드를 사용하면 드라이버 매칭 테이블을 구축할 수 있습니다.

예를 들어 HashMap<(u16, u16), DriverInitFn>에 (Vendor ID, Device ID) 쌍을 키로 초기화 함수를 저장해두고, 스캔한 디바이스마다 해시맵을 조회하여 자동으로 드라이버를 호출하는 플러그 앤 플레이 시스템을 만들 수 있습니다. 실무에서는 와일드카드 매칭(Device ID=0xFFFF면 해당 벤더의 모든 제품)이나 Class Code 기반 제네릭 드라이버(모든 NVMe 컨트롤러는 표준 프로토콜 사용) 같은 고급 기법도 추가합니다.

실전 팁

💡 pci.ids 파일(https://pci-ids.ucw.cz/)을 빌드 타임에 파싱하여 정적 배열로 임베드하면 런타임에 파일 I/O 없이 모든 디바이스 이름을 조회할 수 있습니다. 다만 수 MB 크기이므로 압축을 고려하세요.

💡 드라이버 매칭 시 Subsystem ID가 일치하는 드라이버를 우선 선택하고, 없으면 Vendor/Device ID만 보는 폴백 로직을 구현하면 OEM 커스터마이징과 제네릭 지원을 모두 만족할 수 있습니다.

💡 Revision ID가 특정 값 미만인 디바이스는 치명적인 하드웨어 버그가 있을 수 있으므로, 드라이버에서 체크하여 경고를 출력하거나 사용을 거부하는 것이 안전합니다.

💡 QEMU나 VirtualBox 같은 가상화 환경은 Vendor ID 0x1234(QEMU) 또는 0x80EE(VirtualBox)를 사용하므로, 이를 감지하여 준가상화 드라이버를 로드하면 성능이 크게 향상됩니다.

💡 디바이스 ID 데이터베이스를 업데이트할 수 있게 설계하면, 커널 재컴파일 없이 새로운 하드웨어를 지원할 수 있습니다. initramfs에 pci.ids를 포함시키는 방법을 고려하세요.


4. BAR 레지스터와 메모리 매핑

시작하며

여러분이 네트워크 카드를 찾았는데, "어떻게 이 디바이스와 실제로 데이터를 주고받을 수 있을까?"라는 의문이 들지 않나요? CPU가 디바이스의 레지스터를 읽고 쓰려면 메모리 주소나 I/O 포트 주소를 알아야 하는데, 이 주소는 부팅할 때마다 BIOS나 UEFI가 동적으로 할당합니다.

이런 문제는 PCI 디바이스가 시스템 메모리 공간의 어디에 매핑되어 있는지 OS가 알 방법이 없으면 발생합니다. 고정된 주소를 사용하면 여러 디바이스가 충돌하므로, 동적 할당이 필수인데 그 정보를 어디서 얻는지가 핵심입니다.

바로 이럴 때 필요한 것이 BAR(Base Address Register) 레지스터입니다. PCI Configuration Space의 오프셋 0x10-0x24에 위치한 6개의 BAR이 디바이스의 메모리 영역이나 I/O 포트 범위를 가리키며, OS는 이를 읽어 디바이스와 통신할 주소를 알아냅니다.

개요

간단히 말해서, BAR은 PCI 디바이스가 사용하는 메모리나 I/O 공간의 베이스 주소를 담고 있는 레지스터로, 펌웨어가 부팅 시 할당한 주소를 OS에게 알려주는 역할을 합니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 디바이스 드라이버가 실제로 하드웨어를 제어하려면 레지스터에 명령을 쓰고 상태를 읽어야 하는데, 이 레지스터들이 어디에 있는지 BAR을 통해 알 수 있습니다.

예를 들어, NVMe SSD 드라이버는 BAR0에서 읽은 메모리 주소를 매핑하여 Submission Queue와 Completion Queue를 설정하고, DMA를 시작하는 레지스터를 제어합니다. 기존 ISA 버스는 고정된 I/O 주소(예: 0x1F0-0x1F7)를 사용해 디바이스 수가 제한되었다면, PCI는 BIOS가 시스템 메모리 맵을 보고 빈 공간에 BAR을 할당하므로 수십 개의 디바이스를 충돌 없이 공존시킬 수 있습니다.

핵심 특징은 첫째, BAR의 최하위 비트가 0이면 메모리 공간, 1이면 I/O 공간이고, 둘째, 메모리 BAR은 Type 필드(비트 1-2)로 32비트/64비트 주소를 구분하며, 셋째, Prefetchable 비트(비트 3)로 읽기 전용 데이터인지 표시하여 CPU 캐시 최적화를 가능하게 한다는 것입니다. 이러한 특징들이 다양한 디바이스 아키텍처를 유연하게 지원합니다.

코드 예제

// BAR 레지스터 오프셋 (0x10부터 6개)
const BAR_OFFSETS: [u8; 6] = [0x10, 0x14, 0x18, 0x1C, 0x20, 0x24];

#[derive(Debug)]
enum BarType {
    Memory32 { address: u32, size: u32, prefetchable: bool },
    Memory64 { address: u64, size: u64, prefetchable: bool },
    IoSpace { port: u16, size: u16 },
}

// BAR 레지스터 파싱
fn read_bar(bus: u8, device: u8, function: u8, bar_index: usize) -> Option<BarType> {
    let offset = BAR_OFFSETS[bar_index];
    let bar = unsafe { pci_read_config(bus, device, function, offset) };

    if bar == 0 { return None; }  // 사용 안 함

    let is_io = bar & 0x1 != 0;

    if is_io {
        // I/O Space BAR
        let port = (bar & 0xFFFC) as u16;
        let size = read_bar_size(bus, device, function, offset) as u16;
        Some(BarType::IoSpace { port, size })
    } else {
        // Memory Space BAR
        let bar_type = (bar >> 1) & 0x3;
        let prefetchable = (bar >> 3) & 0x1 != 0;

        match bar_type {
            0 => {  // 32-bit
                let address = bar & 0xFFFFFFF0;
                let size = read_bar_size(bus, device, function, offset);
                Some(BarType::Memory32 { address, size, prefetchable })
            },
            2 => {  // 64-bit
                let low = bar & 0xFFFFFFF0;
                let high = unsafe { pci_read_config(bus, device, function, offset + 4) };
                let address = ((high as u64) << 32) | (low as u64);
                let size = read_bar_size_64(bus, device, function, offset);
                Some(BarType::Memory64 { address, size, prefetchable })
            },
            _ => None,  // Reserved
        }
    }
}

// BAR 크기 계산 (주의: 원래 값을 복원해야 함)
unsafe fn read_bar_size(bus: u8, device: u8, function: u8, offset: u8) -> u32 {
    let original = pci_read_config(bus, device, function, offset);
    pci_write_config(bus, device, function, offset, 0xFFFFFFFF);  // 모든 비트 쓰기
    let mask = pci_read_config(bus, device, function, offset);
    pci_write_config(bus, device, function, offset, original);  // 복원

    if mask == 0 { return 0; }
    !(mask & 0xFFFFFFF0) + 1  // 크기 = ~mask + 1
}

설명

이것이 하는 일: BAR 파싱 코드는 디바이스가 사용하는 메모리 주소나 I/O 포트를 찾아내어, 드라이버가 해당 주소를 매핑하고 레지스터를 제어할 수 있게 합니다. 또한 영역의 크기를 알아내어 페이지 테이블 매핑 범위를 결정합니다.

첫 번째 단계로, read_bar 함수는 해당 BAR 오프셋에서 32비트 값을 읽습니다. 값이 0이면 해당 BAR이 사용되지 않는 것이므로 None을 반환합니다(디바이스가 6개 BAR을 모두 쓰는 경우는 드물고, 보통 1-2개만 사용).

최하위 비트를 체크하여 I/O 공간인지 메모리 공간인지 구분하는데, 이는 PCI 명세가 정한 표준 포맷입니다. 그 다음으로, I/O 공간 BAR인 경우 비트 15-2가 포트 주소를 나타내므로 0xFFFC 마스크로 추출합니다.

I/O BAR은 항상 32비트이고, 예를 들어 값이 0x0000C001이면 포트 0xC000부터 시작하는 I/O 영역입니다. 크기는 나중에 설명할 마스크 기법으로 계산합니다.

메모리 공간 BAR인 경우, 비트 2-1의 Type 필드를 확인합니다. 0이면 32비트 주소 공간(4GB 이하), 2이면 64비트 주소 공간입니다.

64비트 BAR은 두 개의 연속된 레지스터를 사용하여 상위 32비트와 하위 32비트를 저장하므로, 다음 BAR(offset+4)을 추가로 읽어 64비트 주소를 조합합니다. 예를 들어 GPU의 프레임버퍼는 보통 수 GB 크기로 4GB 이상 주소에 매핑되므로 64비트 BAR을 사용합니다.

세 번째 단계에서 BAR 크기를 알아내기 위해 read_bar_size 함수를 호출합니다. 이 함수는 원래 BAR 값을 백업한 후 0xFFFFFFFF를 쓰고 다시 읽어 마스크를 얻습니다.

PCI 명세에 따르면 디바이스는 자신이 지원하는 주소 범위만 비트를 구현하므로, 예를 들어 1MB 영역이면 하위 20비트가 0으로 읽힙니다. 이 마스크를 반전하고 1을 더하면 크기가 나옵니다(2의 보수).

매우 중요한 점은 크기 읽기 후 반드시 원래 값을 복원해야 디바이스가 정상 동작한다는 것입니다. 일부 디바이스는 BAR이 변경되면 리셋되거나 오작동할 수 있습니다. 마지막으로, Prefetchable 비트(비트 3)를 체크합니다.

이 비트가 1이면 해당 메모리 영역은 읽기 전용이고 사이드 이펙트가 없어 CPU가 미리 캐시에 로드해도 안전합니다(예: GPU 프레임버퍼). 드라이버는 이 정보로 페이지 테이블의 캐시 속성을 설정합니다(Write-Combining으로 매핑하면 그래픽 성능이 크게 향상됨).

여러분이 이 코드를 사용하면 디바이스 초기화 시 BAR을 파싱하여 메모리 매핑을 생성할 수 있습니다. 예를 들어 map_physical_memory(bar.address, bar.size, CacheType::WriteBack)로 커널 가상 주소 공간에 매핑한 후, 해당 주소에 레지스터 오프셋을 더해 디바이스를 제어합니다.

실무에서는 BAR 영역이 다른 디바이스나 시스템 메모리와 겹치지 않는지 e820 메모리 맵과 대조하고, 겹치면 BAR을 재할당하는 로직도 필요합니다.

실전 팁

💡 BAR 크기를 읽는 동안 디바이스가 오작동할 수 있으므로, 전역 PCI 락을 획득하고 인터럽트를 비활성화한 후 빠르게 읽고 복원하세요. 일부 디바이스는 BAR 변경 시 리셋되기도 합니다.

💡 64비트 BAR은 항상 짝수 인덱스(BAR0, BAR2, BAR4)에서 시작하고 다음 BAR을 사용하므로, BAR1이 64비트 BAR의 상위 부분이면 BAR2부터 다시 스캔해야 합니다.

💡 Prefetchable 메모리는 Write-Combining(WC) 캐시 타입으로 매핑하면 대량 쓰기 성능이 10배 이상 향상됩니다. CPU가 여러 쓰기를 묶어 한 번에 PCIe 트랜잭션으로 보내기 때문입니다.

💡 일부 디바이스는 BAR을 사용하지 않고 확장 Capability로 MSI-X 테이블 주소를 제공합니다. BAR과 별도로 Capability 리스트를 스캔하여 추가 리소스를 찾으세요.

💡 IOMMU가 활성화된 시스템에서는 BAR 주소를 그대로 사용하면 DMA가 실패할 수 있습니다. VT-d/AMD-Vi 드라이버를 통해 IOVA(I/O Virtual Address)를 매핑하고 디바이스에 설정해야 합니다.


5. Class Code로 디바이스 타입 분류

시작하며

여러분이 시스템에서 30개의 PCI 디바이스를 발견했는데, "어떤 것이 스토리지고, 어떤 것이 네트워크 카드인지" 어떻게 효율적으로 분류할 수 있을까요? Vendor ID와 Device ID는 수만 가지이므로 일일이 데이터베이스를 조회하기에는 부팅 초기 단계에서 비효율적입니다.

이런 문제는 디바이스의 기능을 빠르게 파악하여 초기화 순서를 결정해야 할 때 발생합니다. 예를 들어 스토리지 컨트롤러를 먼저 초기화해야 루트 파일 시스템을 마운트하고 다른 드라이버를 로드할 수 있는데, 모든 디바이스의 정확한 모델을 파악할 시간이 없습니다.

바로 이럴 때 필요한 것이 Class Code 기반 분류 시스템입니다. PCI 명세가 정의한 표준 Class Code로 디바이스를 큰 카테고리(스토리지, 네트워크, 디스플레이 등)로 나누고, Subclass와 Programming Interface로 세부 타입을 구분하여 제네릭 드라이버를 빠르게 매칭할 수 있습니다.

개요

간단히 말해서, Class Code는 PCI 디바이스의 기능 카테고리를 나타내는 3바이트 코드(Base Class, Subclass, Programming Interface)로, Vendor/Device ID와 무관하게 "이 디바이스가 무슨 일을 하는지" 표준화된 방식으로 알려줍니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, OS는 알려지지 않은 신규 하드웨어라도 Class Code로 대략적인 기능을 파악하여 제네릭 드라이버를 시도할 수 있습니다.

예를 들어, Class Code 0x01(Mass Storage), Subclass 0x08(NVM), Programming Interface 0x02(NVMe)인 디바이스를 발견하면 Vendor가 Intel이든 Samsung이든 상관없이 표준 NVMe 드라이버를 로드할 수 있습니다. 기존에는 각 디바이스마다 전용 드라이버를 작성해야 했다면, Class Code 표준화로 USB 컨트롤러(Class 0x0C, Subclass 0x03), 이더넷 컨트롤러(Class 0x02, Subclass 0x00) 같은 표준 인터페이스는 하나의 드라이버로 여러 제조사 제품을 지원할 수 있습니다.

핵심 특징은 첫째, Base Class(오프셋 0x0B)는 16가지 주요 카테고리(0x01=스토리지, 0x02=네트워크, 0x03=디스플레이, 0x0C=직렬 버스 등)로 디바이스를 구분하고, 둘째, Subclass(오프셋 0x0A)는 각 카테고리의 세부 타입(0x01/0x01=IDE 컨트롤러, 0x01/0x06=SATA 컨트롤러 등)을 나타내며, 셋째, Programming Interface(오프셋 0x09)는 레지스터 인터페이스 호환성(AHCI, XHCI 등)을 표시한다는 것입니다. 이러한 계층 구조가 드라이버 아키텍처를 체계적으로 설계하게 합니다.

코드 예제

// 표준 Base Class Code 정의
const CLASS_STORAGE: u8 = 0x01;
const CLASS_NETWORK: u8 = 0x02;
const CLASS_DISPLAY: u8 = 0x03;
const CLASS_MULTIMEDIA: u8 = 0x04;
const CLASS_BRIDGE: u8 = 0x06;
const CLASS_SERIAL_BUS: u8 = 0x0C;

// Subclass 예시 (스토리지)
const SUBCLASS_SCSI: u8 = 0x00;
const SUBCLASS_IDE: u8 = 0x01;
const SUBCLASS_SATA: u8 = 0x06;
const SUBCLASS_NVM: u8 = 0x08;

// Programming Interface 예시
const PROGIF_AHCI: u8 = 0x01;  // SATA AHCI
const PROGIF_NVME: u8 = 0x02;  // NVMe

// Class Code 정보 구조체
#[derive(Debug, Clone, Copy)]
struct ClassInfo {
    base_class: u8,
    subclass: u8,
    prog_if: u8,
}

impl ClassInfo {
    // Class Code 읽기 (오프셋 0x08에서)
    fn read(bus: u8, device: u8, function: u8) -> Self {
        let class_reg = unsafe { pci_read_config(bus, device, function, 0x08) };
        Self {
            prog_if: ((class_reg >> 8) & 0xFF) as u8,
            subclass: ((class_reg >> 16) & 0xFF) as u8,
            base_class: ((class_reg >> 24) & 0xFF) as u8,
        }
    }

    // 디바이스 타입 판별
    fn device_type(&self) -> &'static str {
        match (self.base_class, self.subclass, self.prog_if) {
            (CLASS_STORAGE, SUBCLASS_NVM, PROGIF_NVME) => "NVMe Storage Controller",
            (CLASS_STORAGE, SUBCLASS_SATA, PROGIF_AHCI) => "SATA AHCI Controller",
            (CLASS_STORAGE, SUBCLASS_IDE, _) => "IDE Controller",
            (CLASS_NETWORK, 0x00, _) => "Ethernet Controller",
            (CLASS_NETWORK, 0x80, _) => "Wireless Network Controller",
            (CLASS_DISPLAY, 0x00, _) => "VGA Compatible Controller",
            (CLASS_DISPLAY, 0x01, _) => "XGA Controller",
            (CLASS_BRIDGE, 0x04, _) => "PCI-to-PCI Bridge",
            (CLASS_SERIAL_BUS, 0x03, 0x30) => "XHCI USB Controller",
            (CLASS_SERIAL_BUS, 0x03, 0x20) => "EHCI USB Controller",
            _ => "Unknown Device Type",
        }
    }

    // 제네릭 드라이버 로드 가능 여부
    fn has_generic_driver(&self) -> bool {
        matches!(
            (self.base_class, self.subclass, self.prog_if),
            (CLASS_STORAGE, SUBCLASS_NVM, PROGIF_NVME) |
            (CLASS_STORAGE, SUBCLASS_SATA, PROGIF_AHCI) |
            (CLASS_SERIAL_BUS, 0x03, 0x30)  // XHCI
        )
    }
}

설명

이것이 하는 일: Class Code 분류 시스템은 수만 가지 PCI 디바이스를 표준화된 카테고리로 나누어, OS가 알려지지 않은 하드웨어도 기본적인 기능을 파악하고 적절한 드라이버를 선택할 수 있게 합니다. 이는 플러그 앤 플레이의 핵심 메커니즘입니다.

첫 번째 단계로, ClassInfo::read 함수는 오프셋 0x08에서 32비트 레지스터를 읽습니다. 이 레지스터의 하위 8비트는 Revision ID이고, 상위 24비트가 Class Code입니다.

비트 시프트로 Programming Interface(8-15비트), Subclass(16-23비트), Base Class(24-31비트)를 추출하여 구조체에 저장합니다. 이 세 값의 조합이 디바이스의 "직업"을 나타냅니다.

그 다음으로, device_type 메서드는 Class Code 조합을 사람이 읽을 수 있는 문자열로 변환합니다. 예를 들어 (0x01, 0x08, 0x02)는 "Mass Storage(0x01) - NVM(0x08) - NVMe(0x02)"이므로 "NVMe Storage Controller"입니다.

이 매칭은 PCI Code and ID Assignment Specification에 정의된 표준 값을 따르므로, 모든 PCI 디바이스가 같은 규칙을 사용합니다. 세 번째 단계에서 has_generic_driver 메서드는 표준 인터페이스인지 확인합니다.

NVMe, AHCI, XHCI 같은 표준은 레지스터 맵과 프로토콜이 명세로 정의되어 있어, Vendor에 관계없이 하나의 드라이버로 모든 제품을 지원할 수 있습니다. 예를 들어 NVMe 명세에 따르면 모든 NVMe SSD는 동일한 Submission/Completion Queue 구조를 사용하므로, Intel SSD든 Samsung SSD든 같은 커맨드로 I/O를 수행할 수 있습니다.

실무에서 이 정보는 드라이버 로딩 우선순위를 결정하는 데 사용됩니다. 부팅 시 먼저 CLASS_STORAGE 디바이스를 초기화하여 루트 파일 시스템을 마운트하고, 그 다음 CLASS_NETWORK로 네트워크를 활성화하며, CLASS_DISPLAY는 마지막에 초기화하는 식입니다.

또한 Class Code 기반 제네릭 드라이버를 먼저 시도하고, 실패하면 Vendor/Device ID 기반 전용 드라이버로 폴백하는 2단계 전략을 구현할 수 있습니다. 마지막으로, Subclass와 Programming Interface의 조합으로 호환성을 판단합니다.

예를 들어 USB 컨트롤러는 UHCI(0x00), OHCI(0x10), EHCI(0x20), XHCI(0x30) 같은 다양한 인터페이스가 있는데, OS는 자신이 지원하는 인터페이스만 초기화하고 나머지는 스킵할 수 있습니다. 최신 OS는 XHCI만 지원하여 코드를 단순화하고, 레거시 UHCI/OHCI는 무시하는 전략을 취하기도 합니다.

여러분이 이 코드를 사용하면 디바이스 목록을 Class Code로 그룹화하여 초기화 순서를 최적화할 수 있습니다. 예를 들어 devices.sort_by_key(|d| (d.class_info.base_class, d.bus))로 스토리지부터 순서대로 정렬하고, 각 그룹별로 병렬 초기화를 수행하면 부팅 시간을 단축할 수 있습니다.

실무에서는 Class Code 통계를 수집하여 "이 시스템에 NVMe SSD 2개, XHCI USB 4개, Intel 네트워크 1개가 있습니다" 같은 요약 정보를 부팅 로그에 출력하면 디버깅이 쉬워집니다.

실전 팁

💡 Class Code 0x00(레거시 디바이스)이나 0xFF(벤더 전용)는 표준 인터페이스가 없으므로, 반드시 Vendor/Device ID 데이터베이스를 조회하여 전용 드라이버를 찾아야 합니다.

💡 PCI-to-PCI 브릿지(Class 0x06, Subclass 0x04)는 하위 버스를 가지므로, 재귀 스캔 로직에서 특별히 처리해야 하며, 브릿지의 Secondary/Subordinate Bus Number를 읽어 탐색 범위를 결정하세요.

💡 Subclass 0x80은 "기타(Other)"를 의미하므로, 이 경우 Programming Interface나 Vendor ID를 추가로 확인해야 정확한 타입을 알 수 있습니다.

💡 일부 멀티펑션 디바이스는 펑션마다 다른 Class Code를 가집니다(예: 펑션 0은 오디오, 펑션 1은 모뎀). 각 펑션을 독립적으로 처리하세요.

💡 PCI Express Native Mode는 Programming Interface를 통해 표시되므로, 레거시 PCI 모드인지 PCIe 네이티브 모드인지 확인하여 적절한 초기화 경로를 선택하면 성능이 향상됩니다.


6. PCI Capability 리스트 순회

시작하며

여러분이 PCI 디바이스를 초기화하면서 "MSI 인터럽트를 활성화하려면 어디를 설정해야 하지?", "전력 관리 기능은 어떻게 제어하지?" 같은 의문을 가진 적 있나요? Configuration Space의 표준 64바이트 헤더에는 기본 정보만 있고, 고급 기능은 다른 곳에 숨겨져 있습니다.

이런 문제는 PCI 명세가 시간이 지나면서 MSI, MSI-X, Power Management, PCI Express 같은 확장 기능을 추가했는데, 고정된 오프셋에 모두 넣을 수 없어서 발생합니다. 각 디바이스가 지원하는 기능도 제각각이므로 유연한 구조가 필요했습니다.

바로 이럴 때 필요한 것이 Capability 연결 리스트 구조입니다. Configuration Space의 오프셋 0x34에 있는 Capabilities Pointer가 첫 번째 Capability를 가리키고, 각 Capability는 다음 Capability의 오프셋을 포함하여 체인을 형성합니다.

OS는 이 리스트를 순회하며 필요한 기능을 찾습니다.

개요

간단히 말해서, PCI Capability는 디바이스의 확장 기능을 나타내는 가변 길이 데이터 구조로, Configuration Space 내에 연결 리스트 형태로 저장되어 있으며, 각 Capability는 ID로 기능 타입을 식별합니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 현대 PCI 디바이스는 수십 가지 선택적 기능을 지원하는데, OS는 자신이 필요한 기능만 찾아 활성화해야 합니다.

예를 들어, NVMe SSD가 MSI-X를 지원하는지 확인하려면 Capability ID 0x11(MSI-X)을 검색하고, 그 구조체에서 인터럽트 벡터 개수와 테이블 위치를 읽어 설정합니다. 기존 PCI 2.x는 고정된 레지스터만 사용했다면, PCI 3.0부터는 Capability 구조로 Power Management(0x01), MSI(0x05), PCI Express(0x10), MSI-X(0x11), Advanced Error Reporting 같은 기능을 플러그형으로 추가할 수 있게 되었습니다.

핵심 특징은 첫째, Status 레지스터(오프셋 0x06)의 Capabilities List 비트로 디바이스가 Capability를 지원하는지 확인하고, 둘째, Capability 헤더는 1바이트 ID + 1바이트 Next Pointer + 가변 길이 데이터로 구성되며, 셋째, Next Pointer가 0이면 리스트의 끝임을 나타낸다는 것입니다. 이러한 유연한 구조가 하위 호환성을 유지하면서 새로운 기능을 계속 추가할 수 있게 합니다.

코드 예제

// 주요 Capability ID 정의
const CAP_POWER_MANAGEMENT: u8 = 0x01;
const CAP_MSI: u8 = 0x05;
const CAP_VENDOR_SPECIFIC: u8 = 0x09;
const CAP_PCIE: u8 = 0x10;
const CAP_MSIX: u8 = 0x11;

// Capability 헤더 구조
#[derive(Debug)]
struct CapabilityHeader {
    id: u8,
    next: u8,
    offset: u8,  // Configuration Space에서의 위치
}

// Capability 리스트 순회
fn find_capability(bus: u8, device: u8, function: u8, cap_id: u8) -> Option<u8> {
    // Status 레지스터에서 Capability 지원 확인 (비트 4)
    let status = unsafe { pci_read_config(bus, device, function, 0x04) };
    if (status >> 16) & (1 << 4) == 0 {
        return None;  // Capability 미지원
    }

    // Capabilities Pointer 읽기 (오프셋 0x34)
    let cap_ptr_reg = unsafe { pci_read_config(bus, device, function, 0x34) };
    let mut cap_ptr = (cap_ptr_reg & 0xFF) as u8;

    // 4바이트 정렬 확인
    if cap_ptr & 0x3 != 0 || cap_ptr == 0 {
        return None;
    }

    // 리스트 순회 (최대 48회 - 무한 루프 방지)
    for _ in 0..48 {
        let cap_header = unsafe { pci_read_config(bus, device, function, cap_ptr) };
        let id = (cap_header & 0xFF) as u8;
        let next = ((cap_header >> 8) & 0xFF) as u8;

        if id == cap_id {
            return Some(cap_ptr);  // 찾음
        }

        if next == 0 || next & 0x3 != 0 {
            break;  // 리스트 끝 또는 잘못된 포인터
        }

        cap_ptr = next;
    }

    None  // 찾지 못함
}

// MSI-X Capability 정보 읽기 예시
#[derive(Debug)]
struct MsixInfo {
    table_size: u16,      // 벡터 개수 - 1
    table_bar: u8,        // 테이블이 위치한 BAR
    table_offset: u32,    // BAR 내 오프셋
    pba_bar: u8,          // Pending Bit Array BAR
    pba_offset: u32,      // PBA 오프셋
}

fn read_msix_capability(bus: u8, device: u8, function: u8) -> Option<MsixInfo> {
    let cap_offset = find_capability(bus, device, function, CAP_MSIX)?;

    // Message Control (오프셋 +2)
    let msg_ctrl = unsafe { pci_read_config(bus, device, function, cap_offset + 2) };
    let table_size = (msg_ctrl & 0x7FF) as u16;  // 비트 10:0

    // Table Offset/BIR (오프셋 +4)
    let table_reg = unsafe { pci_read_config(bus, device, function, cap_offset + 4) };
    let table_bar = (table_reg & 0x7) as u8;
    let table_offset = table_reg & 0xFFFFFFF8;

    // PBA Offset/BIR (오프셋 +8)
    let pba_reg = unsafe { pci_read_config(bus, device, function, cap_offset + 8) };
    let pba_bar = (pba_reg & 0x7) as u8;
    let pba_offset = pba_reg & 0xFFFFFFF8;

    Some(MsixInfo { table_size, table_bar, table_offset, pba_bar, pba_offset })
}

설명

이것이 하는 일: Capability 순회 메커니즘은 PCI 디바이스가 지원하는 확장 기능을 동적으로 발견하고, 해당 기능의 레지스터 오프셋을 찾아 OS가 고급 기능을 활성화할 수 있게 합니다. 이는 MSI 인터럽트, 전력 관리, PCIe 링크 제어 같은 모든 현대 기능의 진입점입니다.

첫 번째 단계로, find_capability 함수는 오프셋 0x04의 Status 레지스터를 읽어 비트 4(Capabilities List)를 체크합니다. 이 비트가 0이면 디바이스가 Capability를 전혀 지원하지 않는 것이므로 즉시 None을 반환합니다.

오래된 PCI 2.0 디바이스는 이 비트가 0일 수 있고, 그런 경우 레거시 인터럽트와 폴링 방식으로만 작동합니다. 그 다음으로, 오프셋 0x34의 Capabilities Pointer를 읽어 첫 번째 Capability의 위치를 얻습니다.

PCI 명세에 따르면 이 포인터는 4바이트 정렬되어야 하므로 하위 2비트는 항상 0입니다. 정렬 검증을 통과하지 못하면 잘못된 구현이거나 하드웨어 오류이므로 안전하게 None을 반환합니다.

세 번째 단계에서 while 루프 대신 for 루프를 사용하여 최대 48회만 순회합니다. Configuration Space는 256바이트이고 각 Capability가 최소 4바이트이므로 이론적 최대 개수는 약 60개인데, 48회로 제한하면 순환 참조로 인한 무한 루프를 방지할 수 있습니다.

실제 디바이스는 보통 5-10개 정도의 Capability만 가지므로 48회면 충분합니다. 각 반복에서 현재 오프셋의 Capability 헤더를 읽습니다.

하위 8비트가 Capability ID로 기능 타입을 나타내고, 다음 8비트가 Next Pointer입니다. ID가 목표 cap_id와 일치하면 현재 오프셋을 반환하여 호출자가 해당 Capability의 상세 데이터를 읽을 수 있게 합니다.

Next가 0이면 리스트의 끝이므로 루프를 종료하고, 0이 아니지만 정렬이 맞지 않으면 손상된 데이터이므로 역시 종료합니다. read_msix_capability 예시는 실제 Capability 사용법을 보여줍니다.

MSI-X는 메시지 기반 인터럽트의 확장 버전으로, 수백 개의 인터럽트 벡터를 지원합니다. Message Control 레지스터의 비트 10:0이 Table Size(실제 개수는 +1)를 나타내고, Table Offset/BIR 레지스터는 인터럽트 테이블이 어느 BAR의 어느 오프셋에 있는지 알려줍니다.

예를 들어 BIR=4이면 BAR4의 메모리 영역에 MSI-X 테이블이 위치하므로, 드라이버는 BAR4를 매핑한 후 테이블을 초기화합니다. 여러분이 이 코드를 사용하면 디바이스 초기화 시 지원하는 인터럽트 방식을 확인할 수 있습니다.

먼저 MSI-X를 검색하고, 없으면 MSI를 검색하며, 둘 다 없으면 레거시 INTx 인터럽트를 사용하는 폴백 전략을 구현할 수 있습니다. 실무에서는 Power Management Capability(0x01)를 찾아 D0(Full Power) 상태로 전환하고, PCIe Capability(0x10)를 읽어 링크 속도와 폭을 확인하며, Vendor Specific Capability(0x09)로 제조사 고유 기능을 제어합니다.

실전 팁

💡 Capability 순회 중 중복 ID를 만날 수 있습니다(예: 여러 개의 Vendor Specific Capability). 첫 번째만 찾지 말고 모든 매칭 Capability를 벡터에 수집하는 find_all_capabilities 함수도 구현하세요.

💡 PCIe Extended Configuration Space(오프셋 256-4095)에는 Extended Capability 리스트가 별도로 존재하며, 오프셋 0x100부터 시작합니다. AER, ACS, ARI 같은 고급 기능은 여기에 있으므로 ECAM을 통해 추가 순회가 필요합니다.

💡 Capability 데이터를 읽기 전에 디바이스 Command 레지스터(오프셋 0x04)에서 Memory Space Enable이나 Bus Master Enable을 설정해야 할 수 있습니다. 일부 Capability는 비활성 상태에서 읽으면 0을 반환합니다.

💡 MSI/MSI-X는 프로세서 아키텍처 종속적이므로, x86의 APIC 주소(0xFEE00000)를 Message Address에 설정하고, 벡터 번호를 Message Data에 쓰는 플랫폼별 코드가 필요합니다.

💡 일부 에뮬레이터나 버그가 있는 하드웨어는 순환 참조를 포함한 Capability 리스트를 생성할 수 있으므로, 방문한 오프셋을 HashSet에 기록하여 중복 방문을 감지하는 것이 더 안전합니다.


7. PCI Command 레지스터로 디바이스 활성화

시작하며

여러분이 PCI 디바이스를 발견하고 BAR을 읽었는데, "메모리에 접근하려고 하면 응답이 없고, DMA도 작동하지 않는" 상황을 겪어본 적 있나요? 디바이스가 물리적으로 존재하고 Configuration Space도 정상인데 실제 데이터 전송이 안 되는 경우입니다.

이런 문제는 PCI 디바이스가 부팅 직후에는 "비활성" 상태로 시작하기 때문에 발생합니다. 보안과 안정성을 위해 펌웨어는 디바이스의 메모리 접근, I/O 포트 접근, DMA 등을 기본적으로 비활성화해두고, OS가 명시적으로 활성화해야 작동하도록 설계되어 있습니다.

바로 이럴 때 필요한 것이 PCI Command 레지스터입니다. Configuration Space 오프셋 0x04의 하위 16비트에 위치한 이 레지스터의 특정 비트를 설정하여 디바이스의 메모리 응답, I/O 응답, 버스 마스터(DMA) 기능을 활성화합니다.

개요

간단히 말해서, PCI Command 레지스터는 디바이스의 주요 기능(메모리 접근, I/O 접근, DMA, 인터럽트)을 on/off하는 16비트 제어 레지스터로, OS가 디바이스를 사용하기 전에 반드시 설정해야 합니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 디바이스가 기본적으로 비활성 상태이면 실수로 잘못된 주소에 접근하거나 초기화되지 않은 디바이스가 DMA로 메모리를 오염시키는 사고를 방지할 수 있습니다.

예를 들어, 드라이버가 로드되기 전까지 네트워크 카드가 임의의 패킷을 메모리에 쓰는 것을 막아 시스템 안정성을 보장합니다. 기존에는 모든 디바이스가 항상 활성화되어 있어 버스 충돌이나 의도치 않은 메모리 접근이 발생했다면, PCI는 명시적 활성화로 OS가 리소스 할당을 완료한 후 안전하게 디바이스를 켤 수 있게 합니다.

핵심 특징은 첫째, 비트 0(I/O Space Enable)으로 I/O 포트 접근을, 비트 1(Memory Space Enable)로 메모리 매핑 I/O 접근을 제어하고, 둘째, 비트 2(Bus Master Enable)로 DMA 기능을 활성화하며, 셋째, 비트 10(Interrupt Disable)으로 레거시 INTx 인터럽트를 마스크할 수 있다는 것입니다. 이러한 세밀한 제어가 단계적 초기화와 디버깅을 가능하게 합니다.

코드 예제

// Command 레지스터 비트 정의
const CMD_IO_SPACE: u16 = 1 << 0;           // I/O Space 접근 허용
const CMD_MEMORY_SPACE: u16 = 1 << 1;       // Memory Space 접근 허용
const CMD_BUS_MASTER: u16 = 1 << 2;         // DMA(버스 마스터) 허용
const CMD_SPECIAL_CYCLES: u16 = 1 << 3;     // Special Cycle 응답
const CMD_MWI_ENABLE: u16 = 1 << 4;         // Memory Write and Invalidate
const CMD_VGA_SNOOP: u16 = 1 << 5;          // VGA Palette Snoop
const CMD_PARITY_ERROR: u16 = 1 << 6;       // Parity Error Response
const CMD_SERR_ENABLE: u16 = 1 << 8;        // SERR# 드라이버 활성화
const CMD_FAST_B2B: u16 = 1 << 9;           // Fast Back-to-Back 허용
const CMD_INTERRUPT_DISABLE: u16 = 1 << 10; // INTx 인터럽트 비활성화

// Command 레지스터 읽기
unsafe fn read_command(bus: u8, device: u8, function: u8) -> u16 {
    (pci_read_config(bus, device, function, 0x04) & 0xFFFF) as u16
}

// Command 레지스터 쓰기
unsafe fn write_command(bus: u8, device: u8, function: u8, command: u16) {
    let status = pci_read_config(bus, device, function, 0x04) & 0xFFFF0000;
    pci_write_config(bus, device, function, 0x04, status | (command as u32));
}

// 디바이스 활성화 (메모리 + DMA)
pub fn enable_device(bus: u8, device: u8, function: u8) {
    unsafe {
        let mut cmd = read_command(bus, device, function);

        // 메모리 접근과 버스 마스터 활성화
        cmd |= CMD_MEMORY_SPACE | CMD_BUS_MASTER;

        // 레거시 인터럽트 비활성화 (MSI/MSI-X 사용 시)
        cmd |= CMD_INTERRUPT_DISABLE;

        write_command(bus, device, function, cmd);
    }
}

// I/O 포트 디바이스 활성화 (레거시 디바이스용)
pub fn enable_io_device(bus: u8, device: u8, function: u8) {
    unsafe {
        let mut cmd = read_command(bus, device, function);
        cmd |= CMD_IO_SPACE | CMD_BUS_MASTER;
        write_command(bus, device, function, cmd);
    }
}

// 디바이스 비활성화 (전력 절약이나 핫플러그 제거 시)
pub fn disable_device(bus: u8, device: u8, function: u8) {
    unsafe {
        let cmd = read_command(bus, device, function);
        // 모든 활성화 비트 제거
        let disabled = cmd & !(CMD_IO_SPACE | CMD_MEMORY_SPACE | CMD_BUS_MASTER);
        write_command(bus, device, function, disabled);
    }
}

설명

이것이 하는 일: Command 레지스터 제어 코드는 PCI 디바이스의 메모리 접근, I/O 접근, DMA 기능을 활성화하여 드라이버가 실제로 하드웨어와 통신할 수 있게 합니다. 이는 모든 디바이스 초기화의 필수 단계입니다.

첫 번째 단계로, read_command 함수는 오프셋 0x04의 32비트 레지스터를 읽어 하위 16비트만 추출합니다. 이 레지스터는 하위 16비트가 Command, 상위 16비트가 Status로 두 개의 레지스터가 합쳐진 형태입니다.

Status는 읽기 전용이고 일부 비트는 W1C(Write 1 to Clear)이므로, Command만 수정할 때 Status를 건드리지 않도록 주의해야 합니다. 그 다음으로, write_command 함수는 Status 레지스터(상위 16비트)를 보존하면서 Command만 업데이트합니다.

먼저 원래 32비트 값을 읽어 상위 16비트를 0xFFFF0000 마스크로 추출하고, 새 Command 값과 OR하여 다시 씁니다. 이렇게 하지 않고 Command만 쓰면 Status의 에러 비트들이 의도치 않게 클리어될 수 있어 진단 정보를 잃게 됩니다.

세 번째 단계에서 enable_device는 현대 PCI 디바이스의 표준 초기화 시퀀스를 수행합니다. CMD_MEMORY_SPACE를 설정하면 디바이스가 BAR에 할당된 메모리 주소에 대한 읽기/쓰기 요청에 응답하기 시작합니다.

이 비트 없이 메모리를 읽으면 PCI 버스에서 타임아웃이 발생하고 0xFFFFFFFF가 반환됩니다. CMD_BUS_MASTER를 설정하면 디바이스가 DMA를 수행할 수 있게 되는데, 이것 없이 DMA를 시도하면 PCI 버스가 요청을 거부하여 데이터 전송이 실패합니다.

추가로 CMD_INTERRUPT_DISABLE을 설정하는 이유는 MSI나 MSI-X를 사용할 때 레거시 INTx 인터럽트를 비활성화하기 위함입니다. PCI 명세에 따르면 MSI가 활성화되어도 INTx 핀이 여전히 신호를 보낼 수 있어, 두 인터럽트가 동시에 발생하면 혼란이 생길 수 있습니다.

이 비트로 INTx를 마스크하면 MSI만 사용하게 됩니다. enable_io_device는 레거시 디바이스나 일부 I/O 포트 기반 디바이스(예: 일부 직렬 포트 카드)를 위한 것입니다.

CMD_IO_SPACE를 설정하면 디바이스가 BAR에 할당된 I/O 포트 범위에 응답하게 됩니다. 현대 디바이스는 대부분 메모리 매핑 I/O를 사용하므로 이 비트는 거의 사용하지 않지만, 하위 호환성을 위해 일부 디바이스는 여전히 지원합니다.

disable_device는 디바이스를 안전하게 종료할 때 사용합니다. 핫플러그로 디바이스를 제거하기 전이나, 전력 절약을 위해 디바이스를 D3(저전력) 상태로 전환하기 전에 호출하여, DMA가 진행 중인 상태에서 메모리 매핑이 해제되는 사고를 방지합니다.

여러분이 이 코드를 사용하면 디바이스 드라이버의 probe 함수에서 BAR을 매핑한 직후, 실제 레지스터를 접근하기 직전에 enable_device를 호출하여 디바이스를 활성화할 수 있습니다. 실무에서는 활성화 후 BAR0의 첫 레지스터를 읽어 0xFFFFFFFF가 아닌 유효한 값이 나오는지 확인하여 활성화가 성공했는지 검증하고, 디바이스 제거 시 disable_device를 호출하여 클린업하는 패턴을 사용합니다.

실전 팁

💡 Command 레지스터 수정 전에 Status 레지스터의 Capabilities List 비트를 확인하여 디바이스가 현대 기능을 지원하는지 미리 체크하면, 레거시 디바이스에 불필요한 작업을 하지 않을 수 있습니다.

💡 Bus Master를 활성화하기 전에 DMA 버퍼와 디스크립터를 모두 설정하고, 디바이스 레지스터를 초기화해야 합니다. 순서가 바뀌면 디바이스가 초기화되지 않은 메모리에 DMA를 시도할 수 있습니다.

💡 일부 디바이스는 Command 레지스터를 쓴 후 수백 마이크로초의 지연이 필요합니다. 활성화 직후 레지스터를 읽으면 이전 값이 캐시되어 있을 수 있으므로, 한 번 더미 읽기를 수행하여 쓰기가 완료되도록 플러시하세요.

💡 VGA 디바이스는 CMD_VGA_SNOOP과 CMD_MEMORY_SPACE를 함께 설정해야 VGA 호환 모드(0xA0000-0xBFFFF)가 작동합니다. 일부 부팅 로그 출력이 화면에 표시되지 않으면 이 비트를 확인하세요.

💡 PCIe AER(Advanced Error Reporting)을 사용하려면 CMD_SERR_ENABLE을 설정하고 AER Extended Capability를 활성화해야 합니다. 이렇게 하면 PCIe 링크 에러를 커널 로그로 받을 수 있어 하드웨어 디버깅이 쉬워집니다.


8. 인터럽트 설정 - INTx부터 MSI-X까지

시작하며

여러분이 네트워크 카드에서 패킷이 도착했을 때 CPU에게 알려야 하는데, "폴링 방식은 CPU를 낭비하고, 인터럽트는 어떻게 설정하는지 복잡하다"는 고민을 해본 적 있나요? PCI는 세 가지 인터럽트 메커니즘(INTx, MSI, MSI-X)을 지원하는데, 각각 장단점이 다릅니다.

이런 문제는 레거시 INTx 인터럽트는 여러 디바이스가 공유하여 성능이 떨어지고, MSI는 벡터 개수가 제한적이며, MSI-X는 설정이 복잡하기 때문에 발생합니다. 현대 고성능 디바이스는 수십 개의 인터럽트 벡터를 필요로 하는데, 각 메커니즘을 언제 사용할지 결정하기 어렵습니다.

바로 이럴 때 필요한 것이 폴백 인터럽트 초기화 전략입니다. 먼저 MSI-X를 시도하고, 실패하면 MSI, 그것도 안 되면 INTx를 사용하는 계층적 접근으로 최대한 좋은 방법을 선택합니다.

개요

간단히 말해서, PCI 인터럽트는 디바이스가 CPU에게 이벤트를 알리는 방법으로, INTx(레거시 핀 기반), MSI(메시지 기반), MSI-X(확장 메시지 기반)의 세 가지 방식이 있으며, 뒤로 갈수록 성능과 유연성이 향상됩니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 고성능 디바이스는 여러 CPU 코어에 인터럽트를 분산하여 병렬 처리를 극대화해야 합니다.

예를 들어, 10Gbps 네트워크 카드는 각 수신 큐마다 별도의 인터럽트 벡터를 할당하여 여러 코어가 동시에 패킷을 처리하게 하는데, MSI-X로 2048개까지 벡터를 지원할 수 있지만 INTx는 단 하나뿐입니다. 기존에는 4개의 INTx 라인(INTA#, INTB#, INTC#, INTD#)을 모든 디바이스가 공유하여 인터럽트 핸들러가 모든 디바이스를 폴링해야 했다면, MSI는 메모리 쓰기로 인터럽트를 전달하여 공유 없이 전용 벡터를 사용하고, MSI-X는 각 벡터마다 독립적인 마스킹과 대상 CPU를 설정할 수 있습니다.

핵심 특징은 첫째, INTx는 설정이 간단하지만 공유로 인한 오버헤드가 크고, 둘째, MSI는 최대 32개 벡터를 지원하며 PCIe 트랜잭션으로 전달되어 지연이 낮으며, 셋째, MSI-X는 테이블 구조로 각 벡터의 주소/데이터/마스크를 독립적으로 설정하여 세밀한 제어가 가능하다는 것입니다. 이러한 진화가 현대 멀티코어 시스템의 I/O 성능을 지탱합니다.

코드 예제

// INTx 인터럽트 설정 (레거시)
fn setup_intx(bus: u8, device: u8, function: u8) -> Result<u8, &'static str> {
    // Interrupt Line 레지스터 읽기 (오프셋 0x3C)
    let int_reg = unsafe { pci_read_config(bus, device, function, 0x3C) };
    let interrupt_line = (int_reg & 0xFF) as u8;
    let interrupt_pin = ((int_reg >> 8) & 0xFF) as u8;

    if interrupt_pin == 0 {
        return Err("Device does not use INTx");
    }

    // IOAPIC나 PIC에 IRQ 등록 (플랫폼 의존적)
    // interrupt_line은 BIOS가 할당한 IRQ 번호 (예: 11)
    register_irq_handler(interrupt_line, device_irq_handler);

    Ok(interrupt_line)
}

// MSI Capability를 통한 MSI 설정
fn setup_msi(bus: u8, device: u8, function: u8, vector: u8) -> Result<(), &'static str> {
    let msi_cap = find_capability(bus, device, function, CAP_MSI)
        .ok_or("MSI not supported")?;

    // Message Control 읽기
    let msg_ctrl = unsafe { pci_read_config(bus, device, function, msi_cap + 2) };
    let is_64bit = (msg_ctrl >> 16) & (1 << 7) != 0;
    let multi_msg_capable = ((msg_ctrl >> 16) >> 1) & 0x7;

    // x86: APIC 메시지 주소 (모든 MSI가 사용)
    let message_addr: u32 = 0xFEE00000;  // Local APIC 베이스

    // Message Data: 벡터 번호
    let message_data: u32 = vector as u32;

    unsafe {
        // Message Address 쓰기 (오프셋 +4)
        pci_write_config(bus, device, function, msi_cap + 4, message_addr);

        if is_64bit {
            // 64비트 MSI: 상위 주소 쓰기 (오프셋 +8)
            pci_write_config(bus, device, function, msi_cap + 8, 0);
            // Message Data (오프셋 +12)
            pci_write_config(bus, device, function, msi_cap + 12, message_data);
        } else {
            // 32비트 MSI: Message Data (오프셋 +8)
            pci_write_config(bus, device, function, msi_cap + 8, message_data);
        }

        // MSI Enable (Message Control 비트 0)
        let new_ctrl = msg_ctrl | (1 << 16);
        pci_write_config(bus, device, function, msi_cap + 2, new_ctrl);
    }

    Ok(())
}

// MSI-X 테이블 엔트리 구조
#[repr(C)]
struct MsixTableEntry {
    msg_addr_low: u32,
    msg_addr_high: u32,
    msg_data: u32,
    vector_control: u32,  // 비트 0: Mask
}

// MSI-X 초기화 (간소화 버전)
fn setup_msix(bus: u8, device: u8, function: u8, vectors: &[(usize, u8)]) -> Result<(), &'static str> {
    let msix_info = read_msix_capability(bus, device, function)
        .ok_or("MSI-X not supported")?;

    // BAR에서 MSI-X 테이블 매핑
    let table_bar_addr = read_bar(bus, device, function, msix_info.table_bar as usize)
        .ok_or("Invalid MSI-X table BAR")?;
    let table_ptr = map_bar_memory(table_bar_addr, msix_info.table_offset);

    // 각 벡터 설정
    for &(index, cpu_vector) in vectors {
        if index > msix_info.table_size as usize {
            return Err("Vector index out of range");
        }

        let entry = unsafe { &mut *(table_ptr as *mut MsixTableEntry).add(index) };
        entry.msg_addr_low = 0xFEE00000;  // APIC 베이스
        entry.msg_addr_high = 0;
        entry.msg_data = cpu_vector as u32;
        entry.vector_control = 0;  // Unmask
    }

    // MSI-X Enable (Message Control 비트 15)
    let msix_cap = find_capability(bus, device, function, CAP_MSIX).unwrap();
    let msg_ctrl = unsafe { pci_read_config(bus, device, function, msix_cap + 2) };
    unsafe {
        pci_write_config(bus, device, function, msix_cap + 2, msg_ctrl | (1 << 31));
    }

    Ok(())
}

설명

이것이 하는 일: 인터럽트 초기화 코드는 PCI 디바이스가 이벤트 발생 시 CPU에게 알리는 메커니즘을 설정합니다. 세 가지 방식 중 시스템과 디바이스가 지원하는 최선의 방법을 선택하여 성능과 호환성을 모두 만족시킵니다.

첫 번째 단계로, setup_intx는 가장 기본적인 레거시 인터럽트를 설정합니다. 오프셋 0x3C의 Interrupt Line(BIOS가 할당한 IRQ 번호, 예: 11)과 Interrupt Pin(디바이스가 사용하는 핀, 1=INTA#, 2=INTB#, 3=INTC#, 4=INTD#)을 읽습니다.

Interrupt Pin이 0이면 디바이스가 INTx를 사용하지 않는 것입니다(MSI 전용 디바이스). IRQ 번호를 IOAPIC나 PIC에 등록하여 해당 인터럽트가 발생하면 드라이버의 핸들러가 호출되게 합니다.

INTx의 문제는 여러 디바이스가 같은 IRQ를 공유할 수 있어, 핸들러가 "이 인터럽트가 내 디바이스에서 온 건가?"를 확인해야 한다는 것입니다. 그 다음으로, setup_msi는 MSI Capability를 통해 메시지 기반 인터럽트를 설정합니다.

Message Control 레지스터를 읽어 64비트 주소 지원 여부와 Multi-Message Capability(최대 벡터 개수, 2^N 형태)를 확인합니다. x86 아키텍처에서는 Message Address를 0xFEE00000(Local APIC의 고정 주소)으로, Message Data를 IDT 벡터 번호로 설정합니다.

디바이스가 인터럽트를 발생시키면 이 주소에 메모리 쓰기를 수행하고, APIC가 이를 감지하여 해당 벡터의 인터럽트 핸들러를 호출합니다. MSI는 PCIe 트랜잭션으로 전달되므로 핀 기반 INTx보다 지연이 낮고, 전용 벡터를 사용하므로 공유 문제가 없습니다.

세 번째 단계에서 setup_msix는 가장 고급 메커니즘을 설정합니다. 먼저 MSI-X Capability에서 테이블 위치(BAR 번호와 오프셋)를 읽고, 해당 BAR을 메모리에 매핑하여 테이블에 접근합니다.

MSI-X 테이블은 배열 형태로 각 엔트리가 독립적인 Message Address, Message Data, Vector Control을 가집니다. 예를 들어 네트워크 카드가 8개의 수신 큐를 가진다면, 8개 엔트리를 설정하여 각 큐의 인터럽트를 다른 CPU 코어로 라우팅할 수 있습니다(Message Data에 각 코어의 Local APIC ID를 인코딩).

Vector Control의 Mask 비트로 개별 벡터를 일시적으로 비활성화할 수도 있습니다. MSI와 MSI-X의 Message Address 0xFEE00000은 x86 아키텍처 특정 값입니다.

비트 20-12는 Destination ID(어느 CPU), 비트 3은 Redirection Hint, 비트 2는 Destination Mode를 인코딩할 수 있습니다. 예를 들어 특정 CPU 코어에 인터럽트를 고정하려면 해당 코어의 APIC ID를 Destination 필드에 설정합니다.

Message Data는 벡터 번호 외에도 Delivery Mode(Fixed, Lowest Priority 등)와 Trigger Mode(Edge, Level)를 인코딩할 수 있지만, 대부분 Edge-triggered Fixed를 사용합니다. 마지막으로, 각 메커니즘을 활성화하는 Enable 비트를 설정합니다.

MSI는 Message Control 레지스터의 비트 0, MSI-X는 비트 15입니다. 활성화 전에 Command 레지스터의 Interrupt Disable(비트 10)을 1로 설정하여 INTx를 비활성화해야 MSI/MSI-X가 제대로 작동합니다.

일부 디바이스는 두 메커니즘을 동시에 사용하면 예측 불가능하게 동작합니다. 여러분이 이 코드를 사용하면 드라이버 초기화 시 setup_msixsetup_msisetup_intx 순으로 시도하는 폴백 로직을 구현할 수 있습니다.

실무에서는 MSI-X를 성공하면 num_cpus()만큼 벡터를 할당하여 각 CPU에 인터럽트를 분산하고, MSI만 가능하면 적은 벡터로 작동하며, INTx만 가능하면 단일 인터럽트 핸들러에서 모든 이벤트를 처리하는 구조로 설계합니다. 인터럽트 폭풍(같은 인터럽트가 빠르게 반복)을 방지하기 위해 NAPI(Linux) 같은 폴링 모드로 전환하는 하이브리드 접근도 고려하세요.

실전 팁

💡 MSI-X 테이블은 디바이스 메모리에 있으므로, 매핑 시 Uncacheable(UC) 속성으로 설정해야 합니다. Write-Back으로 매핑하면 CPU 캐시와 디바이스 상태가 불일치할 수 있습니다.

💡 Multi-Message MSI를 사용할 때는 벡터 개수가 2의 거듭제곱이어야 하며, 베이스 벡터가 해당 크기로 정렬되어야 합니다(예: 4개 벡터면 64, 68, 72, 76처럼 4의 배수부터 시작).

💡 MSI-X PBA(Pending Bit Array)는 마스크된 인터럽트가 발생했는지 추적하므로, 벡터를 언마스크할 때 PBA를 확인하여 밀린 인터럽트를 처리해야 손실을 방지할 수 있습니다.

💡 일부 칩셋은 MSI를 지원하지 않거나 특정 메모리 범위로만 제한합니다. ACPI _OSC 메서드로 OS가 MSI를 사용할 권한을 협상해야 하며, 실패하면 INTx로 폴백하세요.

💡 인터럽트 친화도(affinity)를 수동으로 설정하려면 각 MSI-X 엔트리의 Message Address에 타겟 CPU의 APIC ID를 인코딩하고, OS 인터럽트 밸런서를 비활성화해야 합니다. 이는 NUMA 시스템에서 지역성을 최대화하는 데 유용합니다.


9. DMA 버퍼 할당과 IOMMU 고려사항

시작하며

여러분이 네트워크 카드로 대용량 데이터를 전송하려는데, "CPU를 거치지 않고 메모리와 디바이스 간 직접 전송하려면 어떻게 해야 할까?"라는 고민을 해본 적 있나요? DMA(Direct Memory Access)는 필수인데, 물리 주소와 가상 주소의 차이, IOMMU의 영향 등 고려할 점이 많습니다.

이런 문제는 OS가 가상 메모리를 사용하는데 PCI 디바이스는 물리 주소만 이해하고, IOMMU가 활성화되면 물리 주소조차 변환되어 IOVA(I/O Virtual Address)를 사용해야 하기 때문에 발생합니다. 잘못된 주소로 DMA를 시작하면 메모리 오염이나 시스템 크래시가 발생할 수 있습니다.

바로 이럴 때 필요한 것이 DMA-safe 메모리 할당과 주소 변환 메커니즘입니다. 연속된 물리 메모리를 할당하고, IOMMU가 있으면 매핑하여 IOVA를 얻으며, 디바이스에게는 이 주소를 전달하여 안전한 DMA를 보장합니다.

개요

간단히 말해서, DMA 버퍼는 디바이스가 직접 접근할 수 있는 메모리 영역으로, 물리적으로 연속되어야 하며, IOMMU가 있는 경우 I/O 페이지 테이블에 매핑하여 IOVA를 얻어 디바이스에 전달해야 합니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 고성능 I/O는 CPU 개입 없이 디바이스와 메모리 간 직접 전송으로 달성되는데, 이를 위해 디바이스가 읽고 쓸 메모리의 정확한 물리 주소(또는 IOVA)를 알아야 합니다.

예를 들어, NVMe SSD에 1MB


#Rust#PCI#DeviceDriver#OSdev#SystemProgramming#시스템프로그래밍

댓글 (0)

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