3편에서 커널이 ext4 루트를 마운트하는 데까지 갔지만 그 안이 mkfs 직후 상태라 No working init found로 멈췄다. 이제 그 빈 파티션을 채운다. 두 단계로 간다. 먼저 BusyBox 하나로 셸이 뜨는 최소 구성을 만들어 부팅 경로가 끝까지 이어지는지 확인하고, 그다음 apt로 패키지를 올릴 수 있는 데비안 루트 파일 시스템으로 갈아끼워 호스트에서 SSH로 붙는 데까지 만든다.
이 시리즈의 다른 글
- QEMU(arm64) 임베디드 리눅스 타깃 만들기 (1) — 타깃 디스크 이미지와 virt 보드 구성
- QEMU(arm64) 임베디드 리눅스 타깃 만들기 (2) — U-Boot 올리기
- QEMU(arm64) 임베디드 리눅스 타깃 만들기 (3) — 커널 크로스 컴파일과 부팅
- QEMU(arm64) 임베디드 리눅스 타깃 만들기 (5) — Yocto로 타깃 이미지 빌드
- QEMU(arm64) 임베디드 리눅스 타깃 만들기 (6) — 커널 모듈, gdb 원격 디버깅, NFS 루트
BusyBox 정적 빌드
라이브러리까지 챙길 필요가 없도록 정적으로 빌드한다. 바이너리 하나만 넣으면 셸부터 mount까지 다 되는 구성이라 초기 브링업에 쓰기 좋다.
cd ~/qemu-target
wget https://busybox.net/downloads/busybox-1.37.0.tar.bz2
tar xf busybox-1.37.0.tar.bz2 && cd busybox-1.37.0
export ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu-
make defconfig
sed -i 's/^# CONFIG_STATIC is not set/CONFIG_STATIC=y/' .config
sed -i 's/^CONFIG_SHA1_HWACCEL=y/# CONFIG_SHA1_HWACCEL is not set/;
s/^CONFIG_SHA256_HWACCEL=y/# CONFIG_SHA256_HWACCEL is not set/' .config
make oldconfig < /dev/null
make -j$(nproc)
file busyboxbusybox: ELF 64-bit LSB executable, ARM aarch64, version 1 (GNU/Linux),
statically linked, BuildID[sha1]=9bf6b00e..., for GNU/Linux 3.7.0, stripped
HWACCEL 두 줄을 끄지 않으면 x86 전용 함수를 참조해 빌드가 깨진다. 1.37.0에서 defconfig 그대로 arm64를 빌드하면 바로 만나는 오류다.
libbb/hash_md5_sha.c:1316:35: error: 'sha1_process_block64_shaNI' undeclared
1316 | || ctx->process_block == sha1_process_block64_shaNI
| ^~~~~~~~~~~~~~~~~~~~~~~~~~
최소 루트 파일 시스템 만들기
#!/bin/bash
set -e
BB=busybox-1.37.0/busybox
sudo qemu-nbd --connect=/dev/nbd0 work.qcow2
sleep 1
sudo mount /dev/nbd0p2 /mnt/tr
sudo mkdir -p /mnt/tr/{bin,sbin,etc,proc,sys,dev,tmp,root}
sudo cp "$BB" /mnt/tr/bin/busybox
# 애플릿 심볼릭 링크 — arm64 바이너리 실행은 qemu-user-static이 맡는다
for app in $(qemu-aarch64-static "$BB" --list); do
sudo ln -sf /bin/busybox "/mnt/tr/bin/$app"
done
sudo chmod 1777 /mnt/tr/tmp
sudo umount /mnt/tr
sudo qemu-nbd --disconnect /dev/nbd0#!/bin/sh
mount -t proc none /proc
mount -t sysfs none /sys
[ -e /dev/console ] || mount -t devtmpfs none /dev
echo
echo "=== qemu-target busybox rootfs ==="
uname -a
exec setsid cttyhack /bin/sh/dev는 커널이 CONFIG_DEVTMPFS_MOUNT로 이미 붙여둔 상태라 조건을 걸어두지 않으면 Device or resource busy가 뜬다. 마지막 줄의 setsid cttyhack이 없으면 셸이 제어 터미널을 못 잡아 can't access tty; job control turned off가 나온다.
[ 1.099709] EXT4-fs (vda2): mounted filesystem r/w with ordered data mode.
[ 1.103754] devtmpfs: mounted
[ 1.155233] Run /sbin/init as init process
=== qemu-target busybox rootfs ===
Linux (none) 6.12.0 #1 SMP PREEMPT aarch64 GNU/Linux
~ # busybox | head -2
BusyBox v1.37.0 (2026-08-25 23:34:38 UTC) multi-call binary.
~ # ls /bin | wc -l
403
~ # df -h /
Filesystem Size Used Available Use% Mounted on
/dev/root 925.6M 2.2M 859.5M 0% /
~ # ip link | head -4
1: lo: <LOOPBACK> mtu 65536 qdisc noop qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: eth0: <BROADCAST,MULTICAST> mtu 1500 qdisc noop qlen 1000
link/ether 52:54:00:12:34:56 brd ff:ff:ff:ff:ff:ff
바이너리 2.2MB로 애플릿 403개짜리 시스템이 떴고, 1편에서 붙인 virtio-net도 eth0으로 보인다. 부트로더 → 커널 → 루트 파일 시스템 → 셸까지 한 줄로 이어졌다.
데비안 루트 파일 시스템으로 갈아끼우기
BusyBox 구성은 패키지 관리가 없어서, 라이브러리 하나 추가하려면 매번 크로스 빌드해 복사해야 한다. 타깃 위에서 apt를 쓰려면 배포판 루트 파일 시스템이 필요하다. debootstrap은 호스트에서 다른 아키텍처의 rootfs를 만들 수 있는데, 2단계 설정은 arm64 바이너리를 실행해야 하므로 qemu-user-static과 binfmt_misc가 그 일을 대신한다.
$ cat /proc/sys/fs/binfmt_misc/qemu-aarch64 | head -3
enabled
interpreter /usr/libexec/qemu-binfmt/aarch64-binfmt-P
flags: POF
sudo apt install -y debootstrap qemu-user-static
sudo qemu-nbd --connect=/dev/nbd0 work.qcow2 && sleep 1
sudo mkfs.ext4 -q -F -L rootfs /dev/nbd0p2 # BusyBox rootfs를 밀고 다시 만든다
sudo mount /dev/nbd0p2 /mnt/tr
# 1단계: 패키지를 풀기만 한다 (호스트 아키텍처와 달라 설정은 못 한다)
sudo debootstrap --arch=arm64 --variant=minbase --foreign \
bookworm /mnt/tr http://deb.debian.org/debian
# 2단계: 타깃 바이너리를 QEMU로 돌려 설정까지 마친다
sudo cp /usr/bin/qemu-aarch64-static /mnt/tr/usr/bin/
sudo chroot /mnt/tr /debootstrap/debootstrap --second-stageI: Base system installed successfully.
$ sudo chroot /mnt/tr /bin/bash -c 'uname -m; cat /etc/debian_version'
aarch64
12.15
타깃 설정하기
#!/bin/bash
# chroot 안에서 실행한다
set -e
export DEBIAN_FRONTEND=noninteractive
cat > /etc/apt/sources.list <<'EOF'
deb http://deb.debian.org/debian bookworm main
deb http://deb.debian.org/debian-security bookworm-security main
EOF
apt-get update -qq
apt-get install -y --no-install-recommends \
systemd-sysv udev openssh-server iproute2 iputils-ping ca-certificates
echo "qemu-target" > /etc/hostname
echo "root:root" | chpasswd
cat > /etc/fstab <<'EOF'
/dev/vda2 / ext4 defaults 0 1
/dev/vda1 /boot/firmware vfat defaults 0 2
EOF
mkdir -p /boot/firmware
cat > /etc/systemd/network/20-eth.network <<'EOF'
[Match]
Name=en* eth*
[Network]
DHCP=yes
EOF
systemctl enable serial-getty@ttyAMA0.service systemd-networkd ssh
sed -i 's/^#\?PermitRootLogin.*/PermitRootLogin yes/' /etc/ssh/sshd_config
apt-get cleansudo cp setup-rootfs.sh /mnt/tr/root/
sudo mount --bind /dev /mnt/tr/dev
sudo mount -t proc none /mnt/tr/proc
sudo mount -t sysfs none /mnt/tr/sys
sudo cp /etc/resolv.conf /mnt/tr/etc/resolv.conf # chroot 안에서 apt를 쓰려면 필요
sudo chroot /mnt/tr /bin/bash /root/setup-rootfs.sh
printf 'nameserver 10.0.2.3\n' | sudo tee /mnt/tr/etc/resolv.conf # 타깃용 DNS로 되돌린다
sudo rm -f /mnt/tr/usr/bin/qemu-aarch64-static /mnt/tr/root/setup-rootfs.sh
sudo umount /mnt/tr/dev /mnt/tr/proc /mnt/tr/sys /mnt/tr
sudo qemu-nbd --disconnect /dev/nbd010.0.2.3은 QEMU 사용자 모드 네트워크가 제공하는 DNS 주소다. 호스트의 resolv.conf를 그대로 두고 부팅하면 타깃 안에서 이름 해석이 안 된다.
udev를 빼면 부팅이 멈춘다
--no-install-recommends로 systemd-sysv만 넣고 udev를 빼면 이렇게 된다.
[ TIME ] Timed out waiting for device dev-ttyAMA0.device - /dev/ttyAMA0.
[DEPEND] Dependency failed for serial-getty@ttyAMA0.service.
[ TIME ] Timed out waiting for device dev-vda1.device - /dev/vda1.
[DEPEND] Dependency failed for boot-firmware.mount - /boot/firmware.
[DEPEND] Dependency failed for local-fs.target - Local File Systems.
[ OK ] Started emergency.service - Emergency Shell.
You are in emergency mode.
장치는 커널이 만들어줬지만 systemd에 .device 유닛을 알려줄 udev가 없어서, 시리얼 getty와 /boot/firmware 마운트가 모두 90초를 기다린 뒤 실패한다. 데비안에서 udev는 systemd의 recommends라 --no-install-recommends를 쓰면 빠지므로 명시적으로 넣어야 한다.
부팅과 SSH 접속
qemu-target login: root
Password:
Linux qemu-target 6.12.0 #1 SMP PREEMPT aarch64
root@qemu-target:~# systemd-analyze time
Startup finished in 1.845s (kernel) + 16.544s (userspace) = 18.389s
root@qemu-target:~# ip -br addr
lo UNKNOWN 127.0.0.1/8
eth0 UP 10.0.2.15/24 metric 1024
root@qemu-target:~# df -h / /boot/firmware
Filesystem Size Used Avail Use% Mounted on
/dev/root 926M 318M 544M 37% /
/dev/vda1 63M 44M 20M 70% /boot/firmware
root@qemu-target:~# apt-get install -y --no-install-recommends file | tail -2
Setting up file (1:5.44-3) ...
Processing triggers for libc-bin (2.36-9+deb12u14) ...
타깃 안에서 apt가 데비안 미러까지 나가 패키지를 받아 설치했다. 1편에서 -netdev user로 붙여둔 사용자 모드 네트워크가 여기서 값을 한다. 같은 옵션의 hostfwd 덕분에 호스트에서 SSH로도 붙는다.
$ ssh -p 2222 root@localhost
root@localhost's password:
root@qemu-target:~# hostname; uname -m; systemctl is-system-running
qemu-target
aarch64
running
$ scp -P 2222 xfer.txt root@localhost:/root/
$ ssh -p 2222 root@localhost cat /root/xfer.txt
hello from host 00:03:52
이제 시리얼 콘솔에 매달리지 않고 호스트에서 파일을 밀어 넣고 명령을 돌릴 수 있다. 크로스 빌드한 바이너리를 scp로 던져 타깃에서 바로 실행하는 개발 흐름이 이 시점부터 가능해진다.
두 rootfs 비교
| BusyBox | Debian(minbase) | |
|---|---|---|
| 크기 | 2.2MB | 318MB |
| 부팅 시간 | 약 1.2초 | 약 18초 |
| 패키지 관리 | 없음 | apt |
| init | 셸 스크립트 | systemd |
| SSH | 직접 빌드해야 함 | apt install |
| 쓰임새 | 브링업·부팅 경로 확인 | 애플리케이션 개발·디버깅 |
주의사항
debootstrap2단계는binfmt_misc에 arm64가 등록돼 있어야 한다./proc/sys/fs/binfmt_misc/qemu-aarch64가 없으면qemu-user-static을 설치한다.udev를 빼면 emergency mode로 떨어진다.--no-install-recommends를 쓸 때는 명시적으로 넣을 것.- chroot 안에서
apt를 쓰려면 호스트resolv.conf가 필요하고, 부팅 전에는 타깃용(10.0.2.3)으로 되돌려야 한다. 하나만 빠뜨려도 한쪽에서 이름 해석이 실패한다. - 작업이 끝나면
/mnt/tr/usr/bin/qemu-aarch64-static을 지운다. 타깃에서는 쓸모없고 rootfs만 키운다. minbase에는 dbus가 없어서 SSH로poweroff를 실행하면Failed to connect to bus메시지가 함께 나온다. 종료 자체는 된다.hostfwd로 열어둔 2222 포트는 호스트의 다른 서비스와 겹칠 수 있다. 겹치면 QEMU가 조용히 실패하지 않고 시작 자체가 안 되므로 포트를 바꾼다.
마무리
빈 파티션이 셸이 뜨는 시스템이 됐고, 다시 apt와 SSH가 되는 개발용 타깃이 됐다. 여기까지가 부트로더·커널·루트 파일 시스템을 하나씩 손으로 쌓아 올리는 방식이다. 다음 편에서는 같은 결과물을 Yocto로 만들어본다. 레시피 몇 줄로 이미지를 굽는 방식과 지금까지의 수동 조립을 비교해보면 각각이 어디에 맞는지가 드러난다.