x86-64에서는 syscall 명령이 MSR_LSTAR에 적힌 주소로 곧장 점프한다. 시스템 콜 전용 진입점이 레지스터 하나에 박혀 있는 구조다. arm64에는 그런 전용 레지스터가 없다. svc 명령이 만드는 것은 “동기 예외” 하나일 뿐이고, 페이지 폴트나 정렬 오류와 같은 입구로 들어간 뒤에야 그게 시스템 콜이었는지 판별된다. 이 구조 차이 때문에 arm64의 진입 경로는 소스만 읽어서는 감이 잘 안 잡힌다. 이 글에서는 QEMU로 arm64 커널을 띄워 VBAR_EL1 레지스터 값과 커널 심볼을 직접 대조하고, ftrace로 svc 한 번이 핸들러까지 가는 경로를 잡아본다.
전체 경로
| 단계 | 일어나는 일 |
|---|---|
svc #0 | 유저 공간이 x8에 시스템 콜 번호, x0~x5에 인자를 넣고 실행 |
| CPU | VBAR_EL1 + 0x400(EL0 64비트 동기 예외 슬롯)으로 점프하며 EL1으로 전환 |
el0t_64_sync | 어셈블리. 레지스터를 pt_regs에 저장 |
el0t_64_sync_handler | C 코드. ESR_EL1의 예외 클래스를 보고 무슨 예외인지 판별 |
el0_svc | ESR_ELx_EC_SVC64일 때만 여기로 온다 |
do_el0_svc → el0_svc_common → invoke_syscall | 번호 검사 후 sys_call_table에서 핸들러를 꺼내 호출 |
__arm64_sys_* | 실제 핸들러 |
확인 환경은 커널 7.1.8을 ARCH=arm64 LLVM=1로 빌드해 QEMU virt 머신에 올린 것이다. 루트 파일시스템은 busybox와 테스트 프로그램만 담은 initramfs다.
tar xf linux-7.1.8.tar.xz && cd linux-7.1.8
export ARCH=arm64 LLVM=1
make defconfig
scripts/config --enable FUNCTION_GRAPH_TRACER --enable DYNAMIC_FTRACE \
--enable KALLSYMS_ALL --enable MODULES --enable BLK_DEV_INITRD
make olddefconfig
make -j$(nproc) Image modules
qemu-system-aarch64 -machine virt -cpu cortex-a57 -smp 2 -m 1024 -nographic \
-kernel arch/arm64/boot/Image -initrd initramfs.cpio.gz \
-append "console=ttyAMA0 rdinit=/init loglevel=4"진입점은 시스템 레지스터에 있다
예외 벡터 테이블의 베이스 주소는 VBAR_EL1에 들어 있다. 문제는 이 레지스터를 EL0(유저 공간)에서 읽을 수 없다는 점이다. x86-64에서 rdmsr로 MSR_LSTAR를 읽던 것과 달리, arm64에서는 EL1에서 실행되는 코드가 필요하다. 커널 모듈 하나면 충분하다.
#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
MODULE_LICENSE("GPL");
static int __init vbar_init(void)
{
u64 vbar;
asm volatile("mrs %0, vbar_el1" : "=r"(vbar));
pr_info("VBAR_EL1 = 0x%016llx\n", vbar);
return 0;
}
static void __exit vbar_exit(void) { }
module_init(vbar_init);
module_exit(vbar_exit);### [1] VBAR_EL1을 커널 모듈로 읽기
[ 1.769631] VBAR_EL1 = 0xffffda0c1c811000
vectors 심볼 = 0xffffda0c1c811000
vectors + 0x400 = 0xffffda0c1c811400 <- EL0 64bit sync 벡터 슬롯
el0t_64_sync 심볼 = 0xffffda0c1c811c08
VBAR_EL1과 vectors 심볼이 정확히 일치한다. 커널이 부팅 중 이 레지스터에 벡터 테이블 주소를 써둔 것이다. 주소 자체는 KASLR로 부팅마다 달라지므로, 재현할 때는 두 값이 같은지만 보면 된다.
왜 0x400인가
arm64 벡터 테이블은 항목이 16개이고, 각 항목은 .align 7로 128바이트(0x80) 간격에 놓인다. 예외가 발생한 실행 레벨과 예외 종류의 조합마다 슬롯이 하나씩 배정된다.
SYM_CODE_START(vectors)
kernel_ventry 1, t, 64, sync // Synchronous EL1t (+0x000)
kernel_ventry 1, t, 64, irq // IRQ EL1t (+0x080)
kernel_ventry 1, t, 64, fiq // FIQ EL1t (+0x100)
kernel_ventry 1, t, 64, error // Error EL1t (+0x180)
kernel_ventry 1, h, 64, sync // Synchronous EL1h (+0x200)
kernel_ventry 1, h, 64, irq // IRQ EL1h (+0x280)
kernel_ventry 1, h, 64, fiq // FIQ EL1h (+0x300)
kernel_ventry 1, h, 64, error // Error EL1h (+0x380)
kernel_ventry 0, t, 64, sync // Synchronous 64-bit EL0 (+0x400) <-- svc
kernel_ventry 0, t, 64, irq // IRQ 64-bit EL0 (+0x480)
...유저 공간(EL0, 64비트)에서 발생한 동기 예외는 9번째 슬롯인 +0x400으로 들어간다. 그 자리에 실제로 무엇이 있는지 vmlinux를 디스어셈블해서 확인할 수 있다.
ffff800080011400 <vectors+0x400>:
ffff800080011400: 14000003 b ffff80008001140c <vectors+0x40c>
ffff800080011404: d53bd07e mrs x30, tpidrro_el0
ffff800080011408: d51bd07f msr tpidrro_el0, xzr
ffff80008001140c: d10503ff sub sp, sp, #0x140
슬롯 안에 kernel_ventry 매크로가 펼쳐놓은 스택 확보 코드가 그대로 들어 있다. 즉 el0t_64_sync는 이 슬롯 자체가 아니라 여기서 이어지는 핸들러이고, 위 출력에서 vectors+0xc08에 위치한다 — 0x80 × 16 = 0x800바이트짜리 테이블이 끝난 바로 뒤다. “VBAR_EL1 + 0x400이 곧 el0t_64_sync“라고 생각하면 주소가 안 맞는 이유가 이것이다.
시스템 콜인지는 ESR을 봐야 안다
x86-64와 가장 크게 갈리는 지점이다. +0x400 슬롯은 시스템 콜 전용이 아니라 EL0에서 온 모든 동기 예외의 입구다. 무슨 일이 있었는지는 ESR_EL1(Exception Syndrome Register)의 예외 클래스를 읽어 판별한다.
asmlinkage void noinstr el0t_64_sync_handler(struct pt_regs *regs)
{
unsigned long esr = read_sysreg(esr_el1);
switch (ESR_ELx_EC(esr)) {
case ESR_ELx_EC_SVC64:
el0_svc(regs); /* 시스템 콜 */
break;
case ESR_ELx_EC_DABT_LOW:
el0_da(regs, esr); /* 데이터 abort (페이지 폴트) */
break;
case ESR_ELx_EC_IABT_LOW:
el0_ia(regs, esr); /* 명령어 abort */
break;
case ESR_ELx_EC_FP_ASIMD:
el0_fpsimd_acc(regs, esr);
break;
...
}
}번호를 꺼내는 곳도 다르다. x86-64가 rax를 쓰는 자리에서 arm64는 x8을 쓴다.
void do_el0_svc(struct pt_regs *regs)
{
el0_svc_common(regs, regs->regs[8], __NR_syscalls, sys_call_table);
}
static void invoke_syscall(struct pt_regs *regs, unsigned int scno,
unsigned int sc_nr,
const syscall_fn_t syscall_table[])
{
long ret;
add_random_kstack_offset();
if (likely(scno < sc_nr)) {
syscall_fn_t syscall_fn;
syscall_fn = syscall_table[array_index_nospec(scno, sc_nr)];
ret = __invoke_syscall(regs, syscall_fn);
} else {
ret = do_ni_syscall(regs, scno);
}
syscall_set_return_value(current, regs, 0, ret);
}여기서 눈여겨볼 것은 syscall_table[...] 형태의 함수 포인터 간접 호출이 그대로 남아 있다는 점이다. x86-64는 커널 6.9에서 이 자리를 거대한 switch문(x64_sys_call())으로 바꿨는데, arm64는 여전히 테이블 방식이다.
ftrace로 실제 경로 잡기
경로를 트레이스로 확인하려 하면 곧바로 벽에 부딪힌다. 진입부 함수 일부는 noinstr로 표시되어 ftrace가 후킹할 수 없다. 예외 처리 초입은 아직 커널 상태가 정리되기 전이라 계측 코드가 돌면 안 되기 때문이다.
### [3] ftrace가 후킹할 수 있는 함수인가
el0t_64_sync_handler : 후킹 불가 (noinstr)
el0_svc : 후킹 불가 (noinstr)
do_el0_svc : 후킹 가능
el0_svc_common : 후킹 가능
invoke_syscall : 후킹 가능
따라서 그래프 진입 함수는 후킹 가능한 첫 지점인 do_el0_svc로 잡아야 한다. el0_svc를 set_graph_function에 쓰면 write error: Invalid argument가 난다.
T=/sys/kernel/tracing
echo nop > $T/current_tracer
echo 1 > $T/options/funcgraph-proc
echo 5 > $T/max_graph_depth
echo do_el0_svc > $T/set_graph_function
echo function_graph > $T/current_tracer
echo 1 > $T/tracing_on
./sysprobe_a64 # clock_gettime 1회 + getppid 1회
echo 0 > $T/tracing_on
grep -B4 -A3 getppid $T/trace 1) sysprob-85 | ! 372.880 us | }
1) sysprob-85 | | do_el0_svc() {
1) sysprob-85 | | el0_svc_common() {
1) sysprob-85 | | invoke_syscall() {
1) sysprob-85 | | __arm64_sys_getppid() {
1) sysprob-85 | 3.280 us | __rcu_read_lock();
1) sysprob-85 | 5.920 us | __task_pid_nr_ns();
1) sysprob-85 | 2.064 us | __rcu_read_unlock();
소스에서 읽은 순서가 그대로 나온다. __arm64_sys_getppid는 pt_regs에서 인자를 꺼내주는 래퍼이고, 그 안의 __task_pid_nr_ns()가 실제 일을 한다.
커널로 내려가지 않는 호출
테스트 프로그램은 clock_gettime과 getppid를 각각 한 번씩 부른다. 그런데 트레이스에는 하나만 잡힌다.
### [5] vDSO: 트레이스에 clock_gettime이 잡히는가
getppid 등장 횟수 = 1
clock_gettime 등장 횟수 = 0
clock_gettime은 값을 정상적으로 돌려줬는데도 do_el0_svc 아래에 한 번도 나타나지 않는다. arm64도 vDSO를 매핑하므로 시간 조회가 유저 공간에서 끝나 svc 자체를 실행하지 않은 것이다. ftrace로 시스템 콜 횟수를 세는 방식이 실제 호출 횟수와 어긋날 수 있는 이유다.
주의사항
VBAR_EL1은 EL0에서 읽을 수 없다. 이 글처럼 커널 모듈을 쓰거나gdb로 QEMU에 붙어야 한다. 유저 공간 도구로 확인하려다 안 된다고 결론 내리기 쉬운 부분이다.VBAR_EL1 + 0x400은 벡터 슬롯이지 핸들러 심볼이 아니다.el0t_64_sync는 16개 슬롯(0x800바이트)이 끝난 뒤에 위치하므로 두 주소는 일치하지 않는다. 슬롯 안에 코드가 직접 들어 있다는 점을objdump로 확인하면 헷갈리지 않는다.- 진입부의
noinstr함수(el0t_64_sync_handler,el0_svc)는 ftrace로 잡을 수 없다.available_filter_functions에 있는지 먼저 확인하고 트레이스 시작점을 정할 것. - KASLR 때문에 모든 주소가 부팅마다 바뀐다. 값을 기록해 다음 부팅과 비교하는 검증은 의미가 없고, 같은 부팅 안에서 두 값이 일치하는지를 봐야 한다.
- QEMU
virt머신은 TCG(소프트웨어 에뮬레이션)로 도는 경우 시간 값이 크게 왜곡된다. 위 트레이스의us수치를 실제 하드웨어 성능으로 읽으면 안 되고, 호출 순서를 확인하는 용도로만 쓴다.
마무리
arm64의 시스템 콜 진입은 “전용 입구로 곧장 들어가는” x86-64와 달리 예외 처리 경로를 공유하고 ESR_EL1로 갈라진다. 그래서 벡터 테이블 구조와 예외 클래스 판별을 이해하지 못하면 svc 하나가 어디로 가는지 추적하기 어렵다. 하드웨어가 없어도 QEMU와 커널 모듈 하나면 레지스터 값까지 실제로 대조해볼 수 있으니, 소스만 읽고 추측하는 대신 직접 확인해보는 편이 빠르다.