QEMU 사용법 (arm64)

arm64 타깃 코드를 손볼 때마다 보드를 꺼내 플래싱하고 시리얼을 연결하는 건 번거롭고, 아직 하드웨어가 없는 단계라면 아예 불가능하다. QEMU는 이 문제를 성격이 다른 두 방식으로 풀어준다. 바이너리 하나만 호스트에서 그대로 돌리는 유저 모드와, 커널부터 올려 머신 전체를 흉내 내는 시스템 모드다. 이 글에서는 x86_64 우분투에서 두 모드를 각각 어떻게 쓰는지와, 어느 쪽을 언제 골라야 하는지를 정리한다.

유저 모드와 시스템 모드

유저 모드(qemu-aarch64)시스템 모드(qemu-system-aarch64)
실행 단위리눅스 프로세스 하나커널을 포함한 머신 전체
게스트 커널필요 없음(호스트 커널 사용)필요함
시스템 콜호스트 시스템 콜로 변환게스트 커널이 직접 처리
부팅 시간없음수십 초~수 분
쓰임새크로스 빌드 결과 실행·테스트부팅·드라이버·커널 검증
sudo apt install -y qemu-user-static qemu-system-arm \
    gcc-aarch64-linux-gnu gdb-multiarch

유저 모드로 arm64 바이너리 실행하기

#include <stdio.h>
#include <sys/utsname.h>

int main(void)
{
    struct utsname u;
    uname(&u);
    printf("machine  : %s\n", u.machine);
    printf("pointer  : %zu bytes\n", sizeof(void *));
#if defined(__aarch64__)
    unsigned long midr;
    asm volatile("mrs %0, midr_el1" : "=r"(midr));
    printf("midr_el1 : 0x%08lx\n", midr);
#endif
    return 0;
}
aarch64-linux-gnu-gcc -O2 -static -o arch-static arch.c
./arch-static
machine  : aarch64
pointer  : 8 bytes
midr_el1 : 0x000f0510

arm64 바이너리를 경로만 적어 실행했는데 그대로 돌아간다. binfmt_misc가 ELF 헤더의 아키텍처를 보고 QEMU를 인터프리터로 끼워 넣기 때문이다.

$ cat /proc/sys/fs/binfmt_misc/qemu-aarch64
enabled
interpreter /usr/libexec/qemu-binfmt/aarch64-binfmt-P
flags: POF
offset 0
magic 7f454c460201010000000000000000000200b700
mask ffffffffffffff00fffffffffffffffffeffffff

Ubuntu 24.04에서는 systemd-binfmt가 /usr/lib/binfmt.d/의 설정을 읽어 등록하므로, update-binfmts --display로 조회하면 not in database가 나온다. 등록 여부는 위처럼 /proc/sys/fs/binfmt_misc를 직접 보는 편이 확실하다.

동적 링크된 바이너리는 사정이 다르다. 게스트용 동적 로더와 라이브러리를 QEMU가 찾지 못한다.

$ aarch64-linux-gnu-gcc -O2 -o arch arch.c
$ ./arch
aarch64-binfmt-P: Could not open '/lib/ld-linux-aarch64.so.1': No such file or directory

$ qemu-aarch64-static -L /usr/aarch64-linux-gnu ./arch
machine  : aarch64
pointer  : 8 bytes
midr_el1 : 0x000f0510

-L로 크로스 툴체인이 깔아둔 sysroot를 가리키면 해결된다. 이 경로를 매번 적기 싫으면 QEMU_LD_PREFIX 환경 변수에 넣어두면 된다.

-cpu로 CPU 세대 바꿔가며 확인하기

#include <stdio.h>
#include <sys/auxv.h>
#include <asm/hwcap.h>

static const struct { unsigned long bit; const char *name; } caps[] = {
    { HWCAP_ASIMD,   "asimd"  },
    { HWCAP_AES,     "aes"    },
    { HWCAP_CRC32,   "crc32"  },
    { HWCAP_ATOMICS, "atomics"},
    { HWCAP_SVE,     "sve"    },
};

int main(void)
{
    unsigned long hw = getauxval(AT_HWCAP);
    printf("AT_HWCAP = 0x%016lx\n", hw);
    for (unsigned i = 0; i < sizeof(caps) / sizeof(caps[0]); i++)
        printf("  %-8s %s\n", caps[i].name, (hw & caps[i].bit) ? "yes" : "no");
    return 0;
}
$ qemu-aarch64-static -cpu cortex-a53 ./hwcap
AT_HWCAP = 0x00000000000008fb
  asimd    yes
  aes      yes
  crc32    yes
  atomics  no
  sve      no

$ qemu-aarch64-static -cpu max ./hwcap
AT_HWCAP = 0x00000000effffffb
  asimd    yes
  aes      yes
  crc32    yes
  atomics  yes
  sve      yes

Cortex-A53은 ARMv8.0이라 LSE atomics와 SVE가 빠져 있고 max는 QEMU가 지원하는 기능을 전부 켠다. 라즈베리파이 3(Cortex-A53)에서만 죽는 코드가 있다면 보드 없이 이 옵션 하나로 재현해볼 수 있다. midr_el1 값도 -cpu cortex-a53일 때 실제 A53 값인 0x410fd034로 바뀐다.

시스템 콜 추적과 gdb 디버깅

$ qemu-aarch64-static -strace ./arch-static
...
51920 uname(0x400000800880) = 0
51920 fstat(1,0x0000400000800478) = 0
51920 write(1,0x4986b0,77)machine  : aarch64
pointer  : 8 bytes
midr_el1 : 0x000f0510
 = 77
51920 exit_group(0)

-stracestrace를 따로 설치하지 않고도 게스트 시스템 콜을 보여준다. 한 단계 더 들어가려면 -g로 gdbstub을 열고 gdb-multiarch로 붙는다.

aarch64-linux-gnu-gcc -g -O0 -static -o arch-dbg arch.c
qemu-aarch64-static -g 1234 ./arch-dbg &

gdb-multiarch -q arch-dbg \
    -ex 'set architecture aarch64' \
    -ex 'target remote :1234' \
    -ex 'break main' -ex 'continue'
Breakpoint 1, main () at arch.c:5
5	{
(gdb) info registers pc sp
pc             0x4007bc            0x4007bc <main+12>
sp             0x400000800890      0x400000800890
(gdb) next
7	    uname(&u);
(gdb) print u.machine
$1 = "aarch64", '\000' <repeats 57 times>

소스 레벨 스텝과 변수 확인이 그대로 된다. arm64 어셈블리를 한 줄씩 따라가며 레지스터를 확인해야 할 때 보드와 JTAG 없이 끝낼 수 있는 지점이다.

시스템 모드로 arm64 커널 부팅하기

드라이버나 부팅 과정을 봐야 한다면 유저 모드로는 안 되고 머신 전체를 띄워야 한다. 실제 보드를 흉내 내는 머신도 있지만, 특정 하드웨어가 목적이 아니라면 virt가 가장 편하다.

qemu-system-aarch64 -M virt -cpu cortex-a72 -smp 2 -m 1024 -nographic \
    -kernel Image -initrd initrd.gz \
    -append "console=ttyAMA0 rdinit=/init"
[   16.100968] Freeing unused kernel memory: 12224K
[   16.227050] Run /init as init process

=== initramfs up ===
Linux 6.8.0-71-generic aarch64
cpu: CPU part	: 0xd08
mem: MemTotal:         980296 kB

BusyBox v1.36.1 (Ubuntu 1:1.36.1-6ubuntu3.1) built-in shell (ash)
~ #

CPU part 0xd08은 Cortex-A72의 파트 번호라 -cpu로 지정한 값이 게스트까지 그대로 전달됐음을 보여준다. 디스크와 네트워크는 virtio 장치로 붙인다.

qemu-img create -f qcow2 data.qcow2 1G

qemu-system-aarch64 -M virt -cpu max -smp 2 -m 1024 -nographic \
    -kernel Image -initrd initrd.gz \
    -append "console=ttyAMA0 rdinit=/init" \
    -drive file=data.qcow2,if=none,id=hd0,format=qcow2 \
    -device virtio-blk-device,drive=hd0 \
    -netdev user,id=net0,hostfwd=tcp::2222-:22 \
    -device virtio-net-device,netdev=net0
=== initramfs up ===
Linux 6.8.0-71-generic aarch64
cpu: CPU part	: 0x051
virtio: virtio0 virtio1

게스트에 virtio 장치 둘이 잡혔다. hostfwd를 준 덕분에 게스트에 sshd를 올리면 호스트에서 ssh -p 2222 localhost로 접속할 수 있다.

에뮬레이션 비용

같은 소스를 x86_64와 arm64로 각각 -O2 -static 빌드해서, 정수 연산만 30억 번 도는 루프를 재본 결과다.

실행 방식real
x86_64 네이티브7.570s
arm64 / qemu-aarch64-static11.932s

레지스터 안에서만 도는 루프라 TCG가 가장 유리한 경우이고도 1.6배다. 메모리 접근과 시스템 콜이 섞이면 격차는 더 벌어지고, 시스템 모드는 커널까지 통째로 번역하므로 여기서 다시 한참 느려진다.

주의사항

  • x86_64 호스트에서 arm64를 돌리면 KVM을 쓸 수 없어 전부 TCG 번역이다. -enable-kvm은 호스트와 게스트 아키텍처가 같을 때만 의미가 있다.
  • 유저 모드는 호스트 커널을 그대로 쓴다. uname -r, /proc 내용, 드라이버 동작은 arm64 실물과 다르므로 커널에 의존하는 동작을 이 모드로 검증하면 안 된다.
  • 동적 링크 바이너리는 -L(또는 QEMU_LD_PREFIX) 없이 실행하면 동적 로더를 못 찾아 실패한다. 게스트 배포판 라이브러리가 필요하면 해당 rootfs를 통째로 받아 그 경로를 지정한다.
  • -cpu max는 실제로 존재하는 칩이 아니다. 기능 유무를 넓게 켜보는 용도이고, 특정 제품에서의 동작을 확인하려면 cortex-a53·cortex-a72·neoverse-n1처럼 실제 코어 이름을 지정한다.
  • 위 성능 수치는 QEMU 8.2.2, 정수 루프 기준의 최선값이다. 워크로드 성격에 따라 크게 달라지니 절대 기준으로 삼지 말 것.

마무리

고르는 기준은 단순하다. 빌드한 바이너리가 arm64에서 제대로 도는지만 보면 유저 모드로 충분하고, 부팅·드라이버·커널이 얽히면 시스템 모드를 써야 한다. 유저 모드는 -cpu-strace, gdbstub까지 붙어 있어서 크로스 빌드 결과를 확인하는 일상 작업에는 보드보다 오히려 손이 덜 간다.

참고

답글 남기기