본문 바로가기
임베디드 개발/임베디드 리눅스

QEMU 기반 커스텀 PCIe 장치 에뮬레이션 - (2)

by eteo 2026. 4. 12.
반응형

 

 

qemu 소스 설치 경로의 miscellaneous를 모아둔 hw/misc 경로로 이동하면 Custom PCI 장치를 만들 때 참고할만한 소스들이 있다.

 

$ cd <workspace 경로>
$ ls qemu/hw/misc/pci-testdev.c
$ ls qemu/hw/misc/edu.c

 

pci-testdev.c는 간단히 게스트 OS에서 PCIe I/O 기능을 테스트해 볼 수 있는 간단한 장치고, edu.c는 DMA, IRQ의 기능까지 테스트해볼 수 있는 교육용 장치다.

 

일단 비교적 간단한 pci-tesdev 장치를 살펴보자

 

 

 

pci-testdev.c 소스 분석

먼저 *_register_types 함수로 PCI 장치를 QEMU 타입 시스템에 등록하고, *_class_init 함수에서 해당 PCI 장치가 시스템에서 어떻게 인식될지를 설정한다.

*_class_init 함수에서는 VID, DID, class_id 같은 PCI 장치 식별 정보와 함께, 장치가 인식될 때 호출될 realize 함수와 장치가 제거될 때 호출될 exit 함수를 지정한다.

 

static void pci_testdev_class_init(ObjectClass *klass, const void *data)
{
    DeviceClass *dc = DEVICE_CLASS(klass);
    PCIDeviceClass *k = PCI_DEVICE_CLASS(klass);

    k->realize = pci_testdev_realize;
    k->exit = pci_testdev_uninit;
    k->vendor_id = PCI_VENDOR_ID_REDHAT;
    k->device_id = PCI_DEVICE_ID_REDHAT_TEST;
    k->revision = 0x00;
    k->class_id = PCI_CLASS_OTHERS;
    dc->desc = "PCI Test Device";
    set_bit(DEVICE_CATEGORY_MISC, dc->categories);
    device_class_set_legacy_reset(dc, qdev_pci_testdev_reset);
    device_class_set_props(dc, pci_testdev_properties);
}

static const TypeInfo pci_testdev_info = {
    .name          = TYPE_PCI_TEST_DEV,
    .parent        = TYPE_PCI_DEVICE,
    .instance_size = sizeof(PCITestDevState),
    .class_init    = pci_testdev_class_init,
    .interfaces = (const InterfaceInfo[]) {
        { INTERFACE_CONVENTIONAL_PCI_DEVICE },
        { },
    },
};

static void pci_testdev_register_types(void)
{
    type_register_static(&pci_testdev_info);
}

type_init(pci_testdev_register_types)

 

 

PCITestDevState는 장치가 살아 있는 동안 유지해야 할 모든 데이터를 담고 있는 구조체다. QEMU PCI 장치의 기본 정보를 담고 있는 PCIDevice가 구조체의 첫 멤버로 위치하며, 필요에 따라 PCIDevice * 또는 PCITestDevState *로 캐스팅되어 사용될 수 있다. (부모 구조체를 멤버로 포함하는 방식은 C에서 상속을 구현하는 일반적인 패턴이다.)

 

그 뒤로는 장치가 제공하는 I/O 공간을 나타내는 MemoryRegion이 위치하는데, 이 장치는 4KB 크기의 MMIO 영역(BAR0)과 256Bytes 크기의 Port I/O 영역(BAR1)을 사용한다.

 

그리고 tests와 current는 I/O 테스트를 위해 장치 내부에 구성해 둔 로직의 상태 변수다.

 

struct PCITestDevState {
    /*< private >*/
    PCIDevice parent_obj;
    /*< public >*/

    MemoryRegion mmio;
    MemoryRegion portio;
    IOTest *tests;
    int current;
	
    // ...
};

 

 

다음으로 MemoryRegionOps는 CPU가 해당 MemoryRegion에 접근했을 때 호출될 read/write 콜백을 정의하고, 그 외 endianness외 접근 크기 제한 등을 설정하는 구조체다.

 

  • .endianness : 이 장치는 BAR 접근을 little-endian 방식으로 해석한다.
  • .impl : 디바이스 콜백이 직접 처리할 수 있는 접근 크기로, 1바이트 단위 접근만 처리한다.
  • .valid : 이 BAR 인터페이스에서 허용되는 접근 크기로, .min_access_size, .max_access_size를 설정하면 범위를 벗어나면 접근 자체를 아예 invalid로 처리한다.

 

static const MemoryRegionOps pci_testdev_mmio_ops = {
    .read = pci_testdev_read,
    .write = pci_testdev_mmio_write,
    .endianness = DEVICE_LITTLE_ENDIAN,
    .impl = {
        .min_access_size = 1,
        .max_access_size = 1,
    },
};

static const MemoryRegionOps pci_testdev_pio_ops = {
    .read = pci_testdev_read,
    .write = pci_testdev_pio_write,
    .endianness = DEVICE_LITTLE_ENDIAN,
    .impl = {
        .min_access_size = 1,
        .max_access_size = 1,
    },

 

 

*_realize 함수는 장치가 시스템에 인식됐을 때 호출되는 초기화 함수로, 장치가 사용할 리소스를 할당하는 역할을 한다.

 

memory_region_init_io() 함수를 통해 CPU 접근 시 호출될 콜백 테이블을 연결하고, pci_register_bar() 함수를 통해 BAR를 PCI 장치에 등록한다.

static void pci_testdev_realize(PCIDevice *pci_dev, Error **errp)
{
    PCITestDevState *d = PCI_TEST_DEV(pci_dev);
    uint8_t *pci_conf;
    char *name;
    int r, i;

    pci_conf = pci_dev->config;

    pci_conf[PCI_INTERRUPT_PIN] = 0; /* no interrupt pin */

    memory_region_init_io(&d->mmio, OBJECT(d), &pci_testdev_mmio_ops, d,
                          "pci-testdev-mmio", IOTEST_MEMSIZE * 2);
    memory_region_init_io(&d->portio, OBJECT(d), &pci_testdev_pio_ops, d,
                          "pci-testdev-portio", IOTEST_IOSIZE * 2);
    pci_register_bar(pci_dev, 0, PCI_BASE_ADDRESS_SPACE_MEMORY, &d->mmio);
    pci_register_bar(pci_dev, 1, PCI_BASE_ADDRESS_SPACE_IO, &d->portio);

	// ...

    d->current = -1;
    d->tests = g_malloc0(IOTEST_MAX * sizeof *d->tests);
    for (i = 0; i < IOTEST_MAX; ++i) {
        IOTest *test = &d->tests[i];
 		// ...
    }
}

 

 

 

다음은 write 콜백 함수인데 이 장치는 먼저 test 종류를 선택하는 write가 발생하면 해당 테스트를 활성화하고, 이후 미리 정의된 offset에 write가 발생할 때마다 접근 조건을 확인한 뒤 조건을 만족하는 경우에는 내부 카운터를 증가시킨다.

 

여기서 중요한 점은 BAR에 접근해 write 하는 행위가 반드시 PCIe 장치 내부 메모리 갱신으로 이어지지 않는다는 점이다. RC(Root Complex) 입장에서 BAR에 write 하면 값이 써지고 남아 있으니까 메모리가 갱신되었다고 생각하겠지만, EP(Endpoint) 입장에서는 반드시 그렇지 않다. 이 장치는 write 콜백 시 값을 저장하지 않으며, 단지 특정 주소에 특정 조건의 접근이 발생했음을 알리는 트리거로 해석하여 테스트 동작 수행과 내부 상태 갱신에 활용한다.

 

 

그리고 곳곳에 le32_to_cpu()와 cpu_to_le32()가 사용되고 있는데 MemoryRegionOps.endianness이 littel-endian으로 설정되어 있기 때문에, 게스트가 어떤 엔디안으로 접근하든 상관없이 디바이스의 read/write 콜백에는 little-endian으로 해석된 값이 들어온다. 그리고 QEMU를 실행하는 호스트 CPU가 little-endian인 경우 결과적으로 이 함수는 no-op 코드로 동작한다. 그럼에도 불구하고 저 함수를 쓰는 이유는 바이트 단위를 초과하는 레지스터 값에 대해 CPU 엔디안과 상관없이 일관되게 처리하기 위한 선택이다.

 

static void pci_testdev_write(void *opaque, hwaddr addr, uint64_t val, unsigned size, int type)
{
    PCITestDevState *d = opaque;
    IOTest *test;
    int t, r;

    if (addr == offsetof(PCITestDevHdr, test)) {
        pci_testdev_reset(d);
        if (val >= IOTEST_MAX_TEST) {
            return;
        }
        t = type * IOTEST_MAX_TEST + val;
        r = pci_testdev_start(&d->tests[t]);
        if (r < 0) {
            return;
        }
        d->current = t;
        return;
    }
    if (d->current < 0) {
        return;
    }
    test = &d->tests[d->current];
    if (addr != le32_to_cpu(test->hdr->offset)) {
        return;
    }
    if (test->match_data && test->size != size) {
        return;
    }
    if (test->match_data && val != test->hdr->data) {
        return;
    }
    pci_testdev_inc(test, 1);
}

 

 


다음은 read 콜백 함수이다. read 접근 시에는 오프셋 0부터 테스트의 메타데이터인 PCITestDevHdr를 바이트 단위로 노출해서 테스트 상태를 확인할 수 있도록 한다.

 

static uint64_t pci_testdev_read(void *opaque, hwaddr addr, unsigned size)
{
    PCITestDevState *d = opaque;
    const char *buf;
    IOTest *test;
    if (d->current < 0) {
        return 0;
    }
    test = &d->tests[d->current];
    buf = (const char *)test->hdr;
    if (addr + size >= test->bufsize) {
        return 0;
    }
    if (test->hasnotifier) {
        event_notifier_test_and_clear(&test->notifier);
    }
    return buf[addr];
}

 

 

 

✓ PCIe BAR의 실체

PCIe BAR는 흔히 장치의 내부의 메모리와 1:1로 대응되는 것으로 생각할 수 있다. 실제 그런 케이스도 존재하지만, 위의 pci-testdev 케이스에서 보듯이 항상 그런 것만은 아니다.

 

RC(Root Complex)가 EP(Endpoint)의 BAR 주소에 접근했을 때 발생하는 동작은 전적으로 장치 내부 로직 구현에 달려 있다. 동일한 BAR 주소라 하더라도 위 사례처럼 Write 시에는 내부 상태를 제어하고, Read 시에는 전혀 다른 값을 반환하도록 설계할 수 있다.

즉, BAR는 단순히 "메모리를 노출하는 것"이 아니라 "접근 가능한 주소 공간을 제공하는 것"으로 이해하길 바란다. 해당 주소 영역 뒤에 실제 RAM을 연결할지, 혹은 특정 로직 레지스터나 상태 머신을 연결할지는 온전히 PCIe 장치 설계자의 선택이다. 실제로 동일 주소에 대해 Read와 Write가 논리적으로 분리된 메모리 맵 구조를 가지는 장치들도 꽤 존재한다.

 

 

 

 

 

 

 

pci-testdev 장치 올려서 테스트 해보기

qemu-system-* 바이너리에 -device help 명령을 사용하면 현재 사용 가능한 장치 목록을 볼 수 있다.

 

$ qemu/build/qemu-system-arm -device help | grep pci-testdev
name "pci-testdev", bus PCI, desc "PCI Test Device"

 

 

 
 

다음 명령으로 QEMU 시스템 에뮬레이션을 시작한다. 이 때, -device pci-testdev 옵션을 통해 pci-testdev 장치를 추가했고, virt 머신 선택 시 highmem 옵션을 off로 설정했다.

 

$ qemu/build/qemu-system-arm \
-M virt,highmem=off \
-m 512M \
-kernel linux-6.12.65/arch/arm/boot/zImage \
-initrd rootfs.cpio.gz \
-append "console=ttyAMA0" \
-device pci-testdev \
-nographic

 

highmem 옵션이 켜져 있으면 QEMU는 기본적으로 RAM과 PCI 장치들을 4GB(32비트) 이상의 주소 영역에 배치하는데, 32비트 CPU를 사용하면서 커널 설정에 CONFIG_LPAE 옵션(32비트 CPU가 4GB 이상 주소를 다룰 수 있게 해주는 기능)이 enabled 되어있지 않은 경우에는 PCI 장치 초기화에 실패한다.

 

이 때는 highmem 옵션을 끄고, 모든 장치와 RAM을 4GB 이하의 32비트 주소 공간에 배치하도록 강제하여 쓰라고 설명하고 있다.

 

 

 

 

lspci 명령을 통해 인식된 장치를 확인한다.

QEMU PCI 장치들의 ID 목록 : https://www.qemu.org/docs/master/specs/pci-ids.html

 

1b36:0008(VID:DID)이 Host Bridge고 1b36:0005가 pci-testdev이다.

 

 

 

 

다음 명령으로 00:02.0(Bus:Dev.Func) 장치의 BAR0은 0x10045000 ~ 0x10045FFF 주소를 사용하는 것을 알 수 있다.

 

$ cat /sys/bus/pci/devices/0000:00:02.0/resource
0x0000000010045000 0x0000000010045fff 0x0000000000040200
0x0000000000001000 0x00000000000010ff 0x0000000000040101
0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 0x0000000000000000

# [start_addr] [end_addr] [flags]

 

 

devmem*을 통해 장치의 테스트 기능이 동작하는지 확인해보자.

 

  • /dev/mem : 물리 주소 전체를 그대로 노출하는 장치 파일
  • devmem : /dev/mem을 mmap() 해서 특정 물리 주소를 읽고/쓰는 툴

 

devmem <address> <width> [value]

 

 

먼저 PCI 장치를 활성화해준다. 드라이버를 작성하면 pci_enable_device() 함수 호출 시 Command 레지스터의 Bit 0 (I/O Space Enable) 또는 Bit 1 (Memory Space Enable)이 1로 셋되는데 지금은 드라이버 없이 devmem으로 테스트하기 때문에 강제로 켜줘야한다.

 

# 1. PCI 장치 활성화
# Command 레지스터(오프셋 4)에 0x03(Memory + I/O Enable) 기록
echo -ne "\x03" | dd of=/sys/bus/pci/devices/0000:00:02.0/config bs=1 seek=4 count=1 conv=notrunc

 

 

 

그럼 이제 본격적으로 테스트 기능을 써보자.

 

위에서 잠깐 설명한 것처럼 이 장치는 BAR 특정 오프셋에 특정 값을 쓰면 내부 카운터가 1씩 증가하는 테스트 기능을 가지고 있고, 테스트의 메타데이터를 레지스터 형태로 노출해서 MMIO와 Port IO의 read/write 동작을 검증할 수 있도록 만들어진 장치다.

 

typedef struct PCITestDevHdr {
    uint8_t test; 		// 테스트 종류 (0~2, 테스트 시작을 위해 write 필요), RW
    uint8_t width;		// access width (1 Byte), RO
    uint8_t pad0[2];	// 구조체 정렬(padding)
    uint32_t offset;	// 테스트 대상 Offset 위치, RO
    uint8_t data;		// 테스트용 match data 확인, RO
    uint8_t pad1[3];	// 구조체 정렬(padding)
    uint32_t count;	    // BAR base + offset에 match data를 write할 때마다 1씩 증가하는 카운터, RO
    uint8_t name[];		// 테스트 이름 문자열, RO
} PCITestDevHdr;

 

 

# 2. 0번 테스트 설정
$ devmem 0x10045000 8 0

# 3. 미리 정의된 오프셋 0x800에 미리 정의된 match data 0xFA를 씀
$ devmem 0x10045800 8 0xFA

# 4.PCITestDevHdr의 count가 증가됐는지 확인
$ devmem 0x1004500c 32

 

 

테스트 기능은 잘 동작한다.

 

 

 

 

 

 

pcituils 크로스컴파일 및 루트파일시스템에 포함

 

BusyBox는 경량 루트파일시스템을 구성하기 위한 도구 모음이다보니 다양한 유틸리티를 하나의 바이너리로 제공하는 대신, 각 명령의 기능이 축소되는 경우가 많다. 대표적으로 lspci가 그렇다.

 

BusyBox의 lspci는 쓸 수 있는 옵션이 거의 없다시피 해서(-m, -k만 지원) 우리가 알고있는 lspci 생각하고 쓰다보면 불편한 상황이 자주 발생한다.

 

$ lspci --help
BusyBox v1.36.1 (2026-01-17 23:23:17 KST) multi-call binary.

Usage: lspci [-mk]

List all PCI devices

	-m	Parsable output
	-k	Show driver

 

 

다행히도 리눅스 배포판에 포함되는 lspci, setpci는 pciutils라는 패키지에 포함된 도구들이고, pciutils은 오픈소스이기 때문에 타겟 환경에 맞게 크로스 컴파일하여 루트파일시스템에 그대로 포함시킬 수 있다. 아래에서는 해당 절차에 대해 기술한다.

 

(참고: https://www.youtube.com/@johannes4gnu_linux96)

 

 

1. 소스 준비

$ cd <설치할 경로>
$ git clone https://github.com/pciutils/pciutils.git
$ cd pciutils

 

 

2. Makefile 수정

$ vim Makefile

 

 

Host를 linux로 설정하고 CROSS_COMPILE에 사용할 툴체인 경로를 지정한다. 또한 ZLIB, DNS, LIBKMOD, HWDB와 같이 외부 라이브러리에 의존하는 고급 기능들은 모두 비활성화한다. 그리고 SHARED 옵션도 no로 설정해 libpci를 공유 라이브러리로 분리하지 않고, 실행파일에 정적으로 포함되도록 빌드하였다.

 

# Host OS and release (override if you are cross-compiling)
HOST=linux
RELEASE=
CROSS_COMPILE=/home/jo/workspace/qemu-workspace/arm-gnu-toolchain/bin/arm-none-linux-gnueabihf-

# Support for compressed pci.ids (yes/no, default: detect)
ZLIB=no

# Support for resolving IDs by DNS (yes/no, default: detect)
DNS=no

# Build libpci as a shared library (yes/no; or local for testing; requires GCC)
SHARED=no

# Use libkmod to resolve kernel modules on Linux (yes/no, default: detect)
LIBKMOD=no

# Use libudev to resolve device names using hwdb on Linux (yes/no, default: detect)
HWDB=no

 

 

3. 빌드

# 1. 빌드
$ make

# 2. 설치
$ cp lspci ../../rootfs/bin/mylspci
$ cp setpci ../../rootfs/bin/mysetpci

 

이후 lspci와 setpci가 타겟시스템에 맞게 생성된다.

 

$ file lspci
lspci: ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-armhf.so.3, for GNU/Linux 3.2.0, with debug_info, not stripped
$ file setpci
setpci: ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-armhf.so.3, for GNU/Linux 3.2.0, with debug_info, not stripped

 

 

다만, readelf -d 명령으로 바이너리의 Dynamic Section을 확인해보면 실행시 필요한 공유 라이브러리(NEEDED) 목록을 확인할 수 있는데, libc.so.6을 필요로 하고있다.

libpci는 실행 파일에 포함해 정적으로 빌드했지만 표준 C 함수를 쓰기위한 libc는 동적 연결을 시도하기 때문이다.

 

 

 

 

4. 의존성 해결

libc는 툴체인 안에 있으니 찾아서 rootfs/lib/ 경로에 복사해준다.

 

$ cd <workspace 경로>

# 1. libc 경로 확인
$ find . -name "libc.so.*"
./arm-gnu-toolchain/arm-none-linux-gnueabihf/libc/lib/libc.so.6

# 2. libc 공유 라이브러리 의존성 확인 (ld-linux-armhf.so.3를 필요로 한다.)
$ readelf -d ./arm-gnu-toolchain/arm-none-linux-gnueabihf/libc/lib/libc.so.6

# 3. ld-linux-armhf.so.3 경로 확인
$ find . -name "ld-linux-armhf.so.3"
./arm-gnu-toolchain/arm-none-linux-gnueabihf/libc/lib/ld-linux-armhf.so.3

# 4. ld-linux-armhf.so.3 공유 라이브러리 의존성 확인 (NEEDED 항목 없음)
$ readelf -d ./arm-gnu-toolchain/arm-none-linux-gnueabihf/libc/lib/ld-linux-armhf.so.3

# 5. rootfs/lib 생성
$ mkdir -p rootfs/lib

# 6. 라이브러리 복사
$ cp ./arm-gnu-toolchain/arm-none-linux-gnueabihf/libc/lib/libc.so.6 rootfs/lib/
$ cp ./arm-gnu-toolchain/arm-none-linux-gnueabihf/libc/lib/ld-linux-armhf.so.3 rootfs/lib/

 

 

 

5. initramfs 이미지 다시 생성

$ cd rootfs/
$ find . -print0 | cpio --null -ov --format=newc | gzip -9 > ../rootfs.cpio.gz
$ cd ..

 

 

6. QEMU 실행

$ qemu/build/qemu-system-arm \
-M virt,highmem=off \
-m 512M \
-kernel linux-6.12.65/arch/arm/boot/zImage \
-initrd rootfs.cpio.gz \
-append "console=ttyAMA0" \
-device pci-testdev \
-nographic

 

 

7. 확인

다음은 setpci를 사용해 PCI Configuration space의 COMMAND 레지스터(0x04)의 Bit 0, Bit 1을 켜서 MMIO/Port IO 접근을 enabled하고 lspci로 결과를 확인해 본 것이다.

 

 

 

 

 

 

 

 
 
반응형