이미지 로딩 중...

Rust로 만드는 나만의 OS 네트워크 카드 기초 - 슬라이드 1/10
A

AI Generated

2025. 11. 14. · 4 Views

Rust로 만드는 나만의 OS 네트워크 카드 기초

OS 개발에서 네트워크 통신을 구현하기 위한 첫걸음, 네트워크 카드(NIC) 드라이버 개발의 핵심 개념을 다룹니다. PCI 디바이스 초기화부터 패킷 송수신까지, 실제 하드웨어와 소통하는 방법을 Rust로 구현합니다.


목차

  1. PCI 디바이스 검색 - 네트워크 카드 찾기
  2. BAR 레지스터 읽기 - 메모리 매핑 주소 얻기
  3. DMA 버퍼 할당 - 고성능 데이터 전송
  4. 수신 디스크립터 링 설정 - 패킷 수신 준비
  5. 송신 디스크립터 링 설정 - 패킷 전송 준비
  6. 인터럽트 핸들러 등록 - 패킷 도착 알림
  7. 패킷 수신 처리 - 수신 링에서 패킷 읽기
  8. 패킷 송신 완료 처리 - 송신 버퍼 회수
  9. ARP 프로토콜 구현 - IP-MAC 주소 매핑

1. PCI 디바이스 검색 - 네트워크 카드 찾기

시작하며

여러분이 자신만의 OS를 만들다가 "이제 네트워크 통신을 해야 하는데, 네트워크 카드가 어디 있지?"라는 고민을 하신 적 있나요? 시스템에 연결된 수많은 하드웨어 중에서 정확히 네트워크 카드를 찾아내는 것은 OS 개발의 첫 번째 관문입니다.

이런 문제는 베어메탈 프로그래밍에서 반드시 해결해야 하는 과제입니다. OS가 하드웨어를 인식하지 못하면 아무것도 할 수 없죠.

특히 네트워크 카드는 PCI 버스 상의 여러 디바이스 중 하나로 존재하기 때문에, PCI 버스를 스캔하고 특정 벤더/디바이스 ID를 찾아내는 과정이 필요합니다. 바로 이럴 때 필요한 것이 PCI 디바이스 검색입니다.

PCI Configuration Space를 읽어 각 디바이스의 정보를 파악하고, 우리가 원하는 네트워크 카드를 식별할 수 있습니다.

개요

간단히 말해서, PCI 디바이스 검색은 PCI 버스에 연결된 모든 하드웨어를 순회하면서 각 디바이스의 고유 식별자를 확인하는 과정입니다. 왜 이 과정이 필요한지 실무 관점에서 보면, OS 커널은 부팅 시 자동으로 모든 하드웨어를 탐지하고 적절한 드라이버를 로드해야 합니다.

예를 들어, Intel의 E1000 네트워크 카드와 Realtek의 RTL8139는 완전히 다른 방식으로 제어되기 때문에, 먼저 "어떤 카드인가"를 정확히 알아야 올바른 초기화 코드를 실행할 수 있습니다. 전통적인 방법으로는 BIOS나 UEFI가 제공하는 정보를 사용했다면, 이제는 직접 PCI Configuration Space의 레지스터를 읽어 벤더 ID와 디바이스 ID를 확인합니다.

PCI 검색의 핵심 특징은 첫째, 버스-디바이스-펑션(BDF) 구조로 계층적 탐색이 가능하고, 둘째, Configuration Space의 표준화된 레지스터 레이아웃 덕분에 모든 PCI 디바이스를 통일된 방법으로 조회할 수 있으며, 셋째, 벤더 ID(16비트)와 디바이스 ID(16비트)의 조합으로 전 세계의 모든 하드웨어를 고유하게 식별할 수 있다는 점입니다. 이러한 특징들이 중요한 이유는 OS가 플러그 앤 플레이 방식으로 다양한 하드웨어를 자동 인식할 수 있게 해주기 때문입니다.

코드 예제

// PCI Configuration Space의 표준 오프셋
const PCI_VENDOR_ID: u32 = 0x00;
const PCI_DEVICE_ID: u32 = 0x02;

// Intel E1000 네트워크 카드의 식별자
const INTEL_VENDOR_ID: u16 = 0x8086;
const E1000_DEVICE_ID: u16 = 0x100E;

fn scan_pci_bus() -> Option<PciDevice> {
    // 버스 0~255, 디바이스 0~31, 펑션 0~7 순회
    for bus in 0..256 {
        for device in 0..32 {
            for function in 0..8 {
                let vendor_id = pci_read_config_word(bus, device, function, PCI_VENDOR_ID);
                if vendor_id == INTEL_VENDOR_ID {
                    let device_id = pci_read_config_word(bus, device, function, PCI_DEVICE_ID);
                    if device_id == E1000_DEVICE_ID {
                        // E1000 네트워크 카드 발견!
                        return Some(PciDevice::new(bus, device, function));
                    }
                }
            }
        }
    }
    None
}

설명

이것이 하는 일: 위 코드는 PCI 버스의 모든 슬롯을 체계적으로 스캔하면서 Intel E1000 네트워크 카드를 찾아내는 과정입니다. 삼중 루프를 통해 가능한 모든 BDF 조합을 확인하죠.

첫 번째 단계로, 각 BDF 위치에서 pci_read_config_word 함수를 호출해 벤더 ID를 읽어옵니다. 이 함수는 내부적으로 I/O 포트 0xCF8과 0xCFC를 사용해 PCI Configuration Space에 접근합니다.

벤더 ID가 0xFFFF라면 해당 슬롯에 디바이스가 없다는 의미이므로 건너뜁니다. 이렇게 하는 이유는 존재하지 않는 디바이스를 계속 검사하는 것이 비효율적이기 때문입니다.

두 번째 단계로, 벤더 ID가 Intel(0x8086)과 일치하면 디바이스 ID를 추가로 확인합니다. 벤더 ID만으로는 "Intel 제품이다"라는 것만 알 수 있고, 디바이스 ID가 0x100E여야 비로소 "E1000 네트워크 카드"라고 확정할 수 있습니다.

Intel은 수백 가지의 제품을 만들기 때문에 두 단계 검증이 필수입니다. 마지막 단계에서, 일치하는 디바이스를 찾으면 해당 BDF 정보를 담은 PciDevice 구조체를 반환합니다.

이후 이 구조체를 통해 BAR(Base Address Register) 읽기, 인터럽트 설정, DMA 버퍼 할당 등 추가 초기화 작업을 수행하게 됩니다. 여러분이 이 코드를 사용하면 시스템에 장착된 네트워크 카드를 자동으로 탐지할 수 있습니다.

실무에서의 이점으로는 첫째, 하드코딩된 주소 없이 동적으로 하드웨어를 발견할 수 있고, 둘째, 여러 종류의 네트워크 카드를 지원하도록 확장 가능하며, 셋째, 핫플러그 시나리오에서도 재스캔을 통해 새로운 디바이스를 인식할 수 있다는 점입니다.

실전 팁

💡 벤더 ID가 0xFFFF인 경우 해당 슬롯이 비어있다는 의미이므로 즉시 continue로 건너뛰어 성능을 최적화하세요. 모든 슬롯을 다 확인하면 부팅 시간이 수초 지연될 수 있습니다.

💡 멀티펑션 디바이스인지 확인하려면 헤더 타입(오프셋 0x0E)의 비트 7을 체크하세요. 이 비트가 0이면 펑션 0만 검사하면 되므로 불필요한 반복을 줄일 수 있습니다.

💡 실제 프로덕션 코드에서는 클래스 코드(오프셋 0x0B)도 함께 확인해 네트워크 컨트롤러(0x02)인지 검증하세요. 같은 벤더/디바이스 ID를 가진 다른 종류의 하드웨어가 존재할 수 있습니다.

💡 PCI Express 시스템에서는 MMIO 기반의 Enhanced Configuration Space를 사용할 수 있습니다. ACPI의 MCFG 테이블에서 베이스 주소를 얻어 직접 메모리 맵핑하면 I/O 포트보다 훨씬 빠릅니다.

💡 디버깅 시에는 모든 발견된 디바이스의 벤더/디바이스 ID를 로그로 출력하세요. 예상치 못한 하드웨어나 가상화 환경에서의 에뮬레이션 이슈를 빠르게 파악할 수 있습니다.


2. BAR 레지스터 읽기 - 메모리 매핑 주소 얻기

시작하며

여러분이 네트워크 카드를 찾았다면 다음 질문은 "이 카드와 어떻게 대화하지?"입니다. 하드웨어는 메모리 공간이나 I/O 공간의 특정 주소에 자신의 레지스터를 노출하는데, 그 주소가 어디인지 알아야 데이터를 읽고 쓸 수 있습니다.

이 문제는 플러그 앤 플레이 시스템의 핵심 과제입니다. 과거에는 하드웨어마다 고정된 주소를 사용했지만, 이제는 BIOS/UEFI가 부팅 시 동적으로 주소를 할당합니다.

따라서 OS는 할당된 주소를 런타임에 알아내야 하며, 잘못된 주소에 접근하면 시스템 크래시나 데이터 손상이 발생할 수 있습니다. 바로 이럴 때 필요한 것이 BAR(Base Address Register) 읽기입니다.

PCI Configuration Space의 BAR 레지스터들을 읽으면 하드웨어가 사용하는 메모리 영역의 시작 주소와 크기를 정확히 알 수 있습니다.

개요

간단히 말해서, BAR는 PCI 디바이스의 레지스터와 메모리가 시스템 메모리 공간 어디에 매핑되어 있는지를 알려주는 Configuration Space의 특별한 레지스터입니다. 왜 이 개념이 필요한지 실무 관점에서 보면, 네트워크 카드의 송신 큐, 수신 큐, 제어 레지스터, 통계 카운터 등 수백 개의 레지스터에 접근하려면 그 시작 주소를 알아야 합니다.

예를 들어, E1000 카드의 경우 BAR0에 128KB 크기의 메모리 매핑 레지스터 공간이 있고, 여기서 오프셋 0x0000은 제어 레지스터, 0x0100은 인터럽트 마스크 같은 식으로 정의되어 있습니다. 기존에는 ioremap 같은 커널 함수로 물리 주소를 가상 주소에 매핑했다면, Rust OS에서는 페이지 테이블을 직접 조작하거나 volatile 읽기/쓰기로 안전하게 접근합니다.

BAR의 핵심 특징은 첫째, 최대 6개의 BAR(BAR0~BAR5)을 지원해 여러 종류의 리소스를 분리할 수 있고, 둘째, 메모리 공간(MMIO)과 I/O 공간(PIO)을 구분하는 플래그가 있으며, 셋째, 64비트 주소 지정이 가능해 4GB 이상의 물리 메모리에도 매핑할 수 있다는 점입니다. 이러한 특징들이 중요한 이유는 현대의 고성능 네트워크 카드가 기가바이트 단위의 버퍼를 사용하거나, NUMA 시스템에서 특정 메모리 노드에 배치되어야 하는 경우가 많기 때문입니다.

코드 예제

// BAR 레지스터는 오프셋 0x10부터 시작
const PCI_BAR0: u32 = 0x10;

struct BarInfo {
    base_address: u64,
    size: u64,
    is_mmio: bool,
    is_64bit: bool,
}

fn read_bar(bus: u8, device: u8, function: u8, bar_num: u8) -> Option<BarInfo> {
    let offset = PCI_BAR0 + (bar_num as u32 * 4);

    // BAR 값 읽기
    let bar_value = pci_read_config_dword(bus, device, function, offset);

    if bar_value == 0 {
        return None; // 사용되지 않는 BAR
    }

    // 비트 0: 0이면 MMIO, 1이면 I/O
    let is_mmio = (bar_value & 0x1) == 0;
    let is_64bit = is_mmio && ((bar_value >> 1) & 0x3) == 2;

    // 크기 계산: BAR에 0xFFFFFFFF 쓰고 다시 읽기
    pci_write_config_dword(bus, device, function, offset, 0xFFFFFFFF);
    let size_mask = pci_read_config_dword(bus, device, function, offset);
    pci_write_config_dword(bus, device, function, offset, bar_value); // 원래 값 복구

    let size = !(size_mask & !0xF) + 1;
    let base_address = (bar_value & !0xF) as u64;

    Some(BarInfo { base_address, size: size as u64, is_mmio, is_64bit })
}

설명

이것이 하는 일: 이 코드는 PCI Configuration Space의 BAR 레지스터를 읽어서 네트워크 카드 레지스터의 물리 주소, 크기, 타입(MMIO/PIO) 정보를 추출합니다. 추가로 크기를 계산하는 트릭도 포함되어 있죠.

첫 번째 단계로, BAR 레지스터의 위치를 계산합니다. BAR0은 오프셋 0x10에 있고 각 BAR는 4바이트씩 떨어져 있으므로 PCI_BAR0 + (bar_num * 4)로 접근합니다.

읽은 값이 0이면 해당 BAR가 사용되지 않는다는 의미이므로 None을 반환합니다. 이렇게 하는 이유는 모든 디바이스가 6개의 BAR를 전부 사용하는 것은 아니기 때문입니다.

두 번째 단계로, BAR 값의 하위 비트들을 파싱해 타입을 판단합니다. 비트 0이 0이면 메모리 공간(MMIO), 1이면 I/O 공간입니다.

MMIO의 경우 비트 2:1을 확인해 00이면 32비트 주소, 10이면 64비트 주소입니다. 64비트 BAR는 실제로 연속된 두 개의 BAR 레지스터를 사용하므로, 다음 BAR도 함께 읽어야 전체 주소를 구성할 수 있습니다.

크기 계산의 트릭이 세 번째 단계입니다. PCI 스펙에 따르면, BAR에 모든 비트가 1인 값(0xFFFFFFFF)을 쓴 후 다시 읽으면, 하드웨어가 요청하는 메모리 크기에 해당하는 마스크 값을 반환합니다.

예를 들어 128KB를 원한다면 0xFFFE0000이 반환되고, 비트 반전 후 1을 더해 크기를 계산합니다. 원래 값을 복구하는 것을 잊지 말아야 하는데, 그렇지 않으면 디바이스가 잘못된 주소를 사용하게 됩니다.

마지막으로, 하위 4비트는 플래그이므로 bar_value & !0xF로 마스킹해 실제 주소만 추출합니다. 64비트 BAR의 경우 상위 32비트를 다음 BAR에서 읽어 조합해야 하지만, 이 예제에서는 간단히 하위 32비트만 다룹니다.

여러분이 이 코드를 사용하면 런타임에 동적으로 할당된 하드웨어 주소를 정확히 얻을 수 있습니다. 실무에서의 이점으로는 첫째, 다양한 시스템 구성에서 동작하는 이식성 높은 드라이버를 작성할 수 있고, 둘째, 메모리 영역의 크기를 알아서 페이지 테이블 매핑을 정확히 설정할 수 있으며, 셋째, MMIO vs PIO를 구분해 적절한 접근 방식을 선택할 수 있다는 점입니다.

실전 팁

💡 BAR 값을 읽고 복구하는 과정에서 인터럽트를 비활성화하세요. 멀티코어 환경에서 다른 코어가 동시에 BAR를 변경하면 데이터 레이스가 발생합니다. 스핀락이나 뮤텍스로 보호하세요.

💡 64비트 BAR를 처리할 때는 BAR[n]과 BAR[n+1]을 함께 읽어 (bar_high << 32) | bar_low 형태로 조합해야 합니다. 상위 32비트를 무시하면 4GB 이상의 주소에서 오작동합니다.

💡 크기 계산 후 반드시 원래 값을 복구하세요. 일부 하드웨어는 BAR가 변경되면 즉시 새 주소로 동작을 시작하므로, 복구하지 않으면 시스템이 멈출 수 있습니다.

💡 MMIO 주소를 사용하기 전에 페이지 테이블에 캐시 비활성화(Cache Disable, CD) 플래그를 설정하세요. 하드웨어 레지스터는 CPU 캐시와 일관성이 없으므로, 캐시를 사용하면 오래된 값을 읽거나 쓰기가 누락될 수 있습니다.

💡 가상화 환경(QEMU, VirtualBox)에서는 BAR 주소가 매우 낮은 범위(예: 0xC0000000)에 할당될 수 있습니다. 실제 하드웨어와 다른 동작을 보일 수 있으니, 로그를 통해 BAR 값을 확인하며 테스트하세요.


3. DMA 버퍼 할당 - 고성능 데이터 전송

시작하며

여러분이 네트워크 카드와 통신 준비를 마쳤다면, 이제 실제로 데이터를 주고받아야 합니다. 그런데 CPU가 일일이 패킷 데이터를 복사한다면 어떻게 될까요?

1Gbps 네트워크라면 초당 125MB, 즉 125,000,000바이트를 CPU가 직접 옮겨야 합니다. 이건 엄청난 낭비죠.

이런 성능 문제는 고속 I/O에서 치명적입니다. CPU는 패킷 처리나 애플리케이션 로직 같은 중요한 일을 해야 하는데, 단순 메모리 복사에 시간을 낭비하면 시스템 전체 처리량이 떨어집니다.

또한 메모리 복사 중에는 다른 작업을 하기 어려우므로 레이턴시도 증가하게 됩니다. 바로 이럴 때 필요한 것이 DMA(Direct Memory Access)입니다.

네트워크 카드가 CPU의 개입 없이 직접 메모리에 접근하도록 하면, CPU는 해방되고 데이터 전송은 하드웨어 레벨에서 병렬로 처리됩니다.

개요

간단히 말해서, DMA는 주변장치가 CPU를 거치지 않고 메모리에 직접 읽기/쓰기를 수행하는 메커니즘으로, 이를 위해 OS는 물리적으로 연속된 메모리 버퍼를 할당하고 그 물리 주소를 하드웨어에게 알려줘야 합니다. 왜 이 개념이 필요한지 실무 관점에서 보면, 네트워크 카드는 가상 주소를 이해하지 못하고 오직 물리 주소만 다룹니다.

예를 들어, 수신 디스크립터 링에 패킷 버퍼의 물리 주소를 등록해두면, 패킷이 도착할 때 네트워크 카드가 자동으로 해당 메모리에 데이터를 써넣습니다. 이 과정에서 CPU는 완전히 자유롭죠.

기존에는 kmalloc이나 페이지 할당자를 사용해 메모리를 받고 virt_to_phys로 변환했다면, Rust OS에서는 프레임 할당자에서 직접 물리 프레임을 받아 가상 주소로 매핑하는 방식으로 구현합니다. DMA의 핵심 특징은 첫째, 물리적으로 연속된 메모리가 필요하므로 조각난 메모리를 사용할 수 없고, 둘째, IOMMU(Input-Output Memory Management Unit)가 있다면 물리 연속성 제약을 완화할 수 있으며, 셋째, 캐시 일관성(Cache Coherence) 문제를 해결해야 한다는 점입니다.

이러한 특징들이 중요한 이유는 잘못 설정하면 데이터 손상이나 시스템 크래시가 발생하기 때문입니다. CPU 캐시와 메모리의 데이터가 불일치하면 네트워크 카드가 오래된 데이터를 읽거나, 새로 쓴 데이터를 CPU가 보지 못할 수 있습니다.

코드 예제

use core::alloc::Layout;

// 물리 주소 타입
#[repr(transparent)]
struct PhysAddr(u64);

// DMA 버퍼 구조체
struct DmaBuffer {
    virt_addr: *mut u8,
    phys_addr: PhysAddr,
    size: usize,
}

impl DmaBuffer {
    fn allocate(size: usize) -> Result<Self, &'static str> {
        // 페이지 경계에 정렬된 크기로 올림
        let aligned_size = (size + 4095) & !4095;

        // 물리 프레임 할당자에서 연속된 프레임 할당
        let phys_addr = allocate_contiguous_frames(aligned_size / 4096)?;

        // 가상 주소 공간에 매핑 (캐시 비활성화)
        let virt_addr = map_physical_memory(phys_addr, aligned_size, PageFlags::NO_CACHE)?;

        Ok(DmaBuffer {
            virt_addr: virt_addr as *mut u8,
            phys_addr,
            size: aligned_size,
        })
    }

    // 네트워크 카드에게 줄 물리 주소
    fn physical_address(&self) -> u64 {
        self.phys_addr.0
    }

    // CPU가 접근할 가상 주소
    fn as_slice(&self) -> &[u8] {
        unsafe { core::slice::from_raw_parts(self.virt_addr, self.size) }
    }
}

설명

이것이 하는 일: 이 코드는 네트워크 카드가 DMA로 사용할 수 있는 메모리 버퍼를 할당하고, CPU와 하드웨어 모두가 접근할 수 있도록 가상/물리 주소 매핑을 설정합니다. 캐시 일관성 문제도 해결하죠.

첫 번째 단계로, 요청된 크기를 페이지 경계(4KB)로 정렬합니다. (size + 4095) & !4095는 올림 정렬을 수행하는 비트 연산 트릭입니다.

예를 들어 5000바이트를 요청하면 8192바이트(2 페이지)로 올라갑니다. 페이지 단위로 할당하는 이유는 페이지 테이블이 페이지 단위로 동작하고, 하드웨어 DMA도 페이지 정렬을 선호하기 때문입니다.

두 번째 단계로, allocate_contiguous_frames 함수를 호출해 물리적으로 연속된 메모리 프레임을 할당받습니다. 이 함수는 버디 할당자(buddy allocator)나 슬랩 할당자를 사용해 구현되며, 실패하면 조각화된 메모리 상태에서 큰 블록을 찾지 못했다는 의미입니다.

물리 연속성이 중요한 이유는 네트워크 카드가 단일 디스크립터로 대량의 데이터를 전송할 수 있기 때문입니다. Scatter-Gather를 지원하는 카드라면 연속성이 필수는 아니지만, 성능상 유리합니다.

세 번째 단계로, 획득한 물리 주소를 커널의 가상 주소 공간에 매핑합니다. 여기서 핵심은 PageFlags::NO_CACHE 플래그입니다.

이를 설정하면 CPU가 이 메모리 영역을 캐시하지 않고 항상 메모리에서 직접 읽습니다. 캐시를 비활성화하지 않으면 CPU가 캐시된 오래된 값을 읽거나, CPU가 쓴 값이 메모리에 반영되기 전에 네트워크 카드가 읽어가는 문제가 발생할 수 있습니다.

마지막으로, DmaBuffer 구조체는 두 가지 인터페이스를 제공합니다. physical_address()는 네트워크 카드 레지스터에 쓸 물리 주소를 반환하고, as_slice()는 CPU가 데이터를 읽고 쓸 수 있는 안전한 슬라이스를 제공합니다.

이렇게 분리하는 이유는 가상 주소와 물리 주소를 혼동하는 실수를 방지하기 위함입니다. 여러분이 이 코드를 사용하면 제로카피(zero-copy) 네트워크 스택을 구현할 수 있습니다.

실무에서의 이점으로는 첫째, CPU 사용률이 크게 감소해 다른 작업에 리소스를 사용할 수 있고, 둘째, 메모리 복사 오버헤드가 없어 처리량과 레이턴시가 개선되며, 셋째, 대용량 데이터 전송 시에도 안정적으로 동작한다는 점입니다.

실전 팁

💡 DMA 버퍼는 반드시 드롭(drop) 시 메모리를 해제하도록 Drop 트레이트를 구현하세요. 물리 메모리 누수는 시스템 전체를 불안정하게 만들 수 있으며, 재부팅 전까지 복구할 수 없습니다.

💡 x86_64에서는 MTRR(Memory Type Range Register)이나 PAT(Page Attribute Table)을 통해 캐시 정책을 제어할 수 있습니다. NO_CACHE 플래그를 설정할 때 내부적으로 PAT를 사용하도록 구현하면 세밀한 제어가 가능합니다.

💡 32비트 주소만 지원하는 구형 네트워크 카드를 위해 4GB 이하의 물리 메모리에서 할당하는 옵션을 추가하세요. 최신 서버는 수백 GB의 RAM을 가지므로, 제약 없이 할당하면 4GB 이상의 주소가 나올 수 있습니다.

💡 IOMMU가 활성화된 시스템에서는 물리 주소 대신 IOMMU가 제공하는 디바이스 주소(DMA 주소)를 사용해야 합니다. IOMMU를 통하면 물리적으로 비연속적인 페이지를 연속된 DMA 주소로 매핑할 수 있어 메모리 조각화 문제가 완화됩니다.

💡 송신/수신 디스크립터 링 자체도 DMA 버퍼로 할당해야 합니다. 네트워크 카드가 디스크립터를 읽고 쓰기 때문에, 일반 커널 메모리에 할당하면 접근할 수 없습니다. 보통 4KB 정도면 충분합니다.


4. 수신 디스크립터 링 설정 - 패킷 수신 준비

시작하며

여러분이 DMA 버퍼까지 준비했다면, 이제 네트워크 카드에게 "패킷이 도착하면 이 버퍼에 써줘"라고 알려줘야 합니다. 하지만 버퍼가 하나뿐이라면?

한 번에 하나의 패킷만 받을 수 있어 성능이 형편없겠죠. 이런 병목 현상은 고속 네트워크에서 치명적입니다.

1Gbps 네트워크에서 1500바이트 패킷은 약 12마이크로초마다 하나씩 도착합니다. CPU가 인터럽트를 처리하는 동안 다음 패킷이 도착하면, 버퍼가 하나뿐이므로 패킷이 손실됩니다.

이것을 방지하려면 여러 개의 버퍼를 미리 준비해야 합니다. 바로 이럴 때 필요한 것이 수신 디스크립터 링(RX Descriptor Ring)입니다.

네트워크 카드가 순환적으로 사용할 수 있는 디스크립터들의 배열을 만들고, 각 디스크립터는 하나의 패킷 버퍼를 가리킵니다.

개요

간단히 말해서, 수신 디스크립터 링은 네트워크 카드와 OS가 공유하는 원형 큐로, 각 엔트리(디스크립터)가 패킷을 받을 버퍼의 물리 주소와 상태 정보를 담고 있습니다. 왜 이 개념이 필요한지 실무 관점에서 보면, 링 구조 덕분에 네트워크 카드와 OS가 프로듀서-컨슈머 패턴으로 협업할 수 있습니다.

예를 들어, 네트워크 카드는 head 인덱스를 증가시키며 패킷을 쓰고, OS는 tail 인덱스를 증가시키며 패킷을 읽고 버퍼를 재활용합니다. 링이 가득 차기 전에는 패킷 손실 없이 연속적으로 수신할 수 있죠.

기존의 단순한 버퍼 배열 방식에서는 인덱스 관리가 복잡하고 경계 조건 처리가 어려웠다면, 링 버퍼는 모듈로 연산으로 자동으로 순환하므로 코드가 간결하고 버그가 적습니다. 수신 디스크립터 링의 핵심 특징은 첫째, 2의 거듭제곱 크기(256, 512 등)를 사용해 비트 마스킹으로 빠른 모듈로 연산이 가능하고, 둘째, 각 디스크립터에 DD(Descriptor Done) 플래그가 있어 네트워크 카드가 쓰기를 완료했는지 확인할 수 있으며, 셋째, 멀티큐 NIC에서는 여러 개의 링을 사용해 CPU 코어별로 병렬 처리가 가능하다는 점입니다.

이러한 특징들이 중요한 이유는 현대 네트워크 카드가 멀티기가비트 속도를 지원하고, 수백만 PPS(Packets Per Second)를 처리해야 하기 때문입니다.

코드 예제

// E1000 수신 디스크립터 구조 (16바이트)
#[repr(C, packed)]
struct RxDescriptor {
    buffer_addr: u64,  // 패킷 버퍼의 물리 주소
    length: u16,       // 수신된 패킷의 길이
    checksum: u16,     // 체크섬
    status: u8,        // DD 플래그 등
    errors: u8,        // 에러 플래그
    special: u16,      // VLAN 태그 등
}

const RX_RING_SIZE: usize = 256;
const DD_FLAG: u8 = 0x01; // Descriptor Done

struct RxRing {
    descriptors: DmaBuffer, // 디스크립터 링 자체
    buffers: Vec<DmaBuffer>, // 각 디스크립터의 패킷 버퍼
    tail: usize, // OS가 읽을 다음 위치
}

impl RxRing {
    fn initialize(nic_base: u64) -> Result<Self, &'static str> {
        // 디스크립터 링 할당
        let descriptors = DmaBuffer::allocate(RX_RING_SIZE * 16)?;
        let desc_ptr = descriptors.virt_addr as *mut RxDescriptor;

        // 각 디스크립터에 패킷 버퍼 할당
        let mut buffers = Vec::new();
        for i in 0..RX_RING_SIZE {
            let buffer = DmaBuffer::allocate(2048)?; // 2KB 패킷 버퍼
            unsafe {
                (*desc_ptr.add(i)).buffer_addr = buffer.physical_address();
                (*desc_ptr.add(i)).status = 0; // 네트워크 카드가 사용 가능
            }
            buffers.push(buffer);
        }

        // 네트워크 카드 레지스터에 링 설정
        write_register(nic_base, RDBAL, (descriptors.physical_address() & 0xFFFFFFFF) as u32);
        write_register(nic_base, RDBAH, (descriptors.physical_address() >> 32) as u32);
        write_register(nic_base, RDLEN, (RX_RING_SIZE * 16) as u32);
        write_register(nic_base, RDH, 0); // Head = 0
        write_register(nic_base, RDT, RX_RING_SIZE as u32 - 1); // Tail = 마지막

        Ok(RxRing { descriptors, buffers, tail: 0 })
    }
}

설명

이것이 하는 일: 이 코드는 256개의 수신 디스크립터로 구성된 링을 생성하고, 각 디스크립터에 2KB 패킷 버퍼를 연결한 후, 네트워크 카드 레지스터에 링의 위치와 크기를 알려줍니다. 첫 번째 단계로, 디스크립터 링 자체를 위한 메모리를 할당합니다.

256개 × 16바이트 = 4KB가 필요하며, 이것도 DMA 버퍼로 할당해야 네트워크 카드가 접근할 수 있습니다. 디스크립터 링은 네트워크 카드가 읽고 쓰기 때문에, 일반 커널 힙 메모리에 할당하면 안 됩니다.

RxDescriptor 구조체는 #[repr(C, packed)]로 정의되어 컴파일러가 패딩을 추가하지 않고 정확히 16바이트로 유지됩니다. 두 번째 단계에서, 각 디스크립터에 대해 2KB 크기의 패킷 버퍼를 할당하고 그 물리 주소를 디스크립터에 씁니다.

2KB를 선택한 이유는 이더넷 MTU가 1500바이트이고 추가 헤더를 고려하면 2KB면 충분하기 때문입니다. 점보 프레임을 지원하려면 9KB 버퍼가 필요합니다.

status 필드를 0으로 설정하면 "이 디스크립터는 비어있고, 네트워크 카드가 사용할 수 있다"는 의미입니다. 세 번째 단계로, 네트워크 카드의 레지스터에 링 정보를 씁니다.

RDBAL/RDBAH는 링의 물리 주소(64비트를 상위/하위로 분할), RDLEN은 링의 총 바이트 크기, RDH(Receive Descriptor Head)는 네트워크 카드가 다음에 쓸 위치, RDT(Receive Descriptor Tail)는 OS가 처리한 마지막 위치입니다. Tail을 마지막 디스크립터로 설정하면 "모든 디스크립터를 사용해도 좋다"는 의미입니다.

내부 동작 메커니즘을 보면, 패킷이 도착하면 네트워크 카드가 RDH 위치의 디스크립터를 읽고, 해당 buffer_addr로 패킷 데이터를 DMA 전송한 후, lengthstatus를 업데이트하고 DD 플래그를 설정합니다. 그런 다음 RDH를 1 증가시킵니다.

OS는 주기적으로 tail 위치의 디스크립터를 확인해 DD 플래그가 설정되었는지 검사하고, 설정되었다면 패킷을 읽고 버퍼를 재활용한 후 RDT를 업데이트합니다. 여러분이 이 코드를 사용하면 버스트 트래픽 상황에서도 패킷 손실 없이 안정적으로 수신할 수 있습니다.

실무에서의 이점으로는 첫째, 링 크기만큼 패킷을 버퍼링할 수 있어 CPU 인터럽트 처리 지연을 흡수하고, 둘째, 프로듀서-컨슈머 패턴으로 락 없이 동시성을 구현할 수 있으며, 셋째, 링 크기를 조정해 메모리와 성능 사이의 트레이드오프를 제어할 수 있다는 점입니다.

실전 팁

💡 링 크기는 반드시 2의 거듭제곱(128, 256, 512 등)을 사용하세요. 하드웨어 제약이기도 하고, index & (SIZE - 1) 비트 마스킹으로 빠른 모듈로 연산이 가능합니다.

💡 DD 플래그를 확인한 후 반드시 메모리 배리어(memory barrier)를 사용하세요. 컴파일러나 CPU가 메모리 접근 순서를 재배치하면, DD 플래그는 설정되었지만 패킷 데이터가 아직 메모리에 도착하지 않은 상황이 발생할 수 있습니다.

💡 RDT를 업데이트할 때는 여러 패킷을 처리한 후 한 번에 업데이트하세요. 매번 업데이트하면 PCI 트랜잭션이 과도하게 발생해 성능이 저하됩니다. 보통 32~64개씩 배치 처리합니다.

💡 멀티큐 NIC(RSS, Receive Side Scaling)를 사용하면 CPU 코어별로 전용 RX 링을 할당할 수 있습니다. 해시 함수로 패킷을 분산시켜 락 경합 없이 병렬 처리하면 10Gbps 이상의 처리량을 달성할 수 있습니다.

💡 디버깅 시에는 RDH와 RDT 값을 주기적으로 로그로 출력하세요. 두 값의 차이가 링 크기에 근접하면 OS가 패킷 처리를 따라가지 못해 오버플로우 직전이라는 신호입니다.


5. 송신 디스크립터 링 설정 - 패킷 전송 준비

시작하며

여러분이 수신 링을 완성했다면 이제 반대 방향, 즉 패킷을 전송하는 메커니즘도 필요합니다. 애플리케이션이 send() 시스템 콜을 호출하면, OS는 그 데이터를 네트워크 카드에게 전달해야 하는데, 어떻게 효율적으로 할 수 있을까요?

이 문제도 수신과 마찬가지로 성능이 핵심입니다. CPU가 매번 패킷 데이터를 네트워크 카드 레지스터에 직접 쓴다면, 송신 속도가 CPU 속도에 제한됩니다.

또한 큰 패킷을 보낼 때마다 CPU가 블록되면 다른 작업을 할 수 없어 시스템 전체 응답성이 떨어집니다. 바로 이럴 때 필요한 것이 송신 디스크립터 링(TX Descriptor Ring)입니다.

OS가 보낼 패킷들을 링에 쌓아두면, 네트워크 카드가 비동기적으로 DMA로 읽어서 전송하므로 CPU는 즉시 다음 작업으로 넘어갈 수 있습니다.

개요

간단히 말해서, 송신 디스크립터 링은 OS가 전송할 패킷 정보를 네트워크 카드에게 전달하는 원형 큐로, 각 디스크립터가 패킷 데이터의 물리 주소, 길이, 전송 옵션을 담고 있습니다. 왜 이 개념이 필요한지 실무 관점에서 보면, 네트워크 스택은 여러 커넥션에서 동시에 패킷을 전송하려고 합니다.

예를 들어, HTTP 서버가 100개의 클라이언트에게 응답을 보낸다면, 100개의 패킷이 거의 동시에 큐에 들어옵니다. 링 구조 덕분에 이들을 순서대로 저장하고, 네트워크 카드가 하나씩 처리하도록 할 수 있습니다.

기존의 동기식 전송 방식에서는 각 패킷이 전송 완료될 때까지 기다려야 했다면, 이제는 링에 디스크립터만 추가하고 즉시 리턴하므로 완전한 비동기 I/O가 가능합니다. 송신 디스크립터 링의 핵심 특징은 첫째, 수신 링과 마찬가지로 원형 버퍼 구조로 head/tail 포인터로 관리되고, 둘째, RS(Report Status) 플래그를 사용해 특정 패킷의 전송 완료를 확인할 수 있으며, 셋째, TSO(TCP Segmentation Offload) 같은 하드웨어 오프로드 기능을 디스크립터 플래그로 제어할 수 있다는 점입니다.

이러한 특징들이 중요한 이유는 대용량 전송 시 CPU 오버헤드를 최소화하고, 체크섬 계산이나 세그멘테이션을 하드웨어에 맡겨 성능을 극대화할 수 있기 때문입니다.

코드 예제

// E1000 송신 디스크립터 구조 (16바이트)
#[repr(C, packed)]
struct TxDescriptor {
    buffer_addr: u64,  // 패킷 데이터의 물리 주소
    length: u16,       // 패킷 길이
    cso: u8,           // Checksum Offset
    cmd: u8,           // EOP, RS 등 명령 플래그
    status: u8,        // DD 플래그
    css: u8,           // Checksum Start
    special: u16,      // VLAN 태그 등
}

const TX_RING_SIZE: usize = 256;
const CMD_EOP: u8 = 0x01; // End Of Packet
const CMD_RS: u8 = 0x08;  // Report Status

struct TxRing {
    descriptors: DmaBuffer,
    buffers: Vec<Option<DmaBuffer>>, // 전송 중인 버퍼
    head: usize, // OS가 추가할 다음 위치
    tail: usize, // 네트워크 카드가 처리한 위치
}

impl TxRing {
    fn send_packet(&mut self, data: &[u8], nic_base: u64) -> Result<(), &'static str> {
        // 링이 가득 찼는지 확인
        if (self.head + 1) % TX_RING_SIZE == self.tail {
            return Err("TX ring full");
        }

        // 패킷 데이터를 DMA 버퍼에 복사
        let mut buffer = DmaBuffer::allocate(data.len())?;
        unsafe {
            core::ptr::copy_nonoverlapping(data.as_ptr(), buffer.virt_addr, data.len());
        }

        // 디스크립터 설정
        let desc_ptr = self.descriptors.virt_addr as *mut TxDescriptor;
        unsafe {
            (*desc_ptr.add(self.head)).buffer_addr = buffer.physical_address();
            (*desc_ptr.add(self.head)).length = data.len() as u16;
            (*desc_ptr.add(self.head)).cmd = CMD_EOP | CMD_RS; // 전송 완료 보고
            (*desc_ptr.add(self.head)).status = 0;
        }

        self.buffers[self.head] = Some(buffer);
        self.head = (self.head + 1) % TX_RING_SIZE;

        // TDT 레지스터 업데이트 (네트워크 카드에게 알림)
        write_register(nic_base, TDT, self.head as u32);

        Ok(())
    }
}

설명

이것이 하는 일: 이 코드는 사용자가 보내려는 데이터를 DMA 버퍼에 복사하고, 송신 디스크립터를 설정한 후, 네트워크 카드에게 "새로운 패킷이 있다"고 알려 전송을 트리거합니다. 첫 번째 단계로, 링에 여유 공간이 있는지 확인합니다.

(head + 1) % TX_RING_SIZE == tail이면 링이 가득 찬 상태로, 새로운 패킷을 추가할 수 없습니다. 이 경우 에러를 반환하거나, 실제 드라이버에서는 인터럽트를 기다려 tail이 진행될 때까지 블록합니다.

링이 가득 차는 상황은 네트워크 카드보다 OS가 더 빠르게 패킷을 생성하고 있다는 의미로, 트래픽 쉐이핑이나 백프레셔(backpressure) 메커니즘이 필요합니다. 두 번째 단계에서, 패킷 데이터를 DMA 버퍼에 복사합니다.

사용자 공간의 데이터는 가상 메모리에 있고 스왑될 수도 있으므로, 네트워크 카드가 안전하게 접근할 수 있는 커널 DMA 버퍼로 복사해야 합니다. 이것이 "one-copy" 오버헤드이며, 제로카피 송신을 구현하려면 사용자 버퍼를 직접 핀(pin)하고 물리 주소로 변환하는 고급 기법이 필요합니다.

하지만 대부분의 경우 한 번의 복사는 허용 가능한 오버헤드입니다. 세 번째 단계로, head 위치의 디스크립터에 버퍼 정보를 씁니다.

buffer_addr는 DMA 버퍼의 물리 주소, length는 패킷 크기입니다. cmd 필드에 CMD_EOP(End Of Packet) 플래그를 설정해 이 디스크립터가 패킷의 마지막임을 표시하고, CMD_RS(Report Status) 플래그를 설정해 전송 완료 시 DD 플래그를 업데이트하도록 요청합니다.

큰 패킷은 여러 디스크립터로 나눌 수 있으며, 마지막 디스크립터에만 EOP를 설정합니다. 마지막 단계에서, TDT(Transmit Descriptor Tail) 레지스터를 업데이트합니다.

이것이 네트워크 카드에게 "새로운 디스크립터가 추가되었으니 전송 시작"이라고 알리는 시그널입니다. 네트워크 카드는 TDH(Transmit Descriptor Head)부터 TDT 직전까지의 디스크립터들을 순회하며 패킷을 전송하고, 각각에 대해 DD 플래그를 설정한 후 TDH를 증가시킵니다.

여러분이 이 코드를 사용하면 네트워크 송신을 비동기적으로 처리할 수 있습니다. 실무에서의 이점으로는 첫째, 애플리케이션이 send() 호출 후 즉시 리턴받아 블록되지 않고, 둘째, 여러 패킷을 배치로 큐잉해 PCI 트랜잭션을 줄이고, 셋째, 하드웨어 체크섬 오프로드를 활성화해 CPU 부하를 감소시킬 수 있다는 점입니다.

실전 팁

💡 전송 완료된 디스크립터를 주기적으로 회수(reclaim)하여 버퍼를 해제하세요. DD 플래그가 설정된 디스크립터의 버퍼는 더 이상 네트워크 카드가 사용하지 않으므로 안전하게 해제할 수 있습니다. 메모리 누수를 방지하려면 필수입니다.

💡 모든 패킷에 RS 플래그를 설정하지 말고, 32개마다 한 번씩 설정하세요. RS는 인터럽트를 발생시키므로 매번 설정하면 인터럽트 오버헤드가 큽니다. 배치 처리로 효율을 높이세요.

💡 TSO(TCP Segmentation Offload)를 활성화하면 64KB TCP 세그먼트를 디스크립터 하나로 전송할 수 있습니다. 네트워크 카드가 자동으로 MSS 크기로 분할하므로 CPU에서 세그멘테이션 루프를 돌 필요가 없어 성능이 크게 향상됩니다.

💡 송신 링이 가득 찰 때 폴링보다는 인터럽트 대기가 효율적입니다. 전송 완료 인터럽트를 받으면 tail을 업데이트하고 대기 중인 송신 요청을 재개하세요. 이벤트 기반 설계가 CPU 사용률을 줄입니다.

💡 멀티큐 송신을 구현할 때는 TCP 커넥션별로 큐를 고정(affinity)하세요. 같은 플로우의 패킷이 여러 큐에 분산되면 재정렬 오버헤드가 발생하고, 순서가 뒤바뀌면 TCP 성능이 저하됩니다.


6. 인터럽트 핸들러 등록 - 패킷 도착 알림

시작하며

여러분이 수신/송신 링을 완벽하게 설정했지만, 한 가지 문제가 남았습니다. "패킷이 도착했는지 어떻게 알지?" CPU가 끊임없이 디스크립터를 폴링한다면 엄청난 CPU 낭비입니다.

이 문제는 I/O 효율성의 핵심입니다. 폴링 방식은 레이턴시는 낮지만 CPU 사용률이 100%에 달합니다.

1초에 수천 개의 패킷이 오는 상황이라면 폴링도 괜찮지만, 패킷이 드물게 도착하는 상황에서는 99%의 폴링이 허비입니다. 또한 여러 디바이스를 폴링해야 한다면 CPU가 어떤 일도 하지 못하게 됩니다.

바로 이럴 때 필요한 것이 인터럽트(Interrupt)입니다. 네트워크 카드가 패킷을 수신하거나 송신을 완료하면 CPU에게 신호를 보내고, CPU는 그때만 인터럽트 핸들러를 실행해 처리합니다.

개요

간단히 말해서, 인터럽트는 하드웨어가 CPU에게 비동기적으로 이벤트를 알리는 메커니즘으로, OS는 인터럽트 핸들러 함수를 IDT(Interrupt Descriptor Table)에 등록하고 네트워크 카드를 설정해 특정 인터럽트 번호를 사용하도록 합니다. 왜 이 개념이 필요한지 실무 관점에서 보면, 인터럽트 기반 I/O는 이벤트 드리븐 아키텍처의 핵심입니다.

예를 들어, 웹 서버가 클라이언트 요청을 기다릴 때 CPU는 다른 작업을 하다가 패킷이 도착하면 인터럽트로 깨어나 처리합니다. 이렇게 하면 CPU 사용률을 낮추면서도 밀리초 단위의 빠른 응답성을 유지할 수 있습니다.

기존의 레거시 인터럽트(INTx)는 여러 디바이스가 공유할 수 있어 혼선이 있었다면, 이제는 MSI(Message Signaled Interrupts)나 MSI-X를 사용해 각 디바이스가 고유한 인터럽트 벡터를 가지고, 멀티큐 NIC는 큐별로 다른 인터럽트를 발생시켜 CPU 코어별로 분산 처리할 수 있습니다. 인터럽트의 핵심 특징은 첫째, 비동기적이므로 CPU가 블록되지 않고 다른 작업을 계속할 수 있으며, 둘째, 인터럽트 컨텍스트는 제한적이어서 오래 걸리는 작업은 할 수 없고 빠르게 처리하고 복귀해야 하며, 셋째, 인터럽트 폭풍(interrupt storm)을 방지하기 위해 인터럽트 스로틀링(throttling)이나 코얼레싱(coalescing) 기법이 필요하다는 점입니다.

이러한 특징들이 중요한 이유는 잘못 설계하면 인터럽트 오버헤드가 실제 작업보다 커져 성능이 오히려 나빠지기 때문입니다.

코드 예제

use x86_64::structures::idt::{InterruptDescriptorTable, InterruptStackFrame};

// 인터럽트 핸들러 (extern "x86-interrupt" 호출 규약)
extern "x86-interrupt" fn e1000_interrupt_handler(_stack_frame: InterruptStackFrame) {
    // 인터럽트 원인 확인
    let icr = read_register(NIC_BASE, ICR); // Interrupt Cause Read

    if icr & ICR_RXT0 != 0 {
        // 수신 인터럽트: 패킷 도착
        handle_rx_packets();
    }

    if icr & ICR_TXDW != 0 {
        // 송신 완료 인터럽트: 버퍼 회수
        reclaim_tx_buffers();
    }

    // EOI (End Of Interrupt) 신호
    unsafe {
        PICS.lock().notify_end_of_interrupt(NETWORK_INTERRUPT_ID);
    }
}

fn initialize_interrupts(nic_base: u64) {
    // IDT에 핸들러 등록
    let mut idt = InterruptDescriptorTable::new();
    idt[NETWORK_INTERRUPT_ID as usize].set_handler_fn(e1000_interrupt_handler);
    idt.load();

    // PCI 인터럽트 라인 확인
    let irq_line = pci_read_config_byte(bus, device, function, 0x3C); // Interrupt Line

    // 네트워크 카드 인터럽트 활성화
    let ims = IMS_RXT0 | IMS_TXDW; // RX와 TX 인터럽트 마스크
    write_register(nic_base, IMS, ims);

    // APIC/PIC에서 IRQ 언마스크
    enable_irq(irq_line);
}

const ICR_RXT0: u32 = 0x80;  // Receiver Timer Interrupt
const ICR_TXDW: u32 = 0x01;  // Transmit Descriptor Written Back
const IMS_RXT0: u32 = 0x80;
const IMS_TXDW: u32 = 0x01;

설명

이것이 하는 일: 이 코드는 IDT에 네트워크 인터럽트 핸들러를 등록하고, 네트워크 카드가 특정 이벤트(패킷 수신, 송신 완료) 발생 시 해당 핸들러를 호출하도록 설정합니다. 첫 번째 단계로, extern "x86-interrupt" 호출 규약으로 인터럽트 핸들러 함수를 정의합니다.

이 호출 규약은 CPU가 스택에 푸시한 인터럽트 스택 프레임을 자동으로 처리하고, iretq 명령으로 복귀합니다. 핸들러 내부에서는 먼저 ICR(Interrupt Cause Read) 레지스터를 읽어 어떤 이벤트가 인터럽트를 발생시켰는지 확인합니다.

네트워크 카드는 여러 이유로 인터럽트를 발생시킬 수 있으므로, 비트 플래그를 체크해 적절한 처리 함수를 호출해야 합니다. 두 번째 단계에서, ICR_RXT0 비트가 설정되었다면 패킷이 도착했다는 의미이므로 handle_rx_packets()를 호출해 수신 링의 디스크립터들을 처리합니다.

ICR_TXDW 비트는 송신 디스크립터가 완료되었다는 의미로, reclaim_tx_buffers()를 호출해 전송이 끝난 버퍼를 해제하고 링 공간을 확보합니다. 이렇게 하는 이유는 인터럽트 핸들러가 가능한 한 빠르게 실행되어야 하므로, 실제 패킷 처리 로직은 별도 함수로 분리하는 것이 좋습니다.

세 번째 단계로, EOI(End Of Interrupt) 신호를 PIC 또는 APIC에 보냅니다. 이것은 "인터럽트 처리를 완료했으니 다음 인터럽트를 받을 준비가 되었다"는 의미입니다.

EOI를 보내지 않으면 인터럽트 컨트롤러가 블록 상태로 남아 추가 인터럽트를 전달하지 않습니다. 이것은 매우 중요한 단계로, 잊어버리면 시스템이 멈춘 것처럼 보일 수 있습니다.

초기화 과정에서는 InterruptDescriptorTable에 핸들러를 등록하고, PCI Configuration Space의 Interrupt Line 레지스터(오프셋 0x3C)를 읽어 어떤 IRQ 번호를 사용하는지 확인합니다. 그런 다음 네트워크 카드의 IMS(Interrupt Mask Set) 레지스터에 원하는 인터럽트 종류를 쓰면, 해당 이벤트 발생 시 인터럽트가 발생합니다.

마지막으로 APIC/PIC에서 해당 IRQ를 언마스크해야 CPU까지 인터럽트가 전달됩니다. 여러분이 이 코드를 사용하면 효율적인 이벤트 드리븐 네트워크 드라이버를 구현할 수 있습니다.

실무에서의 이점으로는 첫째, CPU 사용률을 최소화하면서도 빠른 응답성을 유지하고, 둘째, 여러 디바이스의 이벤트를 통합적으로 관리할 수 있으며, 셋째, MSI-X를 사용하면 멀티코어 시스템에서 인터럽트를 분산시켜 확장성을 높일 수 있다는 점입니다.

실전 팁

💡 인터럽트 핸들러는 최소한의 작업만 수행하고 나머지는 워크큐나 태스크릿(tasklet)으로 연기하세요. 인터럽트 컨텍스트에서는 슬립할 수 없고, 오래 걸리면 다른 인터럽트가 지연됩니다.

💡 인터럽트 코얼레싱(interrupt coalescing)을 활성화하세요. 네트워크 카드에 "N개의 패킷이 도착하거나 T 마이크로초가 지나면 인터럽트를 발생시켜"라고 설정하면 인터럽트 빈도를 줄일 수 있습니다. 예를 들어 ITR(Interrupt Throttle Rate) 레지스터를 2000으로 설정하면 초당 최대 500개의 인터럽트로 제한됩니다.

💡 NAPI(New API) 패턴을 구현하세요. 첫 번째 패킷에서만 인터럽트를 받고, 이후에는 인터럽트를 비활성화한 채 폴링 모드로 전환합니다. 패킷이 끝나면 다시 인터럽트를 활성화하는 방식으로, 고부하 시 폴링의 성능과 저부하 시 인터럽트의 효율을 모두 얻을 수 있습니다.

💡 MSI-X를 지원하는 카드라면 반드시 사용하세요. PCI Capability 구조를 파싱해 MSI-X 테이블의 물리 주소를 얻고, 각 큐별로 다른 벡터를 할당하면 CPU 코어별로 전용 인터럽트를 받을 수 있어 락 경합이 사라집니다.

💡 디버깅 시에는 인터럽트 카운터를 유지하세요. 각 인터럽트 원인별로 카운터를 증가시키고 /proc 같은 인터페이스로 노출하면, 예상치 못한 인터럽트나 인터럽트 폭풍을 빠르게 발견할 수 있습니다.


7. 패킷 수신 처리 - 수신 링에서 패킷 읽기

시작하며

여러분이 인터럽트 핸들러까지 등록했다면, 이제 실제로 도착한 패킷을 읽어서 상위 네트워크 스택에 전달해야 합니다. 인터럽트는 "패킷이 도착했다"는 신호일 뿐, 실제 데이터는 수신 디스크립터 링에 있습니다.

이 단계가 중요한 이유는 여기서 패킷의 유효성을 검증하고, 에러를 처리하고, 메모리를 관리해야 하기 때문입니다. 네트워크 카드가 손상된 패킷을 받았을 수도 있고, 버퍼가 부족해서 일부 패킷이 드롭되었을 수도 있습니다.

이런 상황들을 올바르게 처리하지 않으면 시스템 불안정이나 보안 취약점으로 이어질 수 있습니다. 바로 이럴 때 필요한 것이 수신 처리 루프입니다.

수신 링의 디스크립터를 순회하며 DD 플래그를 확인하고, 패킷 데이터를 읽은 후 버퍼를 재활용하는 과정입니다.

개요

간단히 말해서, 패킷 수신 처리는 수신 링의 tail 인덱스부터 시작해 DD 플래그가 설정된 디스크립터들을 순회하며 패킷 데이터를 상위 계층에 전달하고, 버퍼를 재할당해 다음 수신을 준비하는 과정입니다. 왜 이 개념이 필요한지 실무 관점에서 보면, 패킷 수신은 네트워크 스택의 시작점입니다.

예를 들어, 이더넷 프레임을 파싱해 MAC 주소를 확인하고, EtherType을 보고 IPv4/IPv6를 구분한 후, IP 헤더를 파싱해 TCP/UDP 포트를 확인하고, 최종적으로 소켓 버퍼에 데이터를 전달합니다. 이 모든 과정은 수신 처리에서 시작됩니다.

기존의 커널에서는 sk_buff 같은 복잡한 구조체로 패킷을 표현했다면, Rust OS에서는 제로카피를 위해 버퍼 소유권을 명확히 하고, 라이프타임을 통해 메모리 안전성을 보장합니다. 수신 처리의 핵심 특징은 첫째, DD 플래그를 통한 풀링 없는 효율적인 확인이 가능하고, 둘째, 에러 플래그를 체크해 CRC 에러나 프레이밍 에러를 감지할 수 있으며, 셋째, 버퍼 재활용을 통해 동적 할당 오버헤드를 최소화할 수 있다는 점입니다.

이러한 특징들이 중요한 이유는 수신 경로가 네트워크 성능의 병목이 되기 쉬우므로, 모든 최적화가 처리량과 레이턴시에 직접적인 영향을 미치기 때문입니다.

코드 예제

fn handle_rx_packets(rx_ring: &mut RxRing, nic_base: u64) {
    let desc_ptr = rx_ring.descriptors.virt_addr as *mut RxDescriptor;

    loop {
        let desc = unsafe { &mut *desc_ptr.add(rx_ring.tail) };

        // DD 플래그 확인: 네트워크 카드가 쓰기를 완료했는가?
        if desc.status & DD_FLAG == 0 {
            break; // 아직 도착하지 않은 패킷
        }

        // 에러 체크
        if desc.errors != 0 {
            println!("RX error: {:x}", desc.errors);
            // 에러 패킷은 버리고 버퍼 재사용
        } else {
            // 패킷 데이터 읽기
            let buffer = rx_ring.buffers[rx_ring.tail].as_ref().unwrap();
            let packet_data = &buffer.as_slice()[..desc.length as usize];

            // 상위 네트워크 스택에 전달
            process_ethernet_frame(packet_data);
        }

        // 새 버퍼 할당 및 디스크립터 재설정
        let new_buffer = DmaBuffer::allocate(2048).expect("Buffer allocation failed");
        desc.buffer_addr = new_buffer.physical_address();
        desc.status = 0; // 네트워크 카드가 다시 사용 가능
        rx_ring.buffers[rx_ring.tail] = Some(new_buffer);

        // Tail 증가
        rx_ring.tail = (rx_ring.tail + 1) % RX_RING_SIZE;
    }

    // RDT 레지스터 업데이트 (네트워크 카드에게 버퍼 재공급 알림)
    let new_rdt = if rx_ring.tail == 0 { RX_RING_SIZE - 1 } else { rx_ring.tail - 1 };
    write_register(nic_base, RDT, new_rdt as u32);
}

fn process_ethernet_frame(data: &[u8]) {
    // 이더넷 헤더 파싱 (14바이트)
    let dst_mac = &data[0..6];
    let src_mac = &data[6..12];
    let ethertype = u16::from_be_bytes([data[12], data[13]]);

    match ethertype {
        0x0800 => handle_ipv4_packet(&data[14..]), // IPv4
        0x0806 => handle_arp_packet(&data[14..]),  // ARP
        0x86DD => handle_ipv6_packet(&data[14..]), // IPv6
        _ => { /* 지원하지 않는 프로토콜 */ }
    }
}

설명

이것이 하는 일: 이 코드는 수신 링을 순회하며 네트워크 카드가 쓴 패킷들을 읽어 네트워크 스택에 전달하고, 사용된 버퍼를 새로 할당해 링을 계속 동작할 수 있도록 유지합니다. 첫 번째 단계로, tail 위치의 디스크립터를 읽고 DD 플래그를 확인합니다.

이 플래그가 0이면 네트워크 카드가 아직 패킷을 쓰지 않았다는 의미이므로 루프를 종료합니다. 1이면 패킷이 도착했고, buffer_addr가 가리키는 메모리에 데이터가 써졌으며, length 필드에 패킷 크기가 기록되어 있습니다.

메모리 배리어를 사용해 DD 플래그 확인 후 패킷 데이터 읽기 순서를 보장하는 것이 안전합니다. 두 번째 단계에서, errors 필드를 확인해 CRC 에러, 심볼 에러, 오버런 같은 하드웨어 에러가 있는지 검사합니다.

에러가 있다면 해당 패킷은 손상되었으므로 버리고, 통계 카운터만 증가시킵니다. 에러가 없다면 버퍼의 앞부분 length 바이트만큼을 슬라이스로 잘라 유효한 패킷 데이터로 사용합니다.

전체 2KB 버퍼를 사용하는 것이 아니라 실제 패킷 크기만큼만 읽는 것에 주의하세요. 세 번째 단계로, process_ethernet_frame 함수를 호출해 패킷을 상위 계층에 전달합니다.

이 함수는 이더넷 헤더를 파싱해 EtherType을 확인하고, IPv4, ARP, IPv6 등 적절한 핸들러를 호출합니다. 이렇게 계층별로 파싱하는 것이 OSI 모델의 구현입니다.

이 단계에서는 제로카피를 위해 데이터를 복사하지 않고 슬라이스로 참조만 전달하는 것이 이상적입니다. 네 번째 단계로, 사용된 버퍼를 새로운 버퍼로 교체합니다.

새 DMA 버퍼를 할당하고, 그 물리 주소를 디스크립터에 쓴 후, status를 0으로 리셋하면 네트워크 카드가 다시 이 디스크립터를 사용할 수 있습니다. 버퍼를 재활용하지 않고 매번 할당/해제한다면 메모리 할당자가 병목이 될 수 있으므로, 실제 프로덕션에서는 버퍼 풀을 사용해 재활용하는 것이 일반적입니다.

마지막으로, 모든 패킷 처리가 끝나면 RDT 레지스터를 업데이트해 네트워크 카드에게 "이만큼의 버퍼를 다시 사용해도 좋다"고 알립니다. RDT는 OS가 처리한 마지막 디스크립터의 이전 위치를 가리켜야 하므로, tail - 1을 씁니다.

단, tail이 0일 때는 RX_RING_SIZE - 1로 wrap-around해야 합니다. 여러분이 이 코드를 사용하면 안정적이고 효율적인 패킷 수신을 구현할 수 있습니다.

실무에서의 이점으로는 첫째, 에러 처리를 통해 손상된 패킷이 상위 계층을 오염시키지 않고, 둘째, 버퍼 재활용으로 메모리 할당 오버헤드를 최소화하며, 셋째, 제로카피 설계로 패킷 처리 성능을 극대화할 수 있다는 점입니다.

실전 팁

💡 한 번의 인터럽트에서 너무 많은 패킷을 처리하지 마세요. 예를 들어 최대 64개까지만 처리하고 나머지는 다음 인터럽트에 맡기면, 인터럽트 레이턴시를 일정하게 유지할 수 있습니다. budget 변수로 제한하세요.

💡 process_ethernet_frame에서 패킷을 복사하지 말고 참조만 전달하세요. Rust의 라이프타임 시스템을 활용하면 버퍼가 해제되기 전까지 안전하게 참조를 유지할 수 있습니다. 제로카피가 성능의 핵심입니다.

💡 버퍼 풀(buffer pool)을 구현하세요. 미리 수백 개의 DMA 버퍼를 할당해두고, 수신 처리 시 풀에서 꺼내 쓰고 반환하는 방식이 매번 할당하는 것보다 10배 이상 빠릅니다. 락프리 큐를 사용하면 더욱 효율적입니다.

💡 RDT 업데이트를 배치로 처리하세요. 매 패킷마다 레지스터에 쓰면 PCI 트랜잭션 오버헤드가 큽니다. 32~64개 패킷을 처리한 후 한 번만 업데이트하세요.

💡 패킷 통계를 유지하세요. 수신 패킷 수, 드롭 수, 에러 수, 바이트 수 등을 카운터로 관리하고, 디버깅 인터페이스로 노출하면 성능 이슈나 하드웨어 문제를 빠르게 진단할 수 있습니다.


8. 패킷 송신 완료 처리 - 송신 버퍼 회수

시작하며

여러분이 패킷을 송신 링에 추가했다고 해서 끝이 아닙니다. 네트워크 카드가 전송을 완료한 후에는 사용된 버퍼를 회수해야 링 공간을 재사용할 수 있습니다.

그렇지 않으면 256개의 패킷을 보낸 후 링이 가득 차서 더 이상 전송할 수 없게 됩니다. 이 문제는 메모리 관리와 직결됩니다.

각 패킷이 2KB DMA 버퍼를 사용한다면, 256개 링은 512KB를 점유합니다. 버퍼를 회수하지 않으면 메모리가 계속 누수되고, 결국 시스템이 메모리 부족 상태가 됩니다.

또한 링이 가득 차면 새로운 패킷을 보낼 수 없어 네트워크 통신이 멈춥니다. 바로 이럴 때 필요한 것이 송신 완료 처리입니다.

네트워크 카드가 DD 플래그를 설정한 디스크립터들을 확인하고, 해당 버퍼를 해제해 메모리를 회수하며, 링 공간을 확보하는 과정입니다.

개요

간단히 말해서, 송신 완료 처리는 송신 링의 tail 인덱스부터 시작해 DD 플래그가 설정된 디스크립터들을 순회하며 전송이 끝난 버퍼를 해제하고, tail 포인터를 업데이트해 링 공간을 재사용 가능하도록 만드는 과정입니다. 왜 이 개념이 필요한지 실무 관점에서 보면, 송신은 비동기적이므로 OS는 패킷을 큐에 넣자마자 다음 작업으로 넘어갑니다.

예를 들어, 웹 서버가 100개의 응답을 연속으로 보낸다면, 첫 번째 패킷이 전송되는 동안 나머지 99개가 큐에 쌓입니다. 네트워크 카드가 전송을 완료하면 OS는 버퍼를 회수해 101번째 패킷을 위한 공간을 만들어야 합니다.

기존의 동기식 I/O에서는 전송이 완료될 때까지 블록했다면, 이제는 완료 인터럽트를 받아 비동기적으로 정리하므로 처리량이 크게 향상됩니다. 송신 완료 처리의 핵심 특징은 첫째, DD 플래그를 통해 어떤 패킷까지 전송되었는지 정확히 알 수 있고, 둘째, 배치 처리를 통해 여러 버퍼를 한 번에 회수할 수 있으며, 셋째, 송신 완료를 대기 중인 스레드에게 알려 블록을 해제할 수 있다는 점입니다.

이러한 특징들이 중요한 이유는 송신 경로가 수신보다 더 많은 메모리 관리가 필요하고, 애플리케이션의 send() 호출이 블록되는 시간에 직접적인 영향을 주기 때문입니다.

코드 예제

fn reclaim_tx_buffers(tx_ring: &mut TxRing) {
    let desc_ptr = tx_ring.descriptors.virt_addr as *mut TxDescriptor;

    let mut reclaimed = 0;

    while tx_ring.tail != tx_ring.head {
        let desc = unsafe { &*desc_ptr.add(tx_ring.tail) };

        // DD 플래그 확인: 전송 완료되었는가?
        if desc.status & DD_FLAG == 0 {
            break; // 아직 전송 중
        }

        // 버퍼 해제
        if let Some(buffer) = tx_ring.buffers[tx_ring.tail].take() {
            // DmaBuffer의 Drop 트레이트가 자동으로 메모리 해제
            drop(buffer);
            reclaimed += 1;
        }

        // Tail 증가
        tx_ring.tail = (tx_ring.tail + 1) % TX_RING_SIZE;
    }

    if reclaimed > 0 {
        // 송신 대기 중인 스레드 깨우기
        tx_ring.waitqueue.wake_all();
    }
}

// 송신 함수 (링이 가득 찼을 때 대기)
fn send_packet_blocking(tx_ring: &mut TxRing, data: &[u8], nic_base: u64) {
    loop {
        match tx_ring.send_packet(data, nic_base) {
            Ok(_) => return,
            Err("TX ring full") => {
                // 송신 완료 인터럽트를 기다림
                tx_ring.waitqueue.wait();
                // 인터럽트 핸들러가 reclaim_tx_buffers를 호출해 공간 확보
            }
            Err(e) => panic!("Send failed: {}", e),
        }
    }
}

설명

이것이 하는 일: 이 코드는 송신 링의 tail부터 head까지 순회하며 전송이 완료된 디스크립터들을 찾고, 해당 버퍼를 해제해 메모리를 회수하며, 대기 중인 송신 요청을 재개합니다. 첫 번째 단계로, tail != head 조건으로 처리할 디스크립터가 있는지 확인합니다.

tail은 네트워크 카드가 처리한 위치, head는 OS가 추가한 위치이므로, 둘 사이에 있는 디스크립터들이 전송 중이거나 전송 완료된 상태입니다. tail == head이면 링이 비어있어 처리할 것이 없습니다.

두 번째 단계에서, tail 위치의 디스크립터를 읽고 DD 플래그를 확인합니다. 이 플래그가 1이면 네트워크 카드가 해당 패킷의 전송을 완료했다는 의미입니다.

전송 완료는 패킷이 네트워크 카드의 송신 FIFO를 떠났다는 의미로, 물리적으로 이더넷 케이블에 전송되었습니다. DD 플래그가 0이면 아직 전송 중이므로 루프를 종료하고 나중에 인터럽트가 다시 호출될 때까지 기다립니다.

세 번째 단계로, tx_ring.buffers[tail]에서 버퍼를 꺼내 해제합니다. Option::take()를 사용하면 Some(buffer)를 꺼내고 그 자리에 None을 남깁니다.

이렇게 하는 이유는 같은 버퍼를 두 번 해제하는 실수를 방지하기 위함입니다. drop(buffer)는 명시적으로 DmaBufferDrop 트레이트를 호출해 물리 메모리를 해제합니다.

Rust의 RAII 패턴 덕분에 메모리 누수가 발생하지 않습니다. 네 번째 단계에서, tail을 1 증가시켜 다음 디스크립터로 이동합니다.

모듈로 연산으로 링의 끝에 도달하면 자동으로 0으로 돌아갑니다. 이 루프는 DD 플래그가 설정된 모든 디스크립터를 처리할 때까지 계속됩니다.

일반적으로 인터럽트 한 번에 수십 개의 버퍼를 회수하게 됩니다. 마지막으로, 버퍼를 회수했다면 링에 새로운 공간이 생겼으므로, tx_ring.waitqueue.wake_all()을 호출해 "TX ring full" 에러로 블록된 송신 요청들을 깨웁니다.

이들은 다시 send_packet()을 시도하고, 이제 링에 공간이 있으므로 성공합니다. 이것이 프로듀서-컨슈머 패턴의 완성입니다.

여러분이 이 코드를 사용하면 메모리 누수 없이 안정적인 송신을 구현할 수 있습니다. 실무에서의 이점으로는 첫째, 자동 메모리 관리로 버그를 예방하고, 둘째, 블로킹 송신 API를 제공해 플로우 컨트롤을 구현하며, 셋째, 배치 회수로 락 오버헤드를 최소화할 수 있다는 점입니다.

실전 팁

💡 reclaim_tx_buffers는 송신 완료 인터럽트뿐만 아니라 send_packet 시작 시에도 호출하세요. 인터럽트 레이턴시가 크면 버퍼 회수가 지연될 수 있으므로, 송신 전에 미리 회수하면 링 공간을 최대한 활용할 수 있습니다.

💡 RS(Report Status) 플래그를 모든 패킷에 설정하지 말고, 32개마다 한 번씩 설정하세요. RS가 없으면 DD 플래그도 업데이트되지 않으므로, 주기적으로 설정해 버퍼 회수 타이밍을 제어하세요. 너무 드물면 메모리가 오래 묶이고, 너무 자주면 인터럽트 오버헤드가 큽니다.

💡 Rust의 Drop 트레이트를 구현할 때 반드시 물리 메모리 해제와 가상 메모리 언매핑을 모두 수행하세요. 한쪽만 하면 메모리 누수나 페이지 테이블 오염이 발생합니다.

💡 송신 타임아웃을 구현하세요. 네트워크 카드가 멈추거나 케이블이 뽑히면 DD 플래그가 영원히 설정되지 않을 수 있습니다. 타이머를 사용해 일정 시간 이상 전송이 완료되지 않으면 강제로 리셋하세요.

💡 송신 완료 통계를 유지하세요. 평균 회수 개수, 최대 링 사용률, 대기 시간 등을 기록하면 네트워크 부하 패턴을 분석하고 링 크기를 최적화할 수 있습니다.


9. ARP 프로토콜 구현 - IP-MAC 주소 매핑

시작하며

여러분이 이제 패킷을 송수신할 수 있다면, 실제 네트워크 통신을 시작할 차례입니다. 하지만 문제가 하나 있습니다.

IP 주소(예: 192.168.1.100)는 알지만, 이더넷 프레임을 보내려면 MAC 주소(예: AA:BB:CC:DD:EE:FF)가 필요합니다. IP 주소로 MAC 주소를 어떻게 알아낼까요?

이 문제는 네트워크 계층과 링크 계층 사이의 간극입니다. 애플리케이션은 IP 주소로 통신하지만, 실제 이더넷 프레임은 MAC 주소를 사용합니다.

잘못된 MAC 주소로 보내면 패킷이 영영 목적지에 도달하지 못하고, 네트워크 통신이 완전히 실패합니다. 바로 이럴 때 필요한 것이 ARP(Address Resolution Protocol)입니다.

로컬 네트워크에 브로드캐스트로 "IP 주소 X를 가진 사람, MAC 주소를 알려주세요"라고 물어보고, 해당 호스트가 응답하면 그 MAC 주소를 캐시에 저장해 사용합니다.

개요

간단히 말해서, ARP는 IP 주소를 MAC 주소로 변환하는 프로토콜로, ARP 요청(Request)을 브로드캐스트하고 ARP 응답(Reply)을 받아 IP-MAC 매핑 테이블을 구축합니다. 왜 이 개념이 필요한지 실무 관점에서 보면, TCP/IP 스택이 동작하려면 ARP가 필수입니다.

예를 들어, ping 192.168.1.1을 실행하면 OS는 먼저 ARP로 게이트웨이의 MAC 주소를 알아낸 후, ICMP 패킷을 해당 MAC 주소로 전송합니다. ARP 없이는 로컬 네트워크에서 단 한 개의 패킷도 보낼 수 없습니다.

기존의 OS 커널에는 ARP 캐시(arp -a 명령으로 확인 가능)가 있어 한 번 조회한 결과를 재사용했다면, 우리 OS도 비슷한 캐시를 구현해야 합니다. ARP의 핵심 특징은 첫째, 브로드캐스트 기반으로 로컬 네트워크의 모든 호스트가 요청을 받지만 해당 IP를 가진 호스트만 응답하고, 둘째, 캐시를 통해 반복 조회를 피하고 성능을 향상시키며, 셋째, Gratuitous ARP로 자신의 IP-MAC 매핑을 알리거나 IP 충돌을 감지할 수 있다는 점입니다.

이러한 특징들이 중요한 이유는 ARP가 네트워크의 첫 단계이며, 잘못 구현하면 전체 통신이 불가능하거나 보안 취약점(ARP 스푸핑)이 발생할 수 있기 때문입니다.

코드 예제

use alloc::collections::BTreeMap;

// ARP 패킷 구조 (28바이트)
#[repr(C, packed)]
struct ArpPacket {
    hw_type: u16,        // 1 = Ethernet
    proto_type: u16,     // 0x0800 = IPv4
    hw_size: u8,         // 6 (MAC 주소 길이)
    proto_size: u8,      // 4 (IP 주소 길이)
    opcode: u16,         // 1 = Request, 2 = Reply
    sender_mac: [u8; 6],
    sender_ip: [u8; 4],
    target_mac: [u8; 6],
    target_ip: [u8; 4],
}

static ARP_CACHE: Mutex<BTreeMap<u32, [u8; 6]>> = Mutex::new(BTreeMap::new());

fn send_arp_request(target_ip: [u8; 4], nic_base: u64) {
    let arp = ArpPacket {
        hw_type: 1u16.to_be(),
        proto_type: 0x0800u16.to_be(),
        hw_size: 6,
        proto_size: 4,
        opcode: 1u16.to_be(), // Request
        sender_mac: get_my_mac_address(),
        sender_ip: get_my_ip_address(),
        target_mac: [0; 6], // 알 수 없음
        target_ip,
    };

    // 이더넷 헤더 + ARP 패킷 조립
    let mut frame = Vec::new();
    frame.extend_from_slice(&[0xFF; 6]); // 브로드캐스트 MAC
    frame.extend_from_slice(&get_my_mac_address());
    frame.extend_from_slice(&0x0806u16.to_be_bytes()); // EtherType = ARP
    frame.extend_from_slice(unsafe {
        core::slice::from_raw_parts(&arp as *const _ as *const u8, 28)
    });

    send_ethernet_frame(&frame, nic_base);
}

fn handle_arp_packet(data: &[u8]) {
    let arp = unsafe { &*(data.as_ptr() as *const ArpPacket) };

    let opcode = u16::from_be(arp.opcode);
    let sender_ip_u32 = u32::from_be_bytes(arp.sender_ip);

    // ARP 캐시 업데이트
    ARP_CACHE.lock().insert(sender_ip_u32, arp.sender_mac);

    if opcode == 1 { // ARP Request
        // 나를 찾는 요청인가?
        if arp.target_ip == get_my_ip_address() {
            send_arp_reply(arp.sender_mac, arp.sender_ip);
        }
    } else if opcode == 2 { // ARP Reply
        // 캐시에 이미 추가됨
        println!("ARP: {} -> {:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}",
                 format_ip(arp.sender_ip),
                 arp.sender_mac[0], arp.sender_mac[1], arp.sender_mac[2],
                 arp.sender_mac[3], arp.sender_mac[4], arp.sender_mac[5]);
    }
}

설명

이것이 하는 일: 이 코드는 ARP 요청 패킷을 생성해 브로드캐스트로 전송하고, ARP 응답을 받으면 IP-MAC 매핑을 캐시에 저장하며, 자신을 대상으로 하는 ARP 요청에 응답합니다. 첫 번째 단계로, send_arp_request 함수에서 ARP 패킷을 구성합니다.

hw_type은 1(Ethernet), proto_type은 0x0800(IPv4)으로 설정하고, opcode를 1(Request)로 지정합니다. sender_macsender_ip는 자신의 정보, target_ip는 찾고자 하는 IP 주소, target_mac은 아직 모르므로 0으로 채웁니다.

네트워크 바이트 오더(big-endian)로 변환하는 것을 잊지 말아야 하는데, to_be() 메서드가 이를 처리합니다. 두 번째 단계에서, ARP 패킷을 이더넷 프레임으로 감쌉니다.

목적지 MAC 주소를 FF:FF:FF:FF:FF:FF(브로드캐스트)로 설정하면 로컬 네트워크의 모든 호스트가 이 프레임을 받습니다. EtherType을 0x0806(ARP)으로 설정해 수신자가 ARP 패킷임을 인식하도록 합니다.

그런 다음 송신 링을 통해 전송합니다. 세 번째 단계로, ARP 패킷을 수신하면 handle_arp_packet이 호출됩니다.

먼저 opcode를 확인하기 전에 항상 sender_ipsender_mac을 ARP 캐시에 추가합니다. 이렇게 하는 이유는 ARP 요청이든 응답이든, 송신자의 정보는 유효하므로 캐시에 저장해두면 나중에 해당 IP로 통신할 때 재사용할 수 있기 때문입니다.

이것을 "수동적 ARP 학습(passive ARP learning)"이라고 합니다. 네 번째 단계에서, opcode가 1이면 누군가 IP 주소의 MAC 주소를 찾고 있습니다.

target_ip가 자신의 IP 주소와 일치하는지 확인하고, 일치하면 ARP 응답을 보냅니다. 응답은 브로드캐스트가 아닌 요청자의 MAC 주소로 유니캐스트로 전송됩니다.

opcode가 2라면 이전에 보낸 ARP 요청에 대한 응답이므로, 이미 캐시에 추가되었고 추가 작업은 필요 없습니다. ARP 캐시는 BTreeMap<u32, [u8; 6]>로 구현되어 IP 주소(u32)를 키로 MAC 주소를 빠르게 조회할 수 있습니다.

실제 프로덕션에서는 타임아웃을 구현해 오래된 엔트리를 제거해야 하는데, 네트워크 토폴로지가 변경되면 캐시된 MAC 주소가 무효화될 수 있기 때문입니다. 일반적으로 20분 정도의 TTL을 사용합니다.

여러분이 이 코드를 사용하면 IP 기반 네트워크 통신의 기반을 구축할 수 있습니다. 실무에서의 이점으로는 첫째, 로컬 네트워크에서 모든 호스트와 통신할 수 있게 되고, 둘째, 캐시 덕분에 매번 ARP 요청을 보내지 않아 레이턴시가 감소하며, 셋째, ARP를 통해 네트워크 토폴로지를 동적으로 학습할 수 있다는 점입니다.

실전 팁

💡 ARP 요청을 보낸 후 타임아웃을 구현하세요. 1초 동안 응답이 없으면 재시도하고, 3번 실패하면 "Host unreachable" 에러를 반환하세요. 상대 호스트가 다운되었거나 네트워크 연결이 끊긴 경우를 처리할 수 있습니다.

💡 Gratuitous ARP를 부팅 시 전송하세요. 자신의 IP-MAC 매핑을 브로드캐스트하면 다른 호스트의 캐시를 업데이트할 수 있


#Rust#NetworkDriver#PCI#DMA#PacketBuffer#시스템프로그래밍

댓글 (0)

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