임베디드 리눅스 개발은 보드가 손에 있어야 시작되는 일이 많다. 부트로더를 잘못 덮어써 보드를 못 쓰게 만들거나, SD 카드를 다시 굽느라 시간을 흘려보내거나, 사람 수보다 보드가 적어 순서를 기다리는 상황이 반복된다. QEMU의 arm64 virt 머신은 부트로더부터 커널·루트 파일시스템까지 통째로 올릴 수 있는 타깃을 파일 몇 개로 만들어주고, 망가지면 이미지를 되돌리면 끝이다. 이 시리즈에서는 여섯 편에 걸쳐 실제 개발보드처럼 쓸 수 있는 QEMU(arm64) 타깃 디바이스를 만든다. 첫 편인 이 글에서는 어떤 가상 보드를 고를지, 타깃이 쓸 디스크 이미지를 어떻게 만들고 호스트에서 어떻게 열지를 정리한다.
이 시리즈의 다른 글
- QEMU(arm64) 임베디드 리눅스 타깃 만들기 (2) — U-Boot 올리기
- QEMU(arm64) 임베디드 리눅스 타깃 만들기 (3) — 커널 크로스 컴파일과 부팅
- QEMU(arm64) 임베디드 리눅스 타깃 만들기 (4) — 루트 파일 시스템과 패키지 설치
- QEMU(arm64) 임베디드 리눅스 타깃 만들기 (5) — Yocto로 타깃 이미지 빌드
- QEMU(arm64) 임베디드 리눅스 타깃 만들기 (6) — 커널 모듈, gdb 원격 디버깅, NFS 루트
작업 환경은 Ubuntu 24.04 x86_64 호스트에 QEMU 8.2.2, gcc-aarch64-linux-gnu 13.3.0을 설치한 상태를 기준으로 한다.
sudo apt install -y qemu-system-arm qemu-utils gcc-aarch64-linux-gnu \
device-tree-compiler parted dosfstools jq
mkdir -p ~/qemu-target && cd ~/qemu-target어떤 가상 보드를 쓸 것인가
QEMU의 aarch64 시스템 에뮬레이터는 실제 SoC를 흉내 내는 머신과 가상 보드를 모두 제공한다. 특정 하드웨어의 부팅 동작을 재현하려면 raspi3b 같은 실물 대응 머신이, 개발용 타깃을 만들려면 virt가 맞다.
raspi3b 같은 실물 머신 | virt | |
|---|---|---|
| 대응 하드웨어 | 라즈베리파이 3B 등 실제 보드 | 없음(linux,dummy-virt) |
| 메모리·코어 | 실물과 동일하게 고정 | -m/-smp로 자유롭게 |
| 주변장치 | SoC에 있는 것만 | virtio 장치를 필요한 만큼 |
| 펌웨어 | 실물 부팅 파일 필요 | -bios로 원하는 코드 직접 지정 |
| 쓰임새 | 보드 고유 동작 재현 | 부트로더·커널·rootfs 개발 |
라즈베리파이 이미지를 그대로 올리는 방법은 QEMU(arm64)에 라즈베리파이 올리기에서 따로 다뤘다. virt는 버전별로 이름이 나뉘어 있어서, 재현성이 필요하면 별칭 대신 버전을 박아 쓴다.
$ qemu-system-aarch64 -M help | grep '^virt '
virt QEMU 8.2 ARM Virtual Machine (alias of virt-8.2)
QEMU를 업그레이드하면 virt가 가리키는 실체가 바뀌어 장치 구성이 달라질 수 있다. 스크립트에는 -M virt-8.2처럼 고정해두는 편이 안전하다.
타깃 디스크 이미지 만들기
실물 보드의 SD 카드에 해당하는 것이 디스크 이미지 파일이다. 부트로더가 읽을 FAT 파티션과 루트 파일시스템용 ext4 파티션 두 개로 나눈다.
qemu-img create -f raw target.img 1G
parted -s target.img mklabel msdos \
mkpart primary fat32 1MiB 65MiB set 1 boot on \
mkpart primary ext4 65MiB 100%
parted -s target.img printModel: (file)
Disk /home/noble/qemu-target/target.img: 1074MB
Sector size (logical/physical): 512B/512B
Partition Table: msdos
Disk Flags:
Number Start End Size Type File system Flags
1 1049kB 68.2MB 67.1MB primary boot, lba
2 68.2MB 1074MB 1006MB primary
첫 파티션을 1MiB에서 시작시킨 것은 앞쪽 섹터를 부트로더 이미지가 쓸 수 있게 비워두기 위해서다. 파일 안의 파티션에 파일 시스템을 만들려면 루프 장치로 붙여 커널에 파티션을 인식시킨다.
#!/bin/bash
set -e
LOOP=$(sudo losetup --find --show --partscan target.img)
echo "LOOP=$LOOP"
lsblk "$LOOP"
sudo mkfs.vfat -F 32 -n BOOT "${LOOP}p1" > /dev/null
sudo mkfs.ext4 -q -L rootfs "${LOOP}p2"
sudo mkdir -p /mnt/tb /mnt/tr
sudo mount "${LOOP}p1" /mnt/tb
sudo mount "${LOOP}p2" /mnt/tr
echo hello-boot | sudo tee /mnt/tb/README > /dev/null
df -h /mnt/tb /mnt/tr
sudo umount /mnt/tb /mnt/tr
sudo losetup -d "$LOOP"LOOP=/dev/loop4
NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINTS
loop4 7:4 0 1G 0 loop
├─loop4p1 259:0 0 64M 0 part
└─loop4p2 259:1 0 959M 0 part
Filesystem Size Used Avail Use% Mounted on
/dev/loop4p1 63M 1.0K 63M 1% /mnt/tb
/dev/loop4p2 926M 24K 862M 1% /mnt/tr
--partscan이 없으면 /dev/loop4만 생기고 p1, p2가 나타나지 않아 마운트할 대상이 없다.
raw와 qcow2 중 무엇을 쓸까
| raw | qcow2 | |
|---|---|---|
| 호스트에서 열기 | losetup으로 바로 | qemu-nbd 경유 |
| 실제 사용 용량 | sparse 파일로 쓴 만큼만 | 쓴 만큼만 + 메타데이터 |
| 스냅샷 | 없음 | 내부 스냅샷·backing 체인 |
| 실물 보드로 굽기 | dd로 바로 | qemu-img convert 필요 |
| 적합한 용도 | 최종 배포 이미지 | 반복 실험용 작업 이미지 |
$ du -h target.img # 파티션·mkfs까지 마친 raw
18M target.img
$ du -h --apparent-size target.img
1.0G target.img
$ qemu-img convert -f raw -O qcow2 target.img target.qcow2
$ du -h target.qcow2
1.6M target.qcow2
1GiB로 만든 raw 이미지가 18MB만 차지하는 것은 sparse 파일이기 때문이고, qcow2는 여기서 빈 블록까지 걷어내 1.6MB로 줄어든다. 실험용으로는 이 이미지를 원본으로 두고 위에 얇은 오버레이를 얹는 방식이 편하다.
qemu-img create -f qcow2 -F qcow2 -b target.qcow2 work.qcow2
qemu-img info work.qcow2 | head -8image: work.qcow2
file format: qcow2
virtual size: 1 GiB (1073741824 bytes)
disk size: 196 KiB
cluster_size: 65536
backing file: target.qcow2
backing file format: qcow2
오버레이는 196KiB짜리 빈 파일로 시작해 변경된 블록만 담는다. 타깃을 망가뜨렸으면 work.qcow2만 지우고 다시 만들면 원본 상태로 즉시 돌아간다.
qcow2 이미지를 호스트에서 열기
커널 이미지나 설정 파일을 타깃 이미지에 넣으려면 호스트에서 마운트해야 하는데, losetup은 raw만 다룬다. qcow2는 qemu-nbd로 블록 장치로 노출시킨다.
#!/bin/bash
set -e
sudo modprobe nbd max_part=8
sudo qemu-nbd --connect=/dev/nbd0 work.qcow2
sleep 1
lsblk /dev/nbd0
sudo mount /dev/nbd0p1 /mnt/tb
ls -l /mnt/tb
sudo umount /mnt/tb
sudo qemu-nbd --disconnect /dev/nbd0NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINTS
nbd0 43:0 0 1G 0 disk
├─nbd0p1 43:1 0 64M 0 part
└─nbd0p2 43:2 0 959M 0 part
total 1
-rwxr-xr-x 1 root root 11 Aug 25 16:47 README
/dev/nbd0 disconnected
오버레이를 붙였는데도 원본 raw에 넣어둔 README가 보이는 것은 backing 체인을 따라 읽기 때문이다. NBD 자체는 Network Block Device 사용법에 정리해뒀다.
보드에 장치 붙이기
이미지가 준비됐으니 virt 보드에 디스크와 네트워크를 붙인다. 시리즈 내내 같은 명령을 반복하게 되므로 스크립트로 만들어둔다.
#!/bin/bash
# QEMU arm64 타깃 공통 실행 스크립트 (시리즈 전체에서 재사용)
set -e
IMG=${IMG:-work.qcow2}
MEM=${MEM:-1G}
SMP=${SMP:-2}
exec qemu-system-aarch64 \
-M virt -cpu cortex-a57 -m "$MEM" -smp "$SMP" \
-drive if=none,file="$IMG",format=qcow2,id=hd0 \
-device virtio-blk-device,drive=hd0 \
-netdev user,id=net0,hostfwd=tcp::2222-:22 \
-device virtio-net-device,netdev=net0 \
-nographic "$@"| 옵션 | 의미 |
|---|---|
-drive if=none,...,id=hd0 | 백엔드(이미지 파일)만 등록하고 자동 연결은 하지 않음 |
-device virtio-blk-device | 등록한 백엔드를 프론트엔드 장치로 보드에 연결 |
-netdev user,hostfwd=tcp::2222-:22 | 호스트 2222 포트를 타깃 22번으로 포워딩(4편에서 사용) |
-nographic | 그래픽 창 없이 시리얼 콘솔을 현재 터미널에 연결 |
-cpu cortex-a57 | ARMv8.0 코어로 고정. max는 QEMU가 아는 기능을 전부 켬 |
장치가 제대로 붙었는지 확인하기
모니터를 -monitor stdio로 열면 입력 에코가 섞여 로그가 지저분해진다. QMP에 human-monitor-command를 실어 보내고 jq로 문자열만 뽑으면 스크립트에서 쓰기 좋은 출력이 나온다.
#!/bin/bash
# 부팅시키지 않고(-S) 장치 구성만 조회한다
IMG=${IMG:-work.qcow2}
hmp() { printf '{"execute":"human-monitor-command","arguments":{"command-line":"%s"}}\n' "$1"; }
{ echo '{"execute":"qmp_capabilities"}'
hmp "info block"
hmp "info network"
echo '{"execute":"quit"}'
} | qemu-system-aarch64 \
-M virt -cpu cortex-a57 -m 1G -smp 2 \
-drive if=none,file="$IMG",format=qcow2,id=hd0 \
-device virtio-blk-device,drive=hd0 \
-netdev user,id=net0,hostfwd=tcp::2222-:22 \
-device virtio-net-device,netdev=net0 \
-display none -S -qmp stdio \
| jq -r 'select(.return|type=="string")|.return'hd0 (#block146): work.qcow2 (qcow2)
Attached to: /machine/peripheral-anon/device[0]
Cache mode: writeback
Backing file: target.qcow2 (chain depth: 1)
floppy0: [not inserted]
Removable device: not locked, tray closed
virtio-net-device.0: index=0,type=nic,model=virtio-net-device,macaddr=52:54:00:12:34:56
\ net0: index=0,type=user,net=10.0.2.0,restrict=off
오버레이가 backing 체인 depth 1로 물려 있고, 사용자 모드 네트워크가 10.0.2.0 대역으로 붙은 것까지 확인된다.
블록 장치를 virtio-blk-device로 붙일지 virtio-blk-pci로 붙일지에 따라 타깃 커널에 켜야 할 드라이버가 달라진다. 어느 버스에 올라가는지는 QEMU를 멈춘 상태(-S)로 띄워 모니터에 물어보면 된다.
#!/bin/bash
# 블록 장치가 어느 버스에 붙는지 비교한다
for DEV in virtio-blk-device virtio-blk-pci; do
echo "===== $DEV"
{ echo '{"execute":"qmp_capabilities"}'
printf '{"execute":"human-monitor-command","arguments":{"command-line":"info qtree"}}\n'
echo '{"execute":"quit"}'
} | qemu-system-aarch64 -M virt -m 512 \
-drive if=none,file=work.qcow2,format=qcow2,id=hd0 \
-device "$DEV",drive=hd0 -display none -S -qmp stdio \
| jq -r 'select(.return|type=="string")|.return' \
| grep -B2 "dev: $DEV"
done===== virtio-blk-device
bus: virtio-mmio-bus.31
type virtio-mmio-bus
dev: virtio-blk-device, id ""
===== virtio-blk-pci
bus: pcie.0
type PCIE
dev: virtio-blk-pci, id ""
이름이 -device로 끝나는 쪽은 virtio-mmio 슬롯에, -pci로 끝나는 쪽은 PCIe 버스에 올라간다. 커널 설정을 최소로 줄일 계획이라면 PCI 서브시스템 없이도 동작하는 mmio 쪽이 가볍다.
보드가 커널에 넘겨주는 정보
virt는 실물 보드와 달리 고정된 DTB 파일이 없고, 실행 옵션에 맞춰 부팅할 때마다 디바이스 트리를 만들어 커널에 넘긴다. 그 내용은 dumpdtb로 꺼내볼 수 있다.
qemu-system-aarch64 -M virt -cpu cortex-a57 -m 1G -smp 2 \
-machine dumpdtb=virt.dtb
dtc -I dtb -O dts virt.dtb > virt.dts
grep -n 'memory@\|virtio_mmio@a000000 \|pl011@\|stdout-path' virt.dtsqemu-system-aarch64: info: dtb dumped to virt.dtb. Exiting.
19: memory@40000000 {
38: virtio_mmio@a000000 {
307: pl011@9000000 {
397: stdout-path = "/pl011@9000000";
RAM은 0x4000_0000부터, virtio-mmio 슬롯은 0x0a00_0000부터 0x200 간격으로 32개가 잡혀 있고 콘솔은 PL011 UART다. 다음 편부터 부트로더와 커널에 넣을 주소값이 전부 여기서 나온다. 디바이스 트리 문법 자체는 Device Tree 이해를 참고하면 된다.
아직은 부팅되지 않는다
$ timeout 5 ./run-target.sh
qemu-system-aarch64: terminating on signal 15 from pid 2989 (timeout)
$ echo $?
124
보드는 켜졌지만 한 글자도 나오지 않는다. virt는 x86처럼 내장 펌웨어가 없어서 -bios나 -kernel로 실행할 코드를 직접 넣어줘야 하고, 그 자리를 채우는 것이 다음 편의 U-Boot다.
주의사항
-M virt는 QEMU 버전에 따라 실체가 바뀌는 별칭이다. 몇 달 뒤에도 같은 결과를 얻으려면-M virt-8.2처럼 버전을 고정한다.losetup -d와qemu-nbd --disconnect를 빠뜨리면 루프/NBD 장치가 이미지를 계속 잡고 있어, 같은 이미지를 QEMU로 열었을 때 파일 시스템이 깨질 수 있다.- QEMU가 이미지를 쓰고 있는 동안 호스트에서 그 이미지를 마운트하면 안 된다. 양쪽이 각자 캐시를 들고 쓰기 때문에 내용이 어긋난다.
- sparse 이미지를 옮길 때
cp --sparse=always나rsync -S를 쓰지 않으면 1GiB를 통째로 복사하게 된다. - backing 체인을 쓰는 동안 원본
target.qcow2를 옮기거나 수정하면 오버레이가 깨진다. 원본을 손봐야 하면qemu-img commit으로 오버레이를 합친 뒤에 한다. - x86 호스트에서 arm64는 KVM 없이 TCG로 돌아 실행 속도가 실물보다 훨씬 느리다. 커널 빌드 같은 무거운 작업은 타깃 안이 아니라 호스트에서 크로스 컴파일로 처리한다.
hostfwd는 포워딩 규칙만 걸어둘 뿐이라, 타깃에 SSH 서버가 뜨기 전까지 2222 포트로 접속하면 연결이 거부된다.
마무리
타깃 디바이스의 뼈대에 해당하는 것들을 만들었다. 파티션을 나눠 파일 시스템까지 올린 디스크 이미지, 실험할 때마다 되돌릴 수 있는 qcow2 오버레이, 시리즈 내내 재사용할 run-target.sh, 그리고 부트로더와 커널에 넣을 주소값을 담은 디바이스 트리다. 다음 편에서는 U-Boot를 크로스 빌드해 -bios로 올리고, 이 디스크 이미지의 FAT 파티션에서 부팅 파일을 읽게 만든다.