QEMU(arm64)에 라즈베리파이 올리기

라즈베리파이 OS의 설정을 조금 바꿔볼 때마다 SD 카드를 굽고 보드에 꽂고 전원을 넣는 건 느리다. 보드가 아직 손에 없거나, 망가뜨려도 되는 환경이 필요할 때도 있다. QEMU의 raspi3b 머신은 배포된 이미지를 그대로 부팅할 수 있어서 이 과정을 노트북 안에서 끝낼 수 있다. 다만 이미지를 그대로 물리면 부팅되지 않고, 리셋 루프와 시리얼 콘솔 문제를 차례로 넘어야 한다. 이 글에서는 라즈베리파이 OS Lite(arm64)를 로그인 프롬프트까지 올리는 과정을 정리한다.

이미지와 부팅 파일 준비

curl -LO https://downloads.raspberrypi.com/raspios_lite_arm64/images/\
raspios_lite_arm64-2026-06-19/2026-06-18-raspios-trixie-arm64-lite.img.xz
xz -dk 2026-06-18-raspios-trixie-arm64-lite.img.xz
mv 2026-06-18-raspios-trixie-arm64-lite.img rpios.img
fdisk -l rpios.img
Device     Boot   Start     End Sectors  Size Id Type
rpios.img1        16384 1064959 1048576  512M  c W95 FAT32 (LBA)
rpios.img2      1064960 5816319 4751360  2.3G 83 Linux

QEMU의 raspi 머신은 SD 카드 크기가 2의 거듭제곱일 것을 요구하므로 이미지를 4 GiB로 늘린다.

qemu-img resize -f raw rpios.img 4G

LOOP=$(sudo losetup -f -P --show rpios.img)
sudo mount ${LOOP}p1 /mnt
cp /mnt/kernel8.img /mnt/bcm2710-rpi-3-b.dtb /mnt/overlays/disable-bt.dtbo .
sudo umount /mnt

커널과 DTB를 이미지 안에서 꺼내오는 이유는, QEMU가 라즈베리파이 펌웨어를 실행하지 않기 때문이다. 실제 보드라면 start4.elfconfig.txt를 읽어 커널과 DTB를 골라 올리지만 QEMU에서는 그 단계가 없어 -kernel·-dtb로 직접 넘겨야 한다.

첫 시도 — 리셋 루프

qemu-system-aarch64 -M raspi3b -nographic \
    -kernel kernel8.img -dtb bcm2710-rpi-3-b.dtb \
    -drive file=rpios.img,if=sd,format=raw \
    -append "root=/dev/mmcblk0p2 rootfstype=ext4 rw rootwait console=ttyAMA1,115200"

커널은 잘 뜨고 루트 파일시스템도 마운트되는데, systemd가 시작한 직후 아무 메시지 없이 보드가 리셋된다. 그리고 같은 과정을 무한히 반복한다.

[   14.535004] systemd[1]: Detected first boot.
Welcome to Debian GNU/Linux 13 (trixie)!
[   14.714569] systemd[1]: Hostname set to <raspberrypi>.
[   16.636535] systemd[1]: bpf-restrict-fs: BPF LSM hook not enabled in the kernel, ...
[    3.189437] printk: console [ttyAMA1] enabled      <-- 여기서 다시 처음부터

$ grep -ac "Run /sbin/init" console.log
53

init=/bin/sh로 바꿔 부팅하면 셸에서 멈춘 채 리셋되지 않는다. 즉 커널이 아니라 systemd가 시작하는 무언가가 원인이다. 이미지의 rootfs를 열어보면 답이 있다.

$ grep -rn RuntimeWatchdog /mnt/usr/lib/systemd/system.conf.d/
40-rpi-enable-watchdog.conf:2:RuntimeWatchdogSec=1m
40-rpi-enable-watchdog.conf:3:RebootWatchdogSec=2m

라즈베리파이 OS는 BCM2835 하드웨어 워치독을 켜둔다. QEMU가 흉내 낸 워치독에 systemd가 이걸 물리는 순간 머신이 리셋된다. 드롭인으로 덮어쓴다.

[Manager]
RuntimeWatchdogSec=off
RebootWatchdogSec=off

파일 이름이 99-인 것이 중요하다. systemd 드롭인은 디렉터리와 무관하게 파일명 사전순으로 합쳐지고 뒤에 오는 쪽이 이긴다. 00-으로 만들면 40-rpi-enable-watchdog.conf에 그대로 덮여 리셋 루프가 계속된다.

시리얼 콘솔에 로그인 프롬프트 띄우기

리셋이 멈추면 부팅은 끝까지 가는데, 이번에는 로그인 프롬프트가 tty1에만 뜨고 시리얼에는 나오지 않는다. 시리얼 getty를 켜도 마찬가지다.

[ TIME ] Timed out waiting for device dev-ttyAMA1.device - /dev/ttyAMA1.
[DEPEND] Dependency failed for serial-getty@ttyAMA1.service - Serial Getty on ttyAMA1.

라즈베리파이 3에서 PL011 UART는 블루투스 전용이고 사용자 콘솔은 mini UART가 맡는다. 그런데 QEMU에서는 mini UART 드라이버가 probe failed with error -22로 올라오지 않고, PL011은 블루투스 serdev에 묶여 getty가 쓸 tty 노드가 생기지 않는다. 실제 보드에서 이 배선을 바꿀 때 쓰는 disable-bt 오버레이를 DTB에 미리 합쳐두면 해결된다.

fdtoverlay -i bcm2710-rpi-3-b.dtb -o bcm2710-rpi-3-b-nobt.dtb disable-bt.dtbo
dtc -I dtb -O dts bcm2710-rpi-3-b-nobt.dtb | grep -A3 'aliases {'
적용 전                                   적용 후
serial0 = "/soc/serial@7e215040"   →   serial0 = "/soc/serial@7e201000"
serial1 = "/soc/serial@7e201000"   →   serial1 = "/soc/serial@7e215040"

serial0이 mini UART(7e215040)에서 PL011(7e201000)로 바뀌었다. 이제 PL011이 ttyAMA0으로 잡히므로 여기에 getty를 붙인다.

# 루트 파티션: 시리얼 getty 활성화 + cloud-init 비활성화
sudo mount ${LOOP}p2 /mnt
sudo ln -sf /lib/systemd/system/serial-getty@.service \
    /mnt/etc/systemd/system/getty.target.wants/serial-getty@ttyAMA0.service
sudo touch /mnt/etc/cloud/cloud-init.disabled
sudo umount /mnt

# 부트 파티션: 첫 로그인용 계정
sudo mount ${LOOP}p1 /mnt
echo "pi:$(echo raspberry | openssl passwd -6 -stdin)" | sudo tee /mnt/userconf.txt
sudo umount /mnt

로그인

qemu-system-aarch64 -M raspi3b -nographic \
    -kernel kernel8.img -dtb bcm2710-rpi-3-b-nobt.dtb \
    -drive file=rpios.img,if=sd,format=raw \
    -append "root=/dev/mmcblk0p2 rootfstype=ext4 rw rootwait console=ttyAMA0,115200"
[  OK  ] Started serial-getty@ttyAMA0.service - Serial Getty on ttyAMA0.
[  OK  ] Reached target multi-user.target - Multi-User System.

Debian GNU/Linux 13 raspberrypi ttyAMA0

raspberrypi login: pi
Password:
Linux raspberrypi 6.18.34+rpt-rpi-v8 #1 SMP PREEMPT Debian 1:6.18.34-1+rpt1 aarch64

pi@raspberrypi:~$ cat /proc/device-tree/model; echo
Raspberry Pi 3 Model B
pi@raspberrypi:~$ cat /etc/os-release | head -2
PRETTY_NAME="Debian GNU/Linux 13 (trixie)"
NAME="Debian GNU/Linux"
pi@raspberrypi:~$ nproc; grep -m1 'CPU part' /proc/cpuinfo
4
CPU part	: 0xd03
pi@raspberrypi:~$ free -m | head -2
               total        used        free      shared  buff/cache   available
Mem:             917         124         735           3         110         792
pi@raspberrypi:~$ df -h / | tail -1
/dev/root       2.2G  2.0G   92M  96% /

게스트가 자기를 Raspberry Pi 3 Model B로 인식하고 CPU 파트 번호도 Cortex-A53의 0xd03으로 나온다. 전원 투입부터 로그인 프롬프트까지 실측 213초가 걸렸다.

raspi3b 머신의 한계

항목QEMU raspi3b
메모리1 GB 고정(게스트 인식 917 MB)
CPUCortex-A53 4코어, TCG 번역
네트워크장치 없음 — 게스트 IP는 127.0.1.1
SD 이미지크기가 2의 거듭제곱이어야 함
펌웨어실행되지 않음 — config.txt·오버레이 무시
로그인까지약 213초

주의사항

  • config.txtcmdline.txt는 펌웨어가 읽는 파일이라 QEMU에서는 아무 효과가 없다. 부트 인자는 -append로, dtoverlay는 위처럼 fdtoverlay로 DTB에 미리 합쳐서 넘겨야 한다.
  • 네트워크 장치가 없어 systemd-networkd-wait-online이 3분 넘게 붙잡고 있다가 실패한다. cloud-init까지 얹히면 부팅이 4분 이상 더 늘어나므로 cloud-init.disabled를 만들어두는 편이 낫다.
  • 펌웨어 메일박스에 의존하는 도구는 동작하지 않는다. vcgencmd measure_temp는 온도 대신 명령 이름만 되돌려준다.
  • 전부 TCG 번역이라 성능 측정에는 쓸 수 없다. 부팅 순서, 설정 파일, 스크립트 동작을 확인하는 용도로만 쓴다.
  • 이미지 수정은 게스트를 끄고 호스트에서 losetup -P로 붙여 하는 편이 빠르다. 게스트 안에서 고치려면 매번 재부팅에 수 분이 든다.
  • 워치독을 끄는 설정은 QEMU에서 돌리기 위한 것이다. 이 이미지를 그대로 실제 보드에 구우면 워치독이 꺼진 상태로 동작하니, 보드용 SD 카드는 따로 만든다.

마무리

막히는 지점은 세 개였다. 이미지 크기를 2의 거듭제곱으로 맞추는 것, systemd 워치독을 끄는 것, 그리고 disable-bt 오버레이로 시리얼 콘솔을 PL011로 옮기는 것이다. 셋 다 이미지를 호스트에서 마운트해 미리 손봐두면 되는 작업이라 한 번 만들어두면 이후로는 qemu-system-aarch64 한 줄로 3분 만에 라즈베리파이 OS 셸까지 들어갈 수 있다.

참고

답글 남기기