cv.cm

코딩 · 프로그래밍 언어

초보자를 위한 어셈블리어 입문 튜토리얼 (x86-64, NASM)

12분 분량

핵심 답변

어셈블리어는 프로세서의 기계어 명령을 사람이 읽을 수 있게 표현한 것으로, 보통 한 줄이 mov rax, 1이나 add rax, rbx 같은 명령 하나가 됩니다. 변수도 타입도 없이 레지스터, 메모리 주소, 스택을 직접 다루며, 반복은 점프로 직접 만들어야 합니다. 이 튜토리얼은 Linux의 x86-64와 NASM 어셈블러를 사용합니다. .asm 파일을 작성하고 nasm -f elf64로 어셈블한 뒤 ld로 링크합니다.

진법 변환 열기

어셈블리란 무엇이며 누가 쓰는가

프로세서 계열마다 고유한 명령어 집합이 있고, 따라서 고유한 어셈블리어가 있습니다. x86-64(AMD64라고도 함)는 대부분의 데스크톱 PC와 서버에서, ARM(AArch64)은 대부분의 휴대폰, Apple silicon Mac, 많은 서버에서 실행됩니다. 어셈블러는 어셈블리를 기계어로 번역하고, 링커는 그 결과를 합쳐 실행 파일을 만듭니다.

오늘날 전체를 어셈블리로 작성하는 프로그램은 드뭅니다. 운영체제 커널과 부트로더, 성능이 중요한 루틴(암호, 코덱, 수학 라이브러리), 임베디드 펌웨어, 컴파일러 개발, 보안 연구, 리버스 엔지니어링에 쓰입니다. 가장 흔하게는 컴파일러가 만든 결과를 이해하거나 충돌을 디버깅하려고 어셈블리를 읽습니다.

NASM 설치와 Hello, world 실행

Debian이나 Ubuntu에서는 sudo apt install nasm binutils로 도구를 설치합니다(binutils가 링커 ld를 제공합니다). 이 프로그램은 시스템 콜로 Linux 커널을 직접 호출하므로 C 라이브러리가 필요 없습니다.

; hello.asm - x86-64 Linux, NASM syntax
section .data
    msg     db "Hello, world!", 10   ; 10 = newline
    msglen  equ $ - msg              ; length computed by the assembler

section .text
    global _start

_start:
    mov     rax, 1          ; system call number 1 = write
    mov     rdi, 1          ; file descriptor 1 = stdout
    mov     rsi, msg        ; address of the bytes
    mov     rdx, msglen     ; number of bytes
    syscall

    mov     rax, 60         ; system call number 60 = exit
    xor     rdi, rdi        ; exit code 0
    syscall
nasm -f elf64 hello.asm -o hello.o
ld hello.o -o hello
./hello
# Hello, world!

section .data는 초기화된 데이터를, section .text는 코드를 담고, _start는 ld가 찾는 진입점입니다. 주석은 ;로 시작합니다. 마지막의 exit 시스템 콜이 없으면 프로세서는 뒤따르는 바이트를 계속 실행하다가 프로그램이 충돌합니다.

레지스터와 데이터 크기

레지스터는 CPU 안에 있는 작고 매우 빠른 저장 공간입니다. x86-64에는 64비트 범용 레지스터가 16개 있으며, 각각 더 작은 부분으로도 쓸 수 있습니다.

레지스터일반적인 역할더 작은 부분
rax반환 값, 시스템 콜 번호eax(32비트), ax(16), al(8)
rdi, rsi, rdx함수 또는 시스템 콜의 첫 세 인자edi, esi, edx
rcx, r8, r9함수의 네 번째부터 여섯 번째 인자ecx, r8d, r9d
rbx, rbp, r12–r15함수 호출 후에도 유지되어야 하는 값(callee-saved)ebx, r12d
rsp스택 포인터: 스택 맨 위의 주소데이터용으로 쓰지 말 것
rip명령 포인터(점프, 호출, 반환으로만 바뀜)직접 쓸 수 없음

메모리의 데이터는 db(바이트, 8비트), dw(워드, 16), dd(더블워드, 32), dq(쿼드워드, 64)로 선언합니다. 초기화되지 않은 버퍼는 section .bss에 resb, resq 등으로 둡니다. eax 같은 32비트 레지스터에 쓰면 rax의 상위 32비트도 지워지므로, xor eax, eax가 rax를 0으로 만드는 흔한 방법입니다.

기본 명령과 제어 흐름

대부분의 명령은 목적지를 먼저, 소스를 두 번째로 받습니다. add rax, rbx는 rax = rax + rbx를 뜻합니다. if나 while 문은 없습니다. cmp로 값을 비교해 플래그를 설정한 다음 조건부 점프를 사용합니다.

명령의미
mov rax, rbxrbx를 rax로 복사
add, sub, imul, inc, dec정수 산술
cmp rax, 10비교(rax - 10을 계산해 플래그만 설정)
je, jne, jl, jg, jle, jge같으면, 다르면, 작으면, 크면... 점프(부호 있음)
jmp label무조건 점프
push, pop값을 스택에 넣고, 꺼냄
call, ret함수 호출, 함수에서 반환
; sum.asm - add 1..10, return the result as the exit code
section .text
    global _start

_start:
    xor     rax, rax        ; sum = 0
    mov     rcx, 1          ; i = 1
.loop:
    cmp     rcx, 10
    jg      .done           ; if i > 10, leave the loop
    add     rax, rcx        ; sum += i
    inc     rcx             ; i++
    jmp     .loop
.done:
    mov     rdi, rax        ; exit code = sum
    mov     rax, 60         ; exit
    syscall
nasm -f elf64 sum.asm -o sum.o && ld sum.o -o sum
./sum; echo $?
# 55

.loop처럼 점으로 시작하는 레이블은 바로 앞의 일반 레이블에 속하는 지역 레이블입니다. jl과 jg는 값을 부호 있는 수로 취급하고, jb와 ja는 부호 없는 버전입니다.

메모리 주소 지정과 배열

대괄호는 이 주소의 메모리를 뜻합니다. 주소는 베이스 레지스터, 1, 2, 4, 8을 곱한 인덱스 레지스터, 상수를 조합할 수 있습니다: [base + index*scale + offset]. 덕분에 배열 접근이 명령 하나로 됩니다.

section .data
    nums    dq 3, 1, 4, 1, 5         ; five 64-bit integers
    count   equ ($ - nums) / 8

section .text
    global _start

_start:
    xor     rax, rax                 ; total = 0
    xor     rcx, rcx                 ; index = 0
.next:
    add     rax, [nums + rcx*8]      ; total += nums[index]
    inc     rcx
    cmp     rcx, count
    jl      .next
    mov     rdi, rax                 ; exit code 14
    mov     rax, 60
    syscall

mov rax, nums는 주소를 로드하고 mov rax, [nums]는 그 주소에 저장된 값을 로드합니다. lea rsi, [rbx + 8]은 메모리를 읽지 않고 주소를 계산합니다. 레지스터만으로 크기가 분명하지 않을 때는 mov qword [rbx], 0처럼 명시하세요.

함수와 호출 규약

Linux에서 함수는 System V AMD64 ABI를 따릅니다. 정수 인자는 rdi, rsi, rdx, rcx, r8, r9에 들어가고 반환 값은 rax에 있습니다. 함수는 rbx, rbp, r12–r15, rsp를 보존해야 하며, call 직전에 rsp는 16의 배수여야 합니다. 이 규칙을 따르면 어셈블리에서 printf 같은 C 함수를 호출할 수 있고 C에서도 어셈블리를 호출할 수 있습니다.

; square.asm - define a function and call printf from the C library
    extern  printf
    global  main

section .data
    fmt     db "%ld squared is %ld", 10, 0

section .text
square:                         ; long square(long x)
    mov     rax, rdi
    imul    rax, rdi
    ret

main:
    push    rbx                 ; save rbx; also aligns rsp to 16
    mov     rbx, 7
    mov     rdi, rbx
    call    square              ; rax = 49
    lea     rdi, [rel fmt]      ; argument 1: format string
    mov     rsi, rbx            ; argument 2
    mov     rdx, rax            ; argument 3
    xor     eax, eax            ; varargs: no vector registers used
    call    printf wrt ..plt
    xor     eax, eax            ; return 0 from main
    pop     rbx
    ret

section .note.GNU-stack noalloc noexec nowrite progbits
nasm -f elf64 square.asm -o square.o
gcc square.o -o square
./square
# 7 squared is 49

gcc로 링크하면 main을 호출하는 C 런타임이 추가됩니다. [rel fmt]와 wrt ..plt는 위치 독립 코드를 만드는데, 최신 Linux 배포판이 기본으로 사용합니다. 마지막 줄은 스택을 실행 불가로 표시하여 링커 경고를 피합니다.

오류 처리

어셈블리에는 예외가 없습니다. 실패한 Linux 시스템 콜은 rax에 음수 오류 번호를 반환하므로(예: -2는 ENOENT, 그런 파일 없음) 호출마다 부호를 검사합니다. C 라이브러리 함수는 반환 값과 errno로 오류를 알립니다. 잘못된 주소 읽기나 0으로 나누기 같은 하드웨어 오류는 시그널(세그멘테이션 폴트 또는 부동소수점 예외)과 함께 프로그램을 멈춥니다.

    mov     rax, 2              ; system call 2 = open
    lea     rdi, [rel path]     ; path is a zero-terminated string
    xor     esi, esi            ; flags: O_RDONLY
    syscall
    test    rax, rax
    js      .open_failed        ; negative result = -errno
    mov     r12, rax            ; success: rax is the file descriptor

작은 완성 프로그램: 10진수로 숫자 출력

숫자를 출력하려면 직접 자릿수로 변환해야 합니다. 이 프로그램은 1 + 2 + ... + 100을 계산하고, 10으로 반복해서 나누어 오른쪽부터 자릿수를 구한 뒤 시스템 콜 한 번으로 출력합니다.

section .bss
    buf     resb 32

section .text
    global _start

_start:
    xor     rax, rax
    mov     rcx, 100
.sum:
    add     rax, rcx
    loop    .sum                ; rcx -= 1, repeat while rcx != 0
    lea     rsi, [buf + 31]
    mov     byte [rsi], 10      ; newline at the end of the buffer
    mov     rbx, 10
.digit:
    xor     rdx, rdx            ; div uses rdx:rax as the dividend
    div     rbx                 ; rax = quotient, rdx = remainder
    add     dl, '0'             ; remainder to ASCII digit
    dec     rsi
    mov     [rsi], dl
    test    rax, rax
    jnz     .digit
    mov     rdx, buf + 32
    sub     rdx, rsi            ; length = end - start
    mov     rax, 1              ; write(1, rsi, rdx)
    mov     rdi, 1
    syscall
    mov     rax, 60             ; exit(0)
    xor     rdi, rdi
    syscall
; prints 5050

AT&T와 Intel 문법, 그리고 ARM

같은 x86-64 명령을 두 가지 문법으로 쓸 수 있습니다. NASM과 대부분의 Intel, Microsoft 문서는 Intel 문법을 사용합니다. GNU 어셈블러(as), GCC 출력, objdump는 기본적으로 AT&T 문법을 사용하며, objdump -d -M intel과 gcc -S -masm=intel로 Intel 문법으로 바꿀 수 있습니다.

항목Intel (NASM)AT&T (GNU as)
피연산자 순서mov rax, 1(목적지가 먼저)movq $1, %rax(소스가 먼저)
레지스터와 상수rax, 5%rax, $5
메모리 피연산자[rbx + rcx*8 + 16]16(%rbx,%rcx,8)
피연산자 크기mov qword [rbx], 0movq $0, (%rbx)(접미사 b, w, l, q)

ARM 프로세서는 다른 명령어 집합을 사용합니다. AArch64에는 64비트 범용 레지스터가 31개(x0–x30) 있고, 명령 인코딩은 32비트 고정이며, 로드/스토어 설계입니다. 산술은 레지스터에서만 동작하고 메모리는 ldr와 str로 접근합니다. Linux에서는 시스템 콜 번호를 x8에 넣고 svc #0으로 호출합니다.

// AArch64 Linux, GNU as syntax: exit(0)
    mov     x0, #0          // exit status
    mov     x8, #93         // system call 93 = exit
    svc     #0

다음 단계

  • 지시어, 매크로, 출력 형식을 다룬 NASM 매뉴얼.
  • 모든 명령의 정확한 동작을 설명한 Intel 64 and IA-32 Architectures Software Developer's Manuals.
  • 호출 규약과 스택 레이아웃을 다룬 System V AMD64 ABI 문서.
  • 작은 C 함수를 gcc -O2 -S -masm=intel(또는 온라인 컴파일러 탐색기)로 컴파일해 생성된 어셈블리를 읽어 보세요.

자주 묻는 질문

어셈블리어는 아직도 배울 가치가 있나요?

네. 컴퓨터가 동작하는 방식을 이해하고, 충돌을 디버깅하고, 컴파일러 출력을 읽고, 보안 작업과 임베디드 개발을 하는 데 도움이 됩니다. 다만 애플리케이션 전체를 어셈블리로 작성하는 것은 오늘날 거의 현실적이지 않습니다.

x86 어셈블리와 ARM 어셈블리는 같은가요?

아니요. 서로 다른 명령어 집합이며 레지스터, 명령, 인코딩이 다릅니다. 개념(레지스터, 메모리, 점프, 호출 규약)은 그대로 통하지만 코드는 그렇지 않습니다.

NASM과 GAS의 차이는 무엇인가요?

NASM은 Intel 문법을 쓰는 독립형 어셈블러입니다. GNU 어셈블러인 GAS는 GCC가 사용하며 기본은 AT&T 문법이지만 .intel_syntax noprefix로 Intel 문법도 받습니다.

이 코드는 Windows나 macOS에서 실행되나요?

그대로는 실행되지 않습니다. 명령은 어떤 x86-64 시스템에서나 같지만 시스템 콜 번호, 실행 파일 형식, 호출 규약이 다릅니다. Windows는 다른 호출 규약을 쓰고, Apple silicon Mac은 x86-64 대신 ARM을 사용합니다.

코딩 튜토리얼 전체