Memory Mapped I/O와 I/O Mapped I/O의 장치 접근 방식

Memory Mapped I/O와 I/O Mapped I/O의 주소 공간·명령어 차이, x86·ARM·RISC-V 구현과 드라이버 운영 시 고려할 캐시·배리어·보안 설정을 다룬다.

2026-08-14 · 최초 발행 2026-01-06

CPU가 장치 레지스터를 찾는 방법

CPU는 I/O 장치의 제어·상태 레지스터를 읽고 쓰면서 명령을 전달하고 상태를 확인하며 데이터를 주고받는다. 이때 장치를 어떤 주소 공간에 놓고 어떤 명령어로 접근할지에 따라 I/O 주소 지정 방식이 나뉜다.

Memory Mapped I/O(MMIO)는 장치 레지스터를 메모리 주소 공간에 배치해 MOV, LDR 같은 일반 메모리 접근 명령어로 제어한다. 반대로 I/O Mapped I/O, 또는 Port Mapped I/O(PMIO)는 메모리와 구분된 I/O 주소 공간을 두고 IN, OUT 같은 전용 명령어를 사용한다.

MMIOPMIOCPUI/O방식메모리주소 공간I/O주소 공간MOV, LDR명령어IN, OUT명령어I/O장치

MMIO는 단일 주소 공간 안에서 포인터와 다양한 주소 지정 모드를 활용할 수 있고 DMA 구현에도 친화적이다. PMIO는 I/O와 메모리를 명확히 구분하는 대신 전용 명령어와 별도 포트 번호 체계가 필요하다.

메모리 맵에 장치를 배치하는 MMIO

MMIO에서는 장치 레지스터가 메모리 주소의 일부를 차지한다. CPU가 해당 범위로 읽기나 쓰기를 수행하면 주소 디코더가 이를 장치로 라우팅한다.

메모리 맵 예시:
0x00000000 - 0x7FFFFFFF: RAM
0x80000000 - 0x8FFFFFFF: I/O 장치 레지스터
0x90000000 - 0x9FFFFFFF: ROM

UART처럼 레지스터 기반으로 동작하는 장치는 C 코드에서 volatile 포인터로 다룰 수 있다.

// UART 장치 레지스터 (0x80000000)
#define UART_BASE   0x80000000
#define UART_DATA   (UART_BASE + 0x00)
#define UART_STATUS (UART_BASE + 0x04)

// 메모리 접근 명령어 사용
void uart_send_char(char c) {
    volatile uint32_t *data_reg = (uint32_t*)UART_DATA;
    volatile uint32_t *status_reg = (uint32_t*)UART_STATUS;

    // 전송 가능 대기
    while ((*status_reg & 0x01) == 0);

    // 데이터 쓰기
    *data_reg = c;
}

주소 범위에 따라 메모리와 I/O 장치를 구분하는 동작은 다음과 같이 표현할 수 있다.

주소 디코더:
if (address >= 0x80000000 && address < 0x90000000) {
    // I/O 장치로 라우팅
    route_to_io_device(address, data);
} else {
    // 메모리로 라우팅
    route_to_memory(address, data);
}

일반 메모리 명령어를 그대로 쓸 수 있고, 포인터 연산과 주소 지정 모드도 적용할 수 있다. C 언어에서 자연스럽게 다룰 수 있으며 DMA 구현도 용이하다.

// 포인터 배열로 여러 레지스터 접근
volatile uint32_t *regs = (uint32_t*)DEVICE_BASE;

for (int i = 0; i < 10; i++) {
    regs[i] = init_values[i];
}

대신 장치가 메모리 주소 공간을 사용한다. 캐시 일관성 문제, 잘못된 포인터 접근 위험, 페이지 테이블 설정도 함께 검토해야 한다.

포트 공간을 분리하는 PMIO

PMIO는 메모리와 다른 I/O 주소 공간을 사용한다. x86에서는 포트 번호와 IN/OUT 명령어로 이 공간에 접근한다. 포트 공간은 보통 16비트이며, 메모리 포인터를 포트 주소처럼 사용할 수 없다.

x86 I/O 포트 예시:
0x0000 - 0x001F: DMA Controller
0x0020 - 0x003F: Interrupt Controller
0x0040 - 0x005F: Timer
0x0060 - 0x006F: Keyboard Controller
0x0070 - 0x007F: RTC
0x0080 - 0x009F: DMA Page Registers
...
0x03F8 - 0x03FF: COM1 (Serial Port)

x86의 포트 읽기와 쓰기는 다음과 같은 인라인 어셈블리 래퍼로 사용할 수 있다.

// 포트 읽기
static inline uint8_t inb(uint16_t port) {
    uint8_t value;
    asm volatile("inb %1, %0" : "=a"(value) : "Nd"(port));
    return value;
}

// 포트 쓰기
static inline void outb(uint16_t port, uint8_t value) {
    asm volatile("outb %0, %1" :: "a"(value), "Nd"(port));
}

// 16비트 읽기/쓰기
static inline uint16_t inw(uint16_t port) {
    uint16_t value;
    asm volatile("inw %1, %0" : "=a"(value) : "Nd"(port));
    return value;
}

static inline void outw(uint16_t port, uint16_t value) {
    asm volatile("outw %0, %1" :: "a"(value), "Nd"(port));
}

COM1 초기화와 문자 전송은 포트 번호를 기준으로 수행된다.

// 시리얼 포트 (COM1) 초기화
#define COM1_PORT 0x3F8

void init_serial(void) {
    outb(COM1_PORT + 1, 0x00);  // 인터럽트 비활성화
    outb(COM1_PORT + 3, 0x80);  // DLAB 활성화
    outb(COM1_PORT + 0, 0x03);  // 보레이트 하위 (38400)
    outb(COM1_PORT + 1, 0x00);  // 보레이트 상위
    outb(COM1_PORT + 3, 0x03);  // 8비트, no parity, 1 stop bit
    outb(COM1_PORT + 2, 0xC7);  // FIFO 활성화
    outb(COM1_PORT + 4, 0x0B);  // IRQ 활성화
}

void serial_send_char(char c) {
    // 전송 준비 대기
    while ((inb(COM1_PORT + 5) & 0x20) == 0);
    outb(COM1_PORT, c);
}

PMIO는 메모리 주소 공간을 절약하고 I/O와 메모리를 구분하기 쉽다. 포트 번호로 식별할 수 있으며 특권 레벨 제어에도 유리하다. 하지만 전용 명령어가 필요한 아키텍처 종속 방식이고, 포인터와 일반 주소 지정 모드를 사용할 수 없으며 포트 공간도 제한된다.

// 포인터 사용 불가
uint16_t *port_ptr = (uint16_t*)0x3F8;  // 잘못된 접근!
*port_ptr = value;  // 메모리 접근, 포트 접근 아님

주소 공간과 명령어 모델의 차이

항목 Memory Mapped I/O I/O Mapped I/O
주소 공간 통합 (메모리와 공유) 분리 (별도 I/O 공간)
주소 크기 32비트/64비트 16비트 (x86)
주소 할당 메모리 맵에서 예약 포트 번호 할당
충돌 가능성 메모리와 충돌 가능 독립적

MMIO는 메모리 접근 명령어를 사용한다. ARM의 LDR·STR이나 x86에서 MMIO에 접근할 때의 MOV가 그 예다.

// ARM
uint32_t value = *(volatile uint32_t*)0x80000000;  // LDR
*(volatile uint32_t*)0x80000000 = value;           // STR

// x86 (MMIO 사용 시)
uint32_t value = *(volatile uint32_t*)0xFEE00000;  // MOV
*(volatile uint32_t*)0xFEE00000 = value;           // MOV

PMIO는 x86에서만 지원되며 IN과 OUT을 사용한다.

// x86만 지원
uint8_t value = inb(0x3F8);   // IN
outb(0x3F8, value);           // OUT

MMIO는 설정에 따라 캐시할 수 있고, 파이프라이닝과 Write Combining을 적용할 수 있다. PMIO는 캐시할 수 없고 직렬화되지만, x86에서는 간단하고 예측 가능하며 오버헤드가 낮다.

I/O방식MMIOPMIO통합주소 공간일반명령어포인터사용분리주소 공간전용명령어포트번호

아키텍처에 따라 달라지는 지원 방식

x86은 MMIO와 PMIO를 모두 제공한다. 레거시 장치는 PMIO를, APIC 레지스터 같은 현대 장치는 MMIO를 사용할 수 있다.

// I/O Mapped I/O (Legacy)
outb(0x3F8, 'A');  // COM1에 문자 출력

// Memory Mapped I/O (Modern)
*(volatile uint32_t*)0xFEE00000 = value;  // APIC 레지스터

PCI Configuration Space도 PMIO를 통해 접근할 수 있다.

// PMIO를 통한 PCI Config 접근
#define CONFIG_ADDRESS 0xCF8
#define CONFIG_DATA    0xCFC

uint32_t pci_config_read(uint8_t bus, uint8_t slot, uint8_t func, uint8_t offset) {
    uint32_t address = (1 << 31) | (bus << 16) | (slot << 11) | (func << 8) | offset;
    outl(CONFIG_ADDRESS, address);
    return inl(CONFIG_DATA);
}

ARM은 MMIO만 지원한다. 다음은 ARM Cortex-A의 UART 레지스터 접근 예시다.

// ARM Cortex-A 예시
#define UART0_BASE  0x09000000
#define UART0_DR    (UART0_BASE + 0x00)
#define UART0_FR    (UART0_BASE + 0x18)

void uart_send_char(char c) {
    volatile uint32_t *dr = (uint32_t*)UART0_DR;
    volatile uint32_t *fr = (uint32_t*)UART0_FR;

    // Transmit FIFO Full 대기
    while (*fr & (1 << 5));

    *dr = c;
}

디바이스 트리는 장치의 MMIO 영역과 인터럽트를 기술한다.

uart0: serial@09000000 {
    compatible = "arm,pl011";
    reg = <0x09000000 0x1000>;
    interrupts = <0 1 4>;
};

RISC-V 역시 MMIO만 지원한다. SiFive UART는 송신·수신 데이터 레지스터를 메모리 주소로 노출한다.

// RISC-V SiFive UART
#define UART_BASE   0x10000000
#define UART_TXDATA (UART_BASE + 0x00)
#define UART_RXDATA (UART_BASE + 0x04)

void uart_putc(char c) {
    volatile uint32_t *txdata = (uint32_t*)UART_TXDATA;

    while (*txdata & (1 << 31));  // FIFO full
    *txdata = c;
}

char uart_getc(void) {
    volatile uint32_t *rxdata = (uint32_t*)UART_RXDATA;
    uint32_t data;

    do {
        data = *rxdata;
    } while (data & (1 << 31));  // Empty

    return data & 0xFF;
}

드라이버와 버스에서의 MMIO 접근

Linux 플랫폼 드라이버는 MMIO 리소스를 받아 가상 주소로 매핑한 뒤 readl, writel로 레지스터를 다룬다.

struct my_device {
    void __iomem *regs;
    int irq;
};

static int my_probe(struct platform_device *pdev) {
    struct my_device *dev;
    struct resource *res;

    dev = devm_kzalloc(&pdev->dev, sizeof(*dev), GFP_KERNEL);

    // MMIO 리소스 가져오기
    res = platform_get_resource(pdev, IORESOURCE_MEM, 0);
    dev->regs = devm_ioremap_resource(&pdev->dev, res);

    // 레지스터 접근
    writel(0x01, dev->regs + REG_CONTROL);
    uint32_t status = readl(dev->regs + REG_STATUS);

    return 0;
}

Windows 드라이버에서도 물리 주소를 가상 주소로 매핑한 뒤 레지스터 접근 매크로를 사용한다.

NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath) {
    PHYSICAL_ADDRESS phys_addr;
    PVOID virt_addr;

    phys_addr.QuadPart = 0x80000000;

    // 물리 주소를 가상 주소로 매핑
    virt_addr = MmMapIoSpace(phys_addr, 0x1000, MmNonCached);

    // 레지스터 접근
    WRITE_REGISTER_ULONG((PULONG)virt_addr, 0x01);
    ULONG value = READ_REGISTER_ULONG((PULONG)virt_addr);

    MmUnmapIoSpace(virt_addr, 0x1000);

    return STATUS_SUCCESS;
}

DMA 컨트롤러도 MMIO 레지스터로 소스·대상 주소와 전송 길이를 설정할 수 있다.

#define DMA_BASE    0x40020000
#define DMA_SRC     (DMA_BASE + 0x00)
#define DMA_DST     (DMA_BASE + 0x04)
#define DMA_LEN     (DMA_BASE + 0x08)
#define DMA_CTRL    (DMA_BASE + 0x0C)

void dma_transfer(void *src, void *dst, size_t len) {
    volatile uint32_t *dma_src = (uint32_t*)DMA_SRC;
    volatile uint32_t *dma_dst = (uint32_t*)DMA_DST;
    volatile uint32_t *dma_len = (uint32_t*)DMA_LEN;
    volatile uint32_t *dma_ctrl = (uint32_t*)DMA_CTRL;

    *dma_src = (uint32_t)src;
    *dma_dst = (uint32_t)dst;
    *dma_len = len;
    *dma_ctrl = 0x01;  // Start transfer

    // 완료 대기
    while (*dma_ctrl & 0x01);
}

PCI BAR는 장치의 MMIO 영역을 가리킨다. 드라이버는 BAR에서 시작 주소와 길이를 얻어 매핑한 뒤 레지스터를 읽고 쓴다.

// PCI 장치의 MMIO 영역 매핑
struct pci_dev *pdev;
void __iomem *mmio_base;
resource_size_t mmio_start, mmio_len;

mmio_start = pci_resource_start(pdev, 0);  // BAR0
mmio_len = pci_resource_len(pdev, 0);

mmio_base = ioremap(mmio_start, mmio_len);

// 레지스터 접근
iowrite32(value, mmio_base + REG_OFFSET);
uint32_t data = ioread32(mmio_base + REG_OFFSET);

iounmap(mmio_base);

캐시와 순서를 장치 특성에 맞추기

MMIO 영역은 일반 메모리와 다른 캐시 속성을 요구할 수 있다. Linux의 매핑 함수와 x86 페이지 테이블 속성으로 비캐시 매핑을 지정하는 예시는 다음과 같다.

// Linux: ioremap vs ioremap_nocache
void __iomem *regs = ioremap_nocache(phys_addr, size);

// x86 페이지 테이블 속성
pte_t pte = pfn_pte(pfn, PAGE_KERNEL_NOCACHE);

ARM의 Device Memory 속성과 페이지 테이블 설정도 접근 특성을 결정한다.

// ARM: Device Memory 속성
#define MT_DEVICE_nGnRnE  0
#define MT_DEVICE_nGnRE   1
#define MT_NORMAL_NC      2
#define MT_NORMAL         3

// 페이지 테이블 설정
set_pte(pte, pfn_pte(pfn, __pgprot(PROT_DEVICE_nGnRE)));

장치 레지스터 간 읽기와 쓰기 순서가 의미를 가질 때는 메모리 배리어로 순서를 보장한다.

void device_write_sequence(void __iomem *base) {
    writel(0x01, base + REG1);
    wmb();  // Write Memory Barrier
    writel(0x02, base + REG2);
}

void device_read_sequence(void __iomem *base) {
    uint32_t val1 = readl(base + REG1);
    rmb();  // Read Memory Barrier
    uint32_t val2 = readl(base + REG2);
}

void device_io_sequence(void __iomem *base) {
    writel(0x01, base + REG1);
    mb();  // Full Memory Barrier
    uint32_t val = readl(base + REG2);
}

프레임버퍼처럼 연속된 쓰기가 많은 영역은 Write Combining 매핑을 사용할 수 있다.

// Write Combining 매핑 (x86)
void __iomem *fb = ioremap_wc(fb_phys_addr, fb_size);

// 여러 쓰기를 결합하여 버스 트랜잭션 감소
for (int i = 0; i < 1024; i++) {
    writel(pixels[i], fb + i * 4);
}
// 하드웨어가 여러 쓰기를 하나의 버스트로 결합

권한과 주소 검증을 경계로 두기

MMIO는 사용자 공간이 임의로 접근하지 못하도록 권한을 제한해야 한다. 다음 예시는 커널 모드에서 CAP_SYS_RAWIO 권한을 확인한 뒤 매핑한다.

// 커널 모드에서만 MMIO 접근
if (!capable(CAP_SYS_RAWIO)) {
    return -EPERM;
}

void __iomem *regs = ioremap(phys_addr, size);

x86의 IOPL은 I/O 포트 접근 권한을 다루는 수단이다.

// I/O 포트 접근 권한
int iopl(int level);  // level: 0-3

// Ring 0 (커널)만 모든 I/O 포트 접근 가능
// Ring 3 (사용자)는 IOPL 설정 필요

물리 주소를 매핑하기 전에는 허용된 MMIO 범위인지, 장치가 존재하는지 확인해야 한다.

int safe_mmio_write(unsigned long phys_addr, uint32_t value) {
    if (phys_addr < MMIO_START || phys_addr >= MMIO_END) {
        return -EINVAL;
    }

    if (!is_device_present(phys_addr)) {
        return -ENODEV;
    }

    void __iomem *virt = ioremap(phys_addr, 4);
    writel(value, virt);
    iounmap(virt);

    return 0;
}

장치 접근의 타이밍 정보가 노출될 수 있는 상황에서는 캐시 플러시와 펜스로 읽기 순서를 제어할 수 있다.

// Constant-time I/O 접근
void secure_device_read(void __iomem *base, uint32_t *out) {
    // 캐시 플러시
    clflush(base);

    // 일정 시간 대기
    udelay(1);

    *out = readl(base);

    // 타이밍 정보 제거
    lfence();
}

MMIO는 포인터 연산, 다양한 주소 지정 모드, DMA 구현에 적합하지만 메모리 주소 공간을 소모하고 캐시 일관성 문제가 생길 수 있다. PMIO는 메모리와 I/O를 분리하지만 아키텍처 종속적이며 주소 공간이 제한된다. ARM과 RISC-V는 MMIO만 지원하고, x86은 두 방식을 모두 지원한다. 현대 시스템에서는 PCI 같은 확장 버스와 대용량 주소 공간 때문에 MMIO가 주로 사용되며, 운영 환경에서는 캐싱 정책·메모리 배리어·권한 보호·주소 검증을 함께 적용해야 한다.

MMIOPMIO컴퓨터구조디바이스 드라이버메모리 배리어PCI