C 코드를 gcc -S로 컴파일해 어셈블리 출력을 구경해본 적은 있어도, 처음부터 어셈블리만으로 프로그램을 짜서 빌드하고 실행해본 경험은 드물다. 하지만 커널의 부팅 초기 코드, 예외/인터럽트 벡터 핸들러, 컨텍스트 스위칭처럼 C로는 표현할 수 없거나 사이클 단위 제어가 필요한 구간은 여전히 어셈블리로 직접 작성된다. 라즈베리파이 같은 실제 AArch64 보드나 클라우드의 ARM 인스턴스(AWS Graviton 등)를 접하는 일이 흔해진 지금, 최소한의 어셈블리 프로그램을 직접 만들어보는 경험은 이런 저수준 코드를 읽을 때 큰 도움이 된다. 이 글에서는 GNU 어셈블러(as)로 AArch64 어셈블리 프로그램을 작성, 빌드, 실행, 디버깅하는 방법을 정리한다.
핵심 개념 — 호출 규약과 시스템 콜
AArch64 프로그램을 작성하려면 레지스터가 어떤 역할로 예약돼 있는지부터 알아야 한다. AAPCS64(Procedure Call Standard for the ARM 64-bit Architecture)가 정의하는 관례는 다음과 같다.
| 레지스터 | 용도 |
|---|---|
X0–X7 | 함수 인자 전달, X0은 반환값으로도 사용 |
X8 | 리눅스 시스템 콜 번호(syscall number) |
X16/X17 | 임시 레지스터(IP0/IP1), 링커가 프로시저 호출 시 사용 |
X29 | 프레임 포인터(FP) |
X30 | 링크 레지스터(LR), bl 호출 시 복귀 주소 저장 |
SP | 스택 포인터 |
시스템 콜을 직접 호출할 때는 X8에 syscall 번호를, X0–X5에 인자를 채운 뒤 svc #0 명령으로 커널에 트랩(trap)한다. AArch64의 syscall 번호는 x86_64와 다르므로 주의해야 한다. 예를 들어 write는 x86_64에서 1번이지만 AArch64에서는 64번이고, exit는 x86_64에서 60번, AArch64에서는 93번이다. 이 번호는 커널 소스의 include/uapi/asm-generic/unistd.h에 정의된 통합(generic) 시스템 콜 테이블을 아키텍처들이 공유하는 값이다.
실전 코드
먼저 write와 exit 시스템 콜만으로 문자열을 출력하는 최소한의 프로그램을 작성한다. libc 없이 커널과 직접 통신하므로 링크 단계에서도 libc를 붙일 필요가 없다.
.global _start
.section .data
msg:
.ascii "Hello, AArch64!\n"
msg_len = . - msg
.section .text
_start:
// write(fd=1, buf=msg, count=msg_len)
mov x0, #1
ldr x1, =msg
mov x2, #msg_len
mov x8, #64
svc #0
// exit(status=0)
mov x0, #0
mov x8, #93
svc #0
ldr x1, =msg는 실제 CPU 명령어가 아니라 어셈블러 의사 명령(pseudo-instruction)이다. msg의 절대 주소가 명령어 인코딩에 다 들어가지 않기 때문에, 어셈블러가 근처에 리터럴 풀(literal pool)을 만들어 주소값을 저장해두고 그 값을 읽어오는 실제 명령어들로 치환해준다. AArch64 Linux 환경에서 빌드하고 바로 실행한다.
$ as -o hello.o hello.s
$ ld -o hello hello.o
$ ./hello
Hello, AArch64!
다음은 조건 분기와 반복문을 써서 1부터 10까지의 합을 계산하고, 그 결과를 종료 코드로 반환하는 프로그램이다.
.global _start
.section .text
_start:
mov x0, #0 // sum = 0
mov x1, #1 // i = 1
loop:
cmp x1, #10
b.gt done
add x0, x0, x1 // sum += i
add x1, x1, #1 // i++
b loop
done:
mov x8, #93 // exit(sum)
svc #0
cmp는 두 값을 빼서 플래그만 갱신하고, b.gt는 그 플래그를 보고 “크면(greater than) 분기”한다. 셸의 종료 코드는 8bit로 잘리므로, 이 예제처럼 결과가 255 이하일 때만 $?로 값을 그대로 확인할 수 있다.
$ as -o sum.o sum.s
$ ld -o sum sum.o
$ ./sum
$ echo $?
55
레지스터 값이 각 단계마다 어떻게 바뀌는지 직접 눈으로 확인하고 싶다면 gdb로 명령어 단위 실행이 가능하다. 디버그 정보를 포함해 빌드한 뒤 루프 시작 지점에 브레이크포인트를 건다.
$ as -g -o sum.o sum.s
$ ld -o sum sum.o
$ gdb ./sum
(gdb) break loop
(gdb) run
(gdb) info registers x0 x1
x0 0x0 0
x1 0x1 1
(gdb) stepi 4
(gdb) info registers x0 x1
x0 0x1 1
x1 0x2 2
stepi는 소스 라인이 아니라 명령어 하나 단위로 실행을 진행시키므로, 반복문 안에서 레지스터가 명령어마다 어떻게 누적되는지 그대로 관찰할 수 있다.
주의사항
네이티브 AArch64 환경이 필요하다: 위 예제는 라즈베리파이 64bit OS, ARM 기반 클라우드 인스턴스처럼 실제 AArch64 Linux에서 그대로 동작한다. x86_64 머신에서 시험해보려면 sudo apt install binutils-aarch64-linux-gnu qemu-user-static로 크로스 어셈블러와 QEMU 사용자 모드 에뮬레이터를 설치한 뒤, aarch64-linux-gnu-as/aarch64-linux-gnu-ld로 빌드하고 qemu-aarch64 ./hello로 실행해야 한다.
syscall 번호는 아키텍처마다 다르다: x86_64용 예제를 그대로 가져와 syscall 번호만 안 바꾸면 엉뚱한 시스템 콜이 호출된다. 정확한 번호는 커널 소스의 arch/arm64/include/uapi/asm/unistd.h가 include하는 generic 테이블을 기준으로 확인해야 한다.
svc 호출 후 X0은 반환값으로 덮어써진다: write 호출 전에 X0에 넣어둔 fd 값은 svc #0 실행 후 반환값(보통 쓰여진 바이트 수)으로 교체된다. 같은 값을 다시 써야 한다면 별도 레지스터에 백업해둬야 한다.
_start는 C 런타임 초기화를 거치지 않는다: libc의 main()과 달리 _start는 스택 정렬, TLS 초기화 같은 런타임 준비 과정 없이 커널이 곧바로 실행을 넘기는 진입점이다. 이 글의 예제처럼 시스템 콜만 직접 쓰는 단순한 프로그램에는 문제가 없지만, libc 함수(예: printf)를 섞어 쓰려면 _start 대신 gcc로 링크해 main부터 시작하는 방식으로 전환해야 한다.
마무리
시스템 콜 두세 개만으로도 어셈블리 프로그램을 빌드하고 실행하는 전체 과정을 경험할 수 있다. 레지스터별 역할과 syscall 호출 규약만 익혀두면, 커널 소스나 임베디드 부트로더의 어셈블리 코드를 읽을 때도 각 명령어가 무엇을 하려는 것인지 훨씬 빠르게 파악할 수 있다. 다음 단계로는 bl/ret으로 함수를 나눠 호출하는 구조나, 스택에 인자를 넘기는 AAPCS64 호출 규약을 직접 코드로 확인해보는 것을 추천한다.
참고
ARM64 Syscall Table (syscall.sh)
Procedure Call Standard for the Arm 64-bit Architecture (AAPCS64)