프로그래밍/어셈블리어

X86-64 어셈블리 기본만 알아두자

황태건 2023. 10. 9. 23:46

x86-64가 뭐임?

x86 : 인텔 사에서 생산하는 'CPU 시리즈'
- x86, IA...로 시작하는 아키텍쳐들이 여기 속함
 
x86-64 : 이 시리즈에 속하는 CPU 아키텍쳐, 64bit를 기본 연산 단위로 사용
- 이 아키텍쳐를 기반으로 코어 i7, Pentium같은 실제 제품이 생산된다.
- 또다른 의미 : x86-64 아키텍쳐에서 사용하는 어셈블리 언어
 

Byte Ordering(Endian)

LSB(데이터의 가장 오른쪽 바이트)의 위치를 기준으로 기억하면 직관적
 
Big endian : LSB가 (biggest) 주소에
Little endian : LSB가 작은(least) 주소에
 
x86-64에서는 little endian 방식을 사용한다.
 
little endian 방식은 '거꾸로 적는 것'이라고 생각해도 별 문제는 없지만, 메모리 구조를 세로로 표현할 때는 약간 헷갈릴 수도 있으니 LSB 기준 구분도 알아두면 좋다.
 

Basic instruction

1. x86-64 아키텍쳐 레지스터

특수 레지스터
항상 특정한 값만 저장한다.
%rip : 다음 명령어 주소
%rsp : 현재 메모리 스택 영역의 top 주소
 
일반 레지스터
CPU가 당장 사용할 값을 저장한다. 일부는 특별한 값을 저장하기도 하지만, 특수 레지스터처럼 반드시 그러한 값들만 저장해야 하는 것은 아니다.
 
'일부'에는 이런 레지스터들이 있다. 중요하다.
%rax : 함수의 반환값을 저장
%rdi, %rsi, %rdx : 함수의 1,2,3번째 매개변수를 저장
 
부분 접근
어셈블리 코드를 보면 r로 시작하지 않는 레지스터명들이 있다. 해당 레지스터의 8byte의 저장공간 중 일부만 사용하기 때문이다. 3자리 레지스터명에서 뒤의 2자리가 같으면 '부분 접근했구나' 생각하면 된다. 예외도 있는데 굳이 기억하진 말자.
예를 들어 %eax와 %ax는 %rax 레지스터의 일부만 접근하는 것이다.
 

2. mov src, dest

src에서 dest로 값을 옮긴다.
 
src와 dest에 올 수 있는 형식은 3가지이다.
 
즉칫값 : $로 시작한다. dest는 값을 전달할 목적지이므로 즉칫값이 올 수 없다.
레지스터 : %로 시작한다. src일 경우 해당 레지스터에 담긴 값을 사용하고, dest일 경우 src의 값을 해당 레지스터에 저장한다.
* 아까 말한대로 부분 접근이 가능한데, dest에서 부분 접근을 사용하면 나머지 부분은 0 bit로 채워진다. (4byte만 해당)

mov $-1, %eax  #eax : rax의 4byte 부분 접근 # %rax 값 : 0x00000000FFFFFFFF (0xFF..F = -1)

 
메모리 : (%레지스터명또는 $없는 양의 정수로 나타낸다. 레지스터명일 경우 해당 레지스터에 담긴 값을 주소로 사용한다.
레지스터와 마찬가지로 src일 경우 값을 사용하고, dest일 경우 값을 해당 주소에 저장한다.
 
* 메모리 주소는 단순히 (%레지스터명)으로 나타낼 수도 있지만, 여러 가지 주소 표현이 가능한 일반적인 방식이 있다.
src 또는 dest 자리에 A(B, C, D)가 있으면 이는 A + B + C * D의 값을 의미한다고 보면 된다. B나 C 자리에 레지스터가 있을 경우 레지스터에 저장된 값을 사용한다.

mov 0x1000(,%rcx,4), %rax # (0x1000 + 0 + rcx에 저장된 값 x 4)번 주소 메모리에 저장된 값을 rax 레지스터에 전달

 

2.1. mov 접미사

mov 뒤에는 b(1 byte), w(2), l(4), q(8)의 접미사가 붙는데, 이는 옮기는 값의 크기를 나타낸다.
 
src와 dest가 둘 다 레지스터일 경우, 둘의 크기가 다를 수도 있다. ex) char 변수 값을 int 변수에 전달
 
이때는 뒤에 zero extension sign extension을 의미하는 z 또는 s가 붙는다.
ex) movzbw : (b)1바이트에서 (w)2바이트로 전달하되, 모자란 앞의 비트들은 (z)0으로 채운다.
ex) movslq : (l)4바이트에서 (q)8바이트로 전달하되, 모자란 앞의 비트들은 (s)src의 MSB로 채운다.
 

3. 산술 연산, op src, dest

op 자리에는 add, sub 같은 산술연산 종류가 들어간다.
이 코드는 dest = dest op src 형식의 연산을 수행한다. add면 dest = dest + src
대부분 연산의 종류는 이름만 보고 유추할 수 있다.
 
shr, shl, sar 같은 이름은 조금 생소할 수 있다. shr는 shift right, shl은 shift left, sar은 shift arithmecitally right를 의미하는 연산이다. shr과 sar의 차이는 zero, sign extension처럼 앞자리를 0으로 채우느냐(shr), MSB로 채우느냐(sar) 이다.
* sar이 arithmetic이니까 'MSB를 확인해 채운다'라는 추가 연산이 들어간다.. 라고 생각하자.
 
쉬프트 연산 외에 생소한 연산에는 lea(레아)가 있다. lea는 앞의 메모리 주소 표현 방식에 있던 A+B+C*D 연산을 수행해준다. dest는 연산에 사용되지 않고 값을 저장하는 데만 사용된다. 포인터 연산에서 유용하게 사용된다.

# B : int형 배열 arr의 시작 주소
lea (B, C, 4), %rax # rax 레지스터에 원소 B[C]의 주소 저장

 

function call

함수를 호출할 때는 다음의 3가지를 처리해줘야 한다.
1. 제어권 전달 2. 데이터 전달 3. 메모리 할당/해제
 
이를 위해 우리는 메모리의 스택 영역을 사용한다. 스택 영역은 아까 언급한 특수 레지스터 %rsp를 통해 관리한다.
스택 영역에서 사용하는 어셈블리 명령어는 다음과 같다.

push src

src 값을 확인한 뒤, rsp를 감소시킨다. 즉 스택의 top을 증가시킨다. 증가시킨 스택의 빈 자리에 src를 저장한다. 증가시킨 rsp 주소를 시작 위치로 한다.

pop dest

현재 top에 저장된 값을 확인하고 rsp를 증가시킨다. 즉 스택의 top을 감소시킨다. 확인한 값을 dest에 저장한다.
 
1. 제어권 전달
먼저 함수의 호출은 call dest라는 명령어로 실행된다. 이 때 dest는 호출할 함수의 시작 명령어 주소이다.
call 명령어는 바로 다음 명령어의 주소를 push하고 dest로 이동한다. 바로 다음 명령어 주소는 rip 레지스터에 저장돼있다.
 
복귀는 ret 명령어로 실행되는데, ret는 pop %rip와 동일한 역할을 수행한다. 스택의 맨 위에 저장된 값을 rip 레지스터에 저장하는데, 이 값이 바로 아까 저장했던 '바로 다음 명령어의 주소', 즉 return address다. rip에 이 값이 전달되므로 컴퓨터는 다음번에 return address에 있는 명령을 수행함으로써 원래 위치로 복구하게 된다.
 
2. 데이터 전달
앞에서 언급한 '일부' 레지스터를 활용하면 매개변수와 반환값 전달을 설명할 수 있다. 매개변수로 전달할 값을 순서에 맞게 %rdi, %rsi, %rdx로 mov 시킨다. 호출된 함수에서는 별도의 작업 없이 세 레지스터에 저장한 값을 사용할 수 있다. 만약 함수가 값을 반환해야 하면 그 값을 %rax에 mov한다. 마찬가지로 함수가 끝나고 나서 별도의 작업 없이 rax 레지스터에 저장된 값을 반환값으로써 사용할 수 있다.
 
3. 메모리 할당/해제
메모리 관리는 rsp 레지스터의 값을 조작해 구현한다.