David Wheeler는 EDSAC을 개발하면서 어셈블리 언어(Assembly Language)와 어셈블러(Assembler)라는 것을 고안했습니다.
어셈블러는 일종의 통역사이고, 개발자들이 어셈블리어로 코드를 작성하면 컴퓨터가 이해할 수 있는 기계어로 코드를 치환해줬습니다. 어셈블리어가 기계어보다는 인간이 이해하기 훨씬 쉬웠으므로, 개발자들은 더욱 편리하게 개발을 진행하였습니다. 그리고 기계어를 어셈블리 언어로 번역하는 역어셈블러(Disassembler)를 개발했습니다.
어셈블리 언어
명령어 집합구조(Instruction Set Architecture, ISA)를 설명할 때 이야기했든, CPU에 사용되는 ISA는 여러 종류가 굉장히 다양하다. 따라서 이들의 종류만큼 다양한 수의 어셈블리어가 존재한다. 하지만 여기서는 x64 어셈블리어만 소개하도록 하겠습니다.
x64 어셈블리 언어
기본 구조
동사에 해당하는 명령어(Operation Code, Opcode)와 목적어에 해당하는 피연산자(Operand)로 구성된다.
x86-64 어셈블리어 문법 구조
명령어
중요한 21개의 명령어만 학습을 해보겠습니다.
데이터 이동(Data Transfer) | mov, lea |
산술 연산(Arithmetic) | inc, dec, add, sub |
논리 연산(Logical) | add, or, xor, not |
비교(Comparison) | cmp, test |
분기(Branch) | jmp, je, jg |
스택(Stack) | push, pop |
프로시져(Procedure) | call, ret, leave |
시스템 콜(System call) | syscall |
피연산자
피연산자에는 총 3가지 종류가 올 수 있다.
- 상수 (Immediate Value)
- 레지스터 (Register)
- 메모리 (Memory)
메모리 피연산자는 []으로 둘러싸인 것으로 표현되고, 앞의 크기 지정자(Size Directive) TYPE PTR이 추가될 수 있다.
여기서 타입에는 BYTE, WORD, DWORD, QWORD가 올 수 있고, 각각 1바이트, 2바이트, 4바이트, 8바이트의 크기를 지정합니다.
메모리 피연산자의 예
QWORD PTR [0x8048000] | 0x8048000의 데이터를 8바이트만큼 참조 |
DWORD PTR [0x8048000] | 0x8048000의 데이터를 4바이트만큼 참조 |
WORD PTR [rax] | rax가 가르키는 주소에서 데이터를 2바이트 만큼 참조 |
자료형 WORD의 크기가 2바이트인 이유
WORD의 차료형의 크기를 변경하면 기존의 프로그램들이 새로운 아키텍처와 호환되지 않을 수 있기 때문이다. 그래서 인텔은 기존에 사용하던 WORD의 크기를 그대로 유지하고, DWORD(Double Word, 32bit)와 QWORD(Quad Word, 64bit) 자료형을 추가로 만들었습니다.
x86-64 어셈블리 명령어
데이터 이동
데이터 이동 명령어는 어떤 값을 레지스터나 메모리에 옮기도록 지시합니다.
mov dst, src: src에 들어있는 값을 dst에 대입 | |
mov rdi, rsi | rsi의 값을 rdi에 대입 |
mov QWORD PTR[rdi], rsi | rsi의 값을 rdi가 가리키는 주소에 대입 |
mov QWORD PTR[rdi+8*rcx], rsi | rsi의 값을 rdi+8*rcx가 가리키는 주소에 대입 |
lea dst, src : src의 유효 주소(Effective Address, EA)를 dst에 저장합니다. | |
lea rsi, [rbx+8*rcx] | rbx+8*rcx를 rsi에 대입 |
산술 연산
덧셈, 뺄셈, 곱셈, 나눗셈 연산을 지시합니다. 여기서 곱셈과 나눗셈은 따로 설명하지 않겠습니다.
add dst, src : dst에 src의 값을 더합니다. | |
add eax, 3 | eax += 3 |
add ax, WORD PTR[rdi] | ax += *(WORD *)rdi |
ax += *(WORD *)rdi 라는 말은 rdi가 가리키는 메모리 주소에서 16비트(WORD)만큼의 데이터를 읽어와서 ax에 더하라는 말입니다. 8비트 = 1바이트입니다.
sub dst, src: dst에서 src의 값을 뺍니다. | |
sub eax, 3 | eax -= 3 |
sub ax, WORD PTR[rdi] | ax -= *(WORD *)rdi |
inc op: op의 값을 1 증가 시킴 | |
inc eax | eax += 1 |
dec op: op의 값을 1 감소 시킴 | |
dec eax | eax -= 1 |
논리 연산
and, or, xor, neg 등의 비트 연산을 지시합니다. 이 연산은 비트 단위로 이루어 집니다.
and dst, src: dst와 src의 비트가 모두 1이면 1, 아니면 0
[Register]
eax = 0xffff0000
ebx = 0xcafebabe
[Code]
and eax, ebx
[result]
eax = 0xcafe0000
or dst, src: dst와 src의 비트 중 하나라도 1이면 1, 아니면 0
[Register]
eax = 0xffff0000
ebx = 0xcafebabe
[Code]
or eax, ebx
[result]
eax = 0xffffbabe
논리연산 - xor & not
xor dst, src: dst와 src의 비트가 서로 다르면 1, 같으면 0
[Register]
eax = 0xffffffff
ebx = 0xcafebabe
[Code]
xor eax, ebx
[result]
eax = 0x35014541
not op: op의 비트 전부 반전
[Register]
eax = 0xffffffff
[Code]
not eax
[Result]
eax = 0x00000000
비교
두 피연산자의 값을 비교하고, 플래그를 설정합니다
cmp op1, op2: op1과 op2를 비교
cmp는 두 피연산자를 빼서 대소를 비교합니다. 연산의 결과는 op1에 대입하지 않습니다.
ex) 서로 같은 두수를 빼면 결과가 0이 되어, ZF플래그 설정되는데, 이후에 CPU는 이 플래그를 보고 두 값이 같았는지 판단할 수 있다.
[Code]
1: mov rax, 0xA
2: mov rbx, 0xA
3: cmp rax, rbx ; ZF = 1
test op1, op2: op1과 op2를 비교
test는 두 피연산자에 AND 비트연산을 취합니다. 연산의 결과는 op1에 대입하지 않습니다.
ex) 아래 코드에서 처럼 0이된 rax를 op1과 op2로 삼아 test를 수행하면, 결과가 0이므로 ZF플래그가 설정됩니다.
이후에 CPU는 ZF플래그를 보고 rax가 0이었는지 판단할 수 있다.
[Code]
1: xor rax, rax
2: test rax, rax ; ZF = 1
분기
분기 명령어는 rip를 이동시켜 실행 흐름을 바꿉니다.
**분기문은 굉장히 많은 수가 존재한다. 하지만 몇 개만 살펴보면 이름을 통해 직관적으로 의미를 파악할 수 있다.
jmp addr: addr로 rip를 이동시킵니다.
[Code]
1: xor rax, rax
2: jmp 1 ; jump to 1
je addr : 직전에 비교한 두 피연산자가 같으면 점프 (jump if equal)
[Code]
1: mov rax, 0xcafebabe
2: mov rbx, 0xcafebabe
3: cmp rax, rbx ; rax == rbx
4: je 1 ; jump to 1
jg addr: 직전에 비교한 두 연산자 중 전자가 더 크면 점프 (jump if greater)
[Code]
1: mov rax, 0x31337
2: mov rbx, 0x13337
3: cmp rax, rbx ; rax > rbx
4: jg 1