4편까지 오면 타깃은 완성됐다. 남은 것은 이 타깃을 실제 개발에 쓰는 방법이다. 실물 보드에서 하던 일 — 드라이버를 고쳐 올리고, 부팅이 이상하면 붙잡아 들여다보고, 빌드 결과를 매번 굽지 않고 바로 반영하는 것 — 이 QEMU에서는 어떻게 되는지 정리한다. 커널 모듈 크로스 빌드와 적재, 호스트 디렉터리 공유, gdb로 커널 부팅 붙잡기, NFS 루트까지 네 가지를 다룬다.
이 시리즈의 다른 글
- QEMU(arm64) 임베디드 리눅스 타깃 만들기 (1) — 타깃 디스크 이미지와 virt 보드 구성
- QEMU(arm64) 임베디드 리눅스 타깃 만들기 (2) — U-Boot 올리기
- QEMU(arm64) 임베디드 리눅스 타깃 만들기 (3) — 커널 크로스 컴파일과 부팅
- QEMU(arm64) 임베디드 리눅스 타깃 만들기 (4) — 루트 파일 시스템과 패키지 설치
- QEMU(arm64) 임베디드 리눅스 타깃 만들기 (5) — Yocto로 타깃 이미지 빌드
커널 모듈 크로스 빌드
3편에서 만든 빌드 트리를 그대로 KDIR로 쓴다. 타깃에서 도는 커널과 같은 트리여야 vermagic이 맞는다.
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/of.h>
static int __init hello_init(void)
{
struct device_node *root = of_find_node_by_path("/");
const char *model = "unknown";
if (root)
of_property_read_string(root, "model", &model);
pr_info("hello: loaded on %s\n", model);
pr_info("hello: NR_CPUS=%d PAGE_SIZE=%lu\n", NR_CPUS, PAGE_SIZE);
of_node_put(root);
return 0;
}
static void __exit hello_exit(void)
{
pr_info("hello: unloaded\n");
}
module_init(hello_init);
module_exit(hello_exit);
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("qemu-target sample module");obj-m := hello.o
KDIR ?= $(HOME)/qemu-target/kbuild-arm64
ARCH ?= arm64
CROSS_COMPILE ?= aarch64-linux-gnu-
all:
$(MAKE) -C $(KDIR) ARCH=$(ARCH) CROSS_COMPILE=$(CROSS_COMPILE) M=$(PWD) modules3편에서는 Image만 빌드했기 때문에 이 상태로 make를 돌리면 심볼을 못 찾는다.
ERROR: modpost: "of_property_read_string" [hello.ko] undefined!
ERROR: modpost: "of_node_put" [hello.ko] undefined!
ERROR: modpost: "__stack_chk_fail" [hello.ko] undefined!
modules_prepare만으로는 부족하고 커널이 내보내는 심볼 목록(Module.symvers)이 있어야 한다. 그 파일은 커널 모듈을 한 번 빌드해야 생긴다.
make -C ~/kbuild/linux-6.12 O=~/qemu-target/kbuild-arm64 \
ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- -j$(nproc) modules
cd ~/qemu-target/mod && make
modinfo hello.ko | head -6 MODPOST /home/noble/qemu-target/mod/Module.symvers
CC [M] /home/noble/qemu-target/mod/hello.mod.o
LD [M] /home/noble/qemu-target/mod/hello.ko
filename: hello.ko
description: qemu-target sample module
license: GPL
name: hello
vermagic: 6.12.0 SMP preempt mod_unload aarch64
호스트 디렉터리를 타깃에 붙이기
빌드할 때마다 scp를 치는 대신 호스트 디렉터리를 통째로 타깃에 붙여두면 편하다. QEMU의 virtio-9p를 쓰면 이미지를 건드리지 않고 파일이 오간다.
# QEMU 실행 옵션에 추가
-fsdev local,id=fsdev0,path=/home/noble/qemu-target/share,security_model=mapped-xattr \
-device virtio-9p-device,fsdev=fsdev0,mount_tag=hostshareroot@qemu-target:~# mkdir -p /mnt/host
root@qemu-target:~# mount -t 9p -o trans=virtio,version=9p2000.L hostshare /mnt/host
root@qemu-target:~# ls -l /mnt/host
-rw-rw-r-- 1 1000 1000 26 Aug 26 00:27 from-host.txt
root@qemu-target:~# cat /mnt/host/from-host.txt
built on host at 00:27:44
root@qemu-target:~# echo "reply from target $(uname -m)" > /mnt/host/from-target.txt
$ cat ~/qemu-target/share/from-target.txt
reply from target aarch64
양방향으로 즉시 반영된다. 빌드한 모듈을 이 디렉터리에 떨어뜨리면 타깃에서 바로 올릴 수 있다.
root@qemu-target:~# insmod /mnt/host/hello.ko
root@qemu-target:~# lsmod | head -3
Module Size Used by
hello 12288 0
root@qemu-target:~# dmesg | tail -3
[ 530.061447] hello: loading out-of-tree module taints kernel.
[ 530.165674] hello: loaded on linux,dummy-virt
[ 530.165998] hello: NR_CPUS=512 PAGE_SIZE=4096
모듈이 디바이스 트리에서 읽어온 linux,dummy-virt가 1편에서 dumpdtb로 확인한 그 모델명이다. 4편에서 만든 데비안 rootfs는 minbase라 insmod가 없으니 apt install kmod가 먼저다.
gdb로 커널 부팅 붙잡기
QEMU에 -S -gdb tcp::1234를 주면 CPU를 멈춘 채로 띄운다. 실물 보드에서 JTAG로 하던 일을 케이블 없이 할 수 있다.
#!/bin/bash
cd ~/qemu-target
setsid qemu-system-aarch64 -M virt -cpu cortex-a57 -m 1G -smp 2 \
-bios u-boot-2026.01/u-boot.bin \
-drive if=none,file=work.qcow2,format=qcow2,id=hd0 \
-device virtio-blk-device,drive=hd0 \
-nographic -S -gdb tcp::1234 </dev/null > gdbboot.log 2>&1 &
sleep 3
gdb-multiarch -batch \
-ex "target remote :1234" \
-ex "break start_kernel" \
-ex "continue" \
-ex "bt" \
-ex "print init_task.comm" \
-ex "info registers pc sp" \
-ex "next" \
-ex "detach" \
kbuild-arm64/vmlinuxBreakpoint 1 at 0xffff800081ca0930: file init/main.c, line 905.
Thread 1 hit Breakpoint 1, start_kernel () at init/main.c:905
905 char *command_line;
#0 start_kernel () at init/main.c:905
#1 0xffff800081ca9cf0 in __primary_switched () at arch/arm64/kernel/head.S:243
$1 = "swapper\000\000\000\000\000\000\000\000"
pc 0xffff800081ca0930 0xffff800081ca0930 <start_kernel>
sp 0xffff8000826d3eb0 0xffff8000826d3eb0
908 set_task_stack_end_magic(&init_task);
부팅 첫 C 함수에서 멈춰 head.S의 __primary_switched가 호출자로 찍히고, init_task.comm이 swapper인 것까지 보인다. 여기서 한 가지 함정이 있다.
Breakpoint 1 at 0xffff800081ca0930: file init/main.c, line 905.
Cannot execute this command while the target is running.
Use the "interrupt" command to stop the target and then try again.
KASLR이 켜져 있으면 커널이 실제로 올라가는 주소가 매번 달라져 vmlinux의 심볼 주소에 건 브레이크포인트를 지나쳐버린다. 3편의 boot.cmd에서 bootargs에 nokaslr을 넣어야 한다.
setenv bootargs "console=ttyAMA0,115200 root=/dev/vda2 rootwait rw nokaslr"NFS 루트로 부팅하기
루트 파일 시스템을 고칠 때마다 이미지를 마운트했다 푸는 것이 번거로우면, 아예 호스트 디렉터리를 루트로 쓰면 된다. 실물 보드 개발에서 흔히 쓰는 방식이 QEMU에서도 그대로 통한다.
sudo apt install -y nfs-kernel-server
# 이미지의 루트 파티션을 호스트 디렉터리로 꺼낸다
sudo qemu-nbd --connect=/dev/nbd0 work.qcow2 && sleep 1
sudo mount /dev/nbd0p2 /mnt/tr
sudo rsync -aHAX --numeric-ids /mnt/tr/ nfsroot/
sudo sed -i '/vda/d' nfsroot/etc/fstab # 블록 장치 마운트는 지운다
sudo umount /mnt/tr && sudo qemu-nbd --disconnect /dev/nbd0
echo '/home/noble/qemu-target/nfsroot 127.0.0.1(rw,sync,no_subtree_check,no_root_squash,insecure)' \
| sudo tee -a /etc/exports
sudo exportfs -raQEMU 사용자 모드 네트워크는 타깃의 요청을 호스트의 127.0.0.1에서 나온 것처럼 전달한다. 그래서 export 대상이 127.0.0.1이고, 타깃에서 보는 호스트 주소는 10.0.2.2다.
load virtio 0:1 ${kernel_addr_r} Image
setenv bootargs "console=ttyAMA0,115200 root=/dev/nfs rw nokaslr ip=dhcp \
nfsroot=10.0.2.2:/home/noble/qemu-target/nfsroot,vers=3,tcp"
booti ${kernel_addr_r} - ${fdtcontroladdr}[ 4.066629] IP-Config: Got DHCP answer from 10.0.2.2, my address is 10.0.2.15
[ 4.068993] IP-Config: Complete:
[ 5.316998] VFS: Mounted root (nfs filesystem) on device 0:22.
Debian GNU/Linux 12 qemu-target ttyAMA0
qemu-target login:
root@qemu-target:~# findmnt -no SOURCE,FSTYPE /
10.0.2.2:/home/noble/qemu-target/nfsroot nfs
커널이 ip=dhcp로 주소를 받아 호스트의 디렉터리를 루트로 마운트했다. 이제 호스트에서 nfsroot/ 아래 파일을 고치면 타깃에 즉시 반영되고, 타깃에서 만든 파일도 호스트에 그대로 보인다.
| 방법 | 범위 | 맞는 상황 |
|---|---|---|
scp(hostfwd) | 파일 하나씩 | 가끔 바이너리 하나 넘길 때 |
| virtio-9p | 디렉터리 하나 | 빌드 산출물을 계속 던져 넣을 때 |
| NFS 루트 | 루트 전체 | rootfs 자체를 자주 고칠 때 |
| 이미지 마운트 | 루트 전체 | 타깃이 꺼져 있을 때만 |
망가뜨렸으면 되돌리기
1편에서 오버레이로 만들어둔 덕에 실험을 되돌리는 건 파일 하나 지우는 일이다.
rm -f work.qcow2
qemu-img create -f qcow2 -F qcow2 -b target.qcow2 work.qcow2주의사항
- 외부 모듈 빌드에는
Module.symvers가 필요하다.modules_prepare만 돌리고 넘어가면modpost에서 막힌다. - gdb로 커널을 볼 때는
nokaslr이 필수다. 없으면 브레이크포인트가 조용히 안 걸린다. - NFS 루트에서는
/etc/fstab의 블록 장치 항목을 지운다. 남겨두면 부팅 중 마운트 실패로 시간을 버린다. - 9p의
security_model=mapped-xattr은 타깃의 소유권·권한을 호스트 파일의 확장 속성에 저장한다. 호스트에서 보는 소유자가 타깃과 달라 보이는 것은 정상이다. -gdb tcp::1234로 띄운 QEMU는 gdb가detach해도 남는다. 다음 실행에서 이미지 잠금 오류가 나면 이 프로세스가 살아 있는지부터 확인한다.- 모듈은 타깃에서 도는 커널과 같은 트리에서 빌드해야 한다.
vermagic이 다르면insmod가 거부한다.
마무리
여섯 편에 걸쳐 빈 이미지 파일에서 시작해 부트로더·커널·루트 파일 시스템을 쌓고, Yocto로 같은 결과물을 굽고, 마지막으로 그 타깃 위에서 드라이버를 올리고 디버깅하는 데까지 왔다. 실물 보드가 손에 들어오면 달라지는 부분은 부트로더를 굽는 방법과 디바이스 트리 정도이고, 그 위의 작업 흐름은 여기서 만든 것과 거의 같다. 보드를 기다리는 동안 먼저 시작할 수 있다는 것이 이 구성의 값이다.