코딩 · 프로그래밍 언어
초보자를 위한 어셈블리어 입문 튜토리얼 (x86-64, NASM)
핵심 답변
어셈블리어는 프로세서의 기계어 명령을 사람이 읽을 수 있게 표현한 것으로, 보통 한 줄이 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
syscallnasm -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, rbx | rbx를 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
syscallnasm -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
syscallmov 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 progbitsnasm -f elf64 square.asm -o square.o
gcc square.o -o square
./square
# 7 squared is 49gcc로 링크하면 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 5050AT&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], 0 | movq $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을 사용합니다.