어셈블리 관련 정리(x86)

2021. 10. 15. 03:10CS 지식

어셈블리어

C를 비롯한 고급언어들과 달리 컴퓨터와 가까운 저급 언어이기 때문에,

아키텍처에 따라 문법(규격)이 다르다. 즉 호환성이 없다.

기계어와 1대1로 대응된다.

 

프로그램 생성&동작까지..

소스파일 -> 컴파일러 -> (어셈블리어 -> 어셈블러) ->  기계어 (오브젝트 코드)

-> (+라이브러리) Linkage Editor -> 실행 파일 -> 로더  -> 메모리

 

CPU 레지스터란

CPU 내부에 위치한, CPU가 메모리에서 가져온 필요한 값들을 저장하는 곳.

속도가 가장 빠르다.

모든 연산은 cpu 레지스터에서 이루어져야한다.

 

CPU 레지스터의 종류

ebp, esp : 현재 스택프레임의 stack pointer(=top), base pointer

eax, ebx, edx : 연산시 주로 사용하는 임시 전역변수의 역할. 다른 임시로 사용가능하다.

eax의 경우 함수에서 return되는 값을 저장.

ecx : rep 반복문 등의 카운팅 역할.

esi, edi : 데이터 복사시 원본 주소, 목적지 주소.

eip: 현재 실행중인 메모리 위치

플래그 레지스터 : 제로플래그 , 캐리플래그 등..

 

사용하는 시스템의 비트에 따라 레지스터의 이름이

ax(16비트), eax(32비트), rax(64비트)로 달라진다.

그러나 실제 이 레지스터들이 사용하는 공간은 같다.

 

어셈블리 명령어

mov/lea A,B : A에 B를 대입한다. (lea의 경우 B의 주소를 대입)

push/pop: 스택프레임에 push, pop을 한다.

add/sub/imul/idiv A,B : A에 B를 사칙연산한다.

 

함수 호출시 어셈블리

 

위 스크린샷처럼,

 

함수가 호출되면

1. 기존 스택프레임 위에 현재 ebp 값을 push한다.

2. ebp에다가 esp 값을 넣는다.

3. esp를 일정 크기만큼 올린다. (뺀다)

4. 기존 ebx, esi, edi를 push.

5. 함수 역할 수행

6. 백업된 ebx, esi edi를 pop

7. esp에 ebp값을 넣음.

8. 백업된 ebp를 pop

 

이런 과정을 통해 함수 호출 시 메모리를 유지.

 

변수 선언

int a;
a =10;
int a = 10;

선언과 초기화를 따로하던, 같이하던 어셈블리 상에서는 어차피 같다.

지역변수 크기에 대해서는 이미 스택에 할당이 되어있기에 '선언'은 아무 영향이 없기 때문.

 

전위 / 후위 증감 연산

int a = 0;
a++;
++a;

일반 변수의 경우 전위 / 후위 증감 연산의 성능 차이는 존재하지 않는다.

컴파일러에서 이를 판단하기 때문에 값 증감 / 값 대입의 순서의 차이만 있기 때문이다.

(오히려 CPU 최적화 때문에 후위 증감 연산이 조금 더 빠를 수도 있다고 한다..)

 

하지만 클래스의 경우 연산자 오버로딩을 통해 구현해서 쓰기 때문에,

전위 증감 연산이 후위 증감연산보다 빠르다.

전위 증감 연산은 증감 연산 후 자기 자신을 return하는 반면,

후위 증감 연산은 사본을 생성하고 증감 연산을 하고 사본을 return 해야하기 때문이다.

 

switch-case <-> if-else

if-else의 경우 우리의 상식대로 실제로 분기문으로 처리가 된다.

하지만 switch-case의 경우 정상적인 경우 분기로 처리되지 않고 더 효율적으로 처리된다.

디스어셈블리로 처리 방법을 살펴보자.

 

int main(void) {
006B1002  in          al,dx  
006B1003  sub         esp,0Ch  
	int a = 2;
006B1006  mov         dword ptr [ebp-0Ch],2  //a에 2를 넣음
	int t;
	switch (a) {
006B100D  mov         eax,dword ptr [ebp-0Ch]  
006B1010  mov         dword ptr [ebp-8],eax  //a의 값을 ebp-8에 임시로 저장
006B1013  mov         ecx,dword ptr [ebp-8]  //ecx에 ebp-8의 값을 저장
// 이것은 switch문이 0이 아닌 1부터 시작하기 때문
006B1016  sub         ecx,1		//ecx에서 1을 뺌
006B1019  mov         dword ptr [ebp-8],ecx  // ebp-8에 ecx저장.
006B101C  cmp         dword ptr [ebp-8],3  	 // 3보다 ebp-8의 값을 비교
006B1020  ja          006B1050  		// ebp-8의 값이 더 큰 경우 default로 점프
006B1022  mov         edx,dword ptr [ebp-8]  //edx에 ebp-8의 값 저장.
006B1025  jmp         dword ptr [edx*4+006B1070h] // 006B1070h + edx*4에 저장되어있는 곳으로 점프
//아래는 실제 메모리 값. 메모리의 주소가 값으로 저장되어있다.
//각각 case1, case2, case3, case4의 메모리 주소이다.
//0x006B1070  2c 10 6b 00  ,.k.
//0x006B1074  35 10 6b 00  5.k.
//0x006B1078  3e 10 6b 00  >.k.
//0x006B107C  47 10 6b 00  G.k.
	case 1:
		t = 1;
006B102C  mov         dword ptr [ebp-4],1  
		break;
006B1033  jmp         006B1057  // break. switch문 밖으로 나감.
	case 2:
		t = 2;
006B1035  mov         dword ptr [ebp-4],2  
		break;
006B103C  jmp         006B1057  
	case 3:
		t = 3;
006B103E  mov         dword ptr [ebp-4],3  
		break;
006B1045  jmp         006B1057  
	case 4:
		t = 5;
006B1047  mov         dword ptr [ebp-4],5  
		break;
006B104E  jmp         006B1057  
	default:
		t = 7;
006B1050  mov         dword ptr [ebp-4],7  
		break;
	}
	std::cout << t;
006B1057  mov         eax,dword ptr [ebp-4]  
006B105A  push        eax  
006B105B  mov         ecx,dword ptr ds:[006B2038h]  
006B1061  call        dword ptr ds:[006B2034h]  
	return 0;
006B1067  xor         eax,eax  
}

이렇게 switch-case문은

case들을 정리하고 (위 예에서는 1부터 case가 시작하기 때문에 인자에서 1을 빼고 마치 case가 0부터 시작하는 것처럼 작동하게 만들었다.) 

특정 메모리 공간에 jump table (메모리 주소들을 저장해놓음)을 생성하여 case들에 대한 분기를 거치지않고,

주소에서 인자값만큼 더한 곳에 있는 실제 케이스의 주소로 바로 이동하는 효율적인 메커니즘을 가지고 있다.

 

그러나 case들이 너무 많고 서로 크기가 심하게 엉켜있는 경우에는,

if문 처럼 분기 메커니즘으로 작동할 수 있다.

그러므로 switch문을 사용할 때, 최대한 순서대로 케이스를 작성하고, 

프로그램 실행 시 실제로 어셈블리상으로 jumptable이 생성되었는지 확인해야한다.

 

switch문이 너무 방대해져서 jumptable이 불가능한 최악의 경우에는

if문으로 유형을 나누거나 직접 compiler처럼 개발하는 방법이 있을 수 있겠다.

 

while <-> for

비슷하지만 무한루프시 while문은 1이 1인지를 체크한다. (최적화 안하는 경우)

 

함수 호출

어셈블리에서는 call 명령을 통해 함수 호출을 구현하고, ret 명령을 통해 return을 구현한다.

명령어 작동 방식
call A 돌아갈 코드위치를 stack에 넣고 A로 jmp
ret stack에 저장된 돌아갈 코드위치로 jmp,
인자가 있을시 그 크기만큼의 메모리를 비워줌.
리턴값은 레지스터를 통해 전달된다. (eax)
jmp A A 위치로 이동

debug 컴파일 시 call을 하면 jumptable을 거쳐서 실제 함수 위치로 이동하고,

release 컴파일 시 call을 하면 즉시 함수 위치로 이동한다.

 

이러한 차이는 증분링크 기능의 사용 여부에 따라 발생한다. (프로젝트속성-링커-일반-증분링크)

이 증분 링크를 사용하면 프로그램 실행 도중 소스코드를 변경하여 프로그램을 변경하는게 가능하다.

 

이 기능을 위해 증분링크 사용시 크게 두가지의 처리를 한다.

1. 코드 / 데이터의 패딩

   스택 생성시 공간을 여유롭게하고, 함수 사이의 공간도 여유롭게 만든다.

2. 함수 jump table 생성

   함수를 새 주소로 재배치하는 시간을 단축하기 위해서 jumptable을 생성한다.

 

구조체

 

struct Observer {
	int hp;
	int speed;
	char name[20];
	long long rank;
	char initial;
};
int main(void)
{
	Observer ob;
	ob.hp = 0x11111111;
	ob.speed = 0x22222222;
	strcpy_s(ob.name, "Hello, World!");
	ob.rank = 0x4444444444444444;
	ob.initial = 0x55;
	return 0;
}
0x00E2FACC  11 11 11 11  .... //hp
0x00E2FAD0  22 22 22 22  """" //speed
0x00E2FAD4  48 65 6c 6c  Hell //name
0x00E2FAD8  6f 2c 20 57  o, W //name
0x00E2FADC  6f 72 6c 64  orld //name
0x00E2FAE0  21 00 00 00  !... //name
0x00E2FAE4  04 fb e2 00  .??. //name
0x00E2FAE8  49 11 fe 00  I.?. 
0x00E2FAEC  44 44 44 44  DDDD //rank
0x00E2FAF0  44 44 44 44  DDDD //rank  
0x00E2FAF4  55 00 06 01  U... //initial
0x00E2FAF8  cd 12 fe 00  ?.?.
0x00E2FAFC  ff 4e 0d 3d  .N.= 
0x00E2FB00  48 fb e2 00  H??. //ebp가 가리키는 곳

구조체 내부의 패딩은 멤버 변수의 크기에 맞춘다. (할당되는 메모리 주소가 해당 변수 크기의 배수)

메모리에서 구조체 또한 구조체 내부의 가장 크기가 큰 변수의 사이즈에 맞춘다.

맞추기 위해 AND 연산으로 마스킹한다.

ex) 8바이트 단위의 메모리가 필요할 때 : AND 현재메모리, 0xfffffff8

 

해당 변수 크기 경계에 변수가 들어가야 하는 이유는,

캐시 메모리에 로드될 때, 캐시 라인 사이에 걸리지 않고 하나의 캐시라인에 들어가있기 위해서이다.

만약 캐시 라인 끝에 변수가 걸리게되면,

그 변수를 위해서 캐시라인을 하나 더 로드해야하고 (성능 하락) ,

멀티스레드 환경에서는 하나 더 로드하는 사이에 값이 달라질 수 있다.

 

구조체 복사

 

//구조체에 배열 등이 들어가있어 복잡할 경우 rep
00101044  mov         ecx,0Ch  
00101049  lea         esi,[ebp-34h]  
0010104C  lea         edi,[ebp-64h]  
0010104F  rep movs    dword ptr es:[edi],dword ptr [esi]
//단순한 경우 멤버별 mov 복사
009A1027  mov         ecx,dword ptr [ebp-18h]  
009A102A  mov         dword ptr [ebp-30h],ecx  
009A102D  mov         edx,dword ptr [ebp-14h]  
009A1030  mov         dword ptr [ebp-2Ch],edx  
009A1033  mov         eax,dword ptr [ebp-10h]  
009A1036  mov         dword ptr [ebp-28h],eax  
009A1039  mov         ecx,dword ptr [ebp-0Ch]  
009A103C  mov         dword ptr [ebp-24h],ecx  
009A103F  mov         edx,dword ptr [ebp-8]  
009A1042  mov         dword ptr [ebp-20h],edx  
009A1045  mov         eax,dword ptr [ebp-4]  
009A1048  mov         dword ptr [ebp-1Ch],eax

 

위의 어셈블리 코드에서 나와있듯이

구조체 복사시 복잡한 경우 rep로 복사하고, 단순할 경우 멤버별 mov 복사를 한다.

 

구조체를 Call-By-Value 방식으로 함수에 인자 전달 시

//함수
void GetObserver(Observer myob)
{
	return;
}
//인자 전달
GetObserver(ob);
//어셈블리
007E1037  sub         esp,18h  
007E103A  mov         ecx,esp  
007E103C  mov         edx,dword ptr [ebp-18h]  
007E103F  mov         dword ptr [ecx],edx  
007E1041  mov         eax,dword ptr [ebp-14h]  
007E1044  mov         dword ptr [ecx+4],eax  
007E1047  mov         edx,dword ptr [ebp-10h]  
007E104A  mov         dword ptr [ecx+8],edx  
007E104D  mov         eax,dword ptr [ebp-0Ch]  
007E1050  mov         dword ptr [ecx+0Ch],eax  
007E1053  mov         edx,dword ptr [ebp-8]  
007E1056  mov         dword ptr [ecx+10h],edx  
007E1059  mov         eax,dword ptr [ebp-4]  
007E105C  mov         dword ptr [ecx+14h],eax  
007E105F  call        007E1000  
007E1064  add         esp,18h

mov로 하나씩 복사한다.

 

구조체를 반환할 경우

//구조체가 간단한 경우
struct Observer {
	int hp;
	int speed;
};
//반환하는 함수에서 레지스터에 넣고 반환한다.
000B1014  mov         eax,dword ptr [ebp-8]  
000B1017  mov         edx,dword ptr [ebp-4]
//레지스터의 값을 메모리에 넣고, 이걸 한번 더 복사한다.
000B102B  mov         dword ptr [ebp-8],eax  
000B102E  mov         dword ptr [ebp-4],edx  
000B1031  mov         eax,dword ptr [ebp-8]  
000B1034  mov         ecx,dword ptr [ebp-4]  
000B1037  mov         dword ptr [ebp-10h],eax  
000B103A  mov         dword ptr [ebp-0Ch],ecx

 

//구조체가 간단하지 않을 경우
struct Observer {
	int hp;
	int speed;
	long long money;
};
//return시 반환할 함수 스택프레임에 넣고
00141023  mov         ecx,dword ptr [ebp+8]  
00141026  mov         edx,dword ptr [ebp-10h]  
00141029  mov         dword ptr [ecx],edx  
0014102B  mov         eax,dword ptr [ebp-0Ch]  
0014102E  mov         dword ptr [ecx+4],eax  
00141031  mov         edx,dword ptr [ebp-8]  
00141034  mov         dword ptr [ecx+8],edx  
00141037  mov         eax,dword ptr [ebp-4]  
0014103A  mov         dword ptr [ecx+0Ch],eax  
0014103D  mov         eax,dword ptr [ebp+8]  
//반환 받으면 이걸 또 복사하고
00141062  mov         ecx,dword ptr [eax]  
00141064  mov         dword ptr [ebp-10h],ecx  
00141067  mov         edx,dword ptr [eax+4]  
0014106A  mov         dword ptr [ebp-0Ch],edx  
0014106D  mov         ecx,dword ptr [eax+8]  
00141070  mov         dword ptr [ebp-8],ecx  
00141073  mov         edx,dword ptr [eax+0Ch]  
00141076  mov         dword ptr [ebp-4],edx  
//한번 더 복사한다.
00141079  mov         eax,dword ptr [ebp-10h]  
0014107C  mov         dword ptr [ebp-20h],eax  
0014107F  mov         ecx,dword ptr [ebp-0Ch]  
00141082  mov         dword ptr [ebp-1Ch],ecx  
00141085  mov         edx,dword ptr [ebp-8]  
00141088  mov         dword ptr [ebp-18h],edx  
0014108B  mov         eax,dword ptr [ebp-4]  
0014108E  mov         dword ptr [ebp-14h],eax

//구조체 멤버 변경시 맨 마지막 복사본을 사용
newob.hp = 0;
00491091  mov         dword ptr [ebp-20h],0

 

신기하게도 반환하는 Callee 함수에서 Caller 함수의 스택프레임으로 넣어준 것을 그대로 쓰지 않고,

이걸 두번이나 더 복사해서 쓴다.

실제로 반환을 받은 구조체 멤버 변경을 해보니,

맨 마지막에 복사한 메모리에 참조 하는 것을 확인할 수 있었다.

 

'CS 지식' 카테고리의 다른 글

캐시 메모리  (0) 2021.11.08
메모리  (0) 2021.11.08
호출 규약  (0) 2021.10.25
endianness (엔디안)  (0) 2021.10.23
코드, 데이터, 힙, 스택 영역  (0) 2021.10.23