ARMv8 AArch64 어셈블리 입문 가이드

C 코드를 gcc -S로 컴파일해 어셈블리 출력을 구경해본 적은 있어도, 처음부터 어셈블리만으로 프로그램을 짜서 빌드하고 실행해본 경험은 드물다. 하지만 커널의 부팅 초기 코드, 예외/인터럽트 벡터 핸들러, 컨텍스트 스위칭처럼 C로는 표현할 수 없거나 사이클 단위 제어가 필요한 구간은 여전히 어셈블리로 직접 작성된다. 라즈베리파이 같은 실제 AArch64 보드나 클라우드의 ARM 인스턴스(AWS Graviton 등)를 접하는 일이 흔해진 지금, 최소한의 어셈블리 프로그램을 직접 만들어보는 경험은 이런 저수준 코드를 읽을 때 큰 도움이 된다. 이 글에서는 GNU 어셈블러(as)로 AArch64 어셈블리 프로그램을 작성, 빌드, 실행, 디버깅하는 방법을 정리한다.

핵심 개념 — 호출 규약과 시스템 콜

AArch64 프로그램을 작성하려면 레지스터가 어떤 역할로 예약돼 있는지부터 알아야 한다. AAPCS64(Procedure Call Standard for the ARM 64-bit Architecture)가 정의하는 관례는 다음과 같다.

레지스터용도
X0X7함수 인자 전달, X0은 반환값으로도 사용
X8리눅스 시스템 콜 번호(syscall number)
X16/X17임시 레지스터(IP0/IP1), 링커가 프로시저 호출 시 사용
X29프레임 포인터(FP)
X30링크 레지스터(LR), bl 호출 시 복귀 주소 저장
SP스택 포인터

시스템 콜을 직접 호출할 때는 X8에 syscall 번호를, X0X5에 인자를 채운 뒤 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) 시스템 콜 테이블을 아키텍처들이 공유하는 값이다.

실전 코드

먼저 writeexit 시스템 콜만으로 문자열을 출력하는 최소한의 프로그램을 작성한다. 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 클라우드 인스턴스 등에서 그대로 동작. x86_64에서는 sudo apt install binutils-aarch64-linux-gnu qemu-user-static 설치 후 aarch64-linux-gnu-as/aarch64-linux-gnu-ld로 빌드, qemu-aarch64 ./hello로 실행.
syscall 번호는 아키텍처마다 다름x86_64용 번호를 그대로 쓰면 엉뚱한 시스템 콜 호출됨. 정확한 번호는 arch/arm64/include/uapi/asm/unistd.h가 include하는 generic 테이블 기준으로 확인.
svc 호출 후 X0은 반환값으로 덮어써짐write 호출 전 X0에 넣어둔 fd 값은 svc #0 실행 후 반환값(쓰여진 바이트 수)으로 교체됨. 재사용하려면 별도 레지스터에 백업.
_start는 C 런타임 초기화를 거치지 않음main()과 달리 스택 정렬·TLS 초기화 없이 커널이 곧바로 실행을 넘기는 진입점. libc 함수(예: printf)를 섞어 쓰려면 _start 대신 gcc로 링크해 main부터 시작해야 함.

마무리

시스템 콜 두세 개만으로도 어셈블리 프로그램을 빌드하고 실행하는 전체 과정을 경험할 수 있다. 레지스터별 역할과 syscall 호출 규약만 익혀두면, 커널 소스나 임베디드 부트로더의 어셈블리 코드를 읽을 때도 각 명령어가 무엇을 하려는 것인지 훨씬 빠르게 파악할 수 있다. 다음 단계로는 bl/ret으로 함수를 나눠 호출하는 구조나, 스택에 인자를 넘기는 AAPCS64 호출 규약을 직접 코드로 확인해보는 것을 추천한다.

참고

답글 남기기