스택을 이용한 프로시저 실행 과정

스택

 

스택은 이 그림과 같이 아래부터 차곡차곡 쌓는 후입 선출 구조이다.

그 중 우리는 실행시간 스택에 초점을 맞춰서 알아볼 것이다.

실행시간 스택은 CPU에서 하드웨어가 직접 지원하며 프로시저 호출 및 반환 메커니즘의 필수적인 부분이다.
대부분 실행시간 스택을 단순하게 스택이라고 부른다.

 

 

Runtime Stack

실행시간 스택은 두 개의 레지스터를 사용해서 CPU로 관리된다.

  • SS (stack segment)
  • ESP (stack pointer)

여기서 ESP 레지스터는 스택에 있는 어떤 위치에 대한 32비트 오프셋을 저장한다.

이 레지스터를 직접적으로 조작하는 일은 없고, CALL, RET, PUSH, POP과 같은 명령어를 사용하여 간접적으로 수정된다.

ESP는 항상 스택의 맨 위에 새로 추가된, 즉 PUSH된 마지막 정수를 가리킨다.
(ESP는 오프셋인 0000 1000h를 갖고있다.)

 

 

Push 연산

32비트 푸시 연산은 스택 포인터를 4만큼 감소시키고 스택 포인터가 가리키는 위치에 값을 복사한다.
여기서 ESP 레지스터는 항상 스택의 맨 위를 가리키고 있다는 것을 주의하자.

실행시간은 메모리에서 높은 주소부터 낮은 주소로 아래 방향으로 커진다.

 

차례대로 1과 2를 Push하면 스택은 아래쪽으로 커진다.
ESP 아래 영역은 (스택이 넘치지 않는 한) 사용할 수 있다.

 

 

Pop 연산

stack[ESP]의 값을 레지스터 또는 변수로 복사한다.
pop 연산은 스택에서 값을 제거하고, 스택 포인터는 스택의 다음 높은 주소를 가리키도록 증가된다.

 

정리하자면, PUSH 명령어는 먼저 ESP를 감소시킨 후 소스 피연산자를 스택에 복사한다.
16비트 피연산자는 ESP를 2씩 감소시키고, 32비트 피연산자는 4씩 감소시킨다.
PUSH register/ memory 형태가 아니라 바로 PUSH immediate32 인 즉시값을 푸시해도 된다.

POP 명령어는 ESP가 가리키는 스택 원소의 내용을 목적지 피연산자로 복사하고 난 후 ESP를 증가시킨다.
PUSH와 마찬가지로 ESP를 2 또는 4씩 증가시킨다.

이 연산들은 레지스터에 중요한 값이 포함되어 있을 때 저장 및 복원하는 용도로 사용된다.
PUSH 및 POP 명령은 우리가 스택에 넣었던 반대 순서로 발생한다.

push esi 		; push registers
push ecx
push ebx
mov esi,OFFSET dwordVal 	; display some memory
mov ecx,LENGTHOF dwordVal
mov ebx,TYPE dwordVal
call DumpMem
pop ebx 		; restore registers
pop ecx
pop esi

 

 

추가 예시 코드
문자열 역순으로 배치하기

.data
aName BYTE “Hello World!",0
nameSize = ($ - aName) - 1

.code
main PROC
; Push the name on the stack.
	mov ecx,nameSize
	mov esi,0

L1: movzx eax,aName[esi] ; get character
	push eax ; push on stack
	inc esi
	Loop L1

; Pop the name from the stack, in reverse,
; and store in the aName array.

	mov ecx,nameSize
	mov esi,0
    
L2: pop eax ; get character
	mov aName[esi],al ; store in string
	inc esi
	Loop L2

 

이 코드에서 왜 각 문자가 push되기 전에 EAX에 넣어야 할까?
>> 스택에서 워드(16비트) 또는 더블 워드(32비트) 값만 PUSH할 수 있기 때문이다.

 

그 외 push와 pop 명령어와 관련된 명령어들

  • PUSHFD 및 POPFD
    EFLAGS 레지스터를 PUSH하고 POP한다.
  • PUSHAD는 스택의 32비트 범용 레지스터를 PUSH한다.
    순서 : EAX, ECX, EDX, EBX, ESP, EBP, ESI, EDI
  • POPAD는 스택에서 동일한 레지스터를 역순으로 pop한다.
    PUSHA 및 POPA는 16비트 레지스터에 대해 동일한 작업을 수행한다

 

 

 


 

 

 

프로시저의 정의와 사용

복잡한 문제를 여러 개별적인 작업들로 나누면 효과적으로 이해하고 구현할 수 있다. 이를 서브루틴이라고 하는데, 어셈블리 언어에서 일반적으로 서브루틴의 의미로서 프로시저라는 용어를 사용한다.
(다른 언어에서는 서브루틴을 메소드나 함수라고 부름)

 

프로시저 생성

위에 말했듯이, 프로시저를 통해 큰 문제를 작은 문제로 나눠서 해결할 수 있다.
프로시저는 PROC 와 ENDP 디렉티브를 사용하여 선언된다.

sample PROC
.
.
ret
sample ENDP

프로그램의 시작 프로시저가 아닌 다른 프로시저를 만들 때 프로시저는 RET 명령어로 끝난다.
여기서 RET는 CPU가 프로시저를 호출했던 위치로 되돌아가게 한다.

 

 

프로시저 문서화

우리는 프로그램을 명확하고 읽기 쉽도록 프로그램을 문서화 하는 습관을 들여야 한다.

  • 프로시저가 수행하는 모든 작업에 대한 설명
  • Receives 레이블 사용 : 입력 매개변수 목록이나 사용법 및 요구사항을 명시하기.
  • Returns 레이블 사용 : 프로시저에 의해 반환되는 값에 대한 설명
  • Required 레이블 사용 : 프로시저가 호출되기 전에 만족해야 하는 전제 조건이라는 요구 사항의 선택적 목록

다음은 이런 습관들을 적용시킨 예시이다.

;---------------------------------------------------------
SumOf PROC
;
; Calculates and returns the sum of three 32-bit integers.
; Receives: EAX, EBX, ECX, the three integers. May be
; signed or unsigned.
; Returns: EAX = sum, and the status flags (Carry,
; Overflow, etc.) are changed.
; Requires: nothing
;---------------------------------------------------------
add eax,ebx
add eax,ecx
ret
SumOf ENDP

 

 

CALL과 RET 명령어

  • CALL 명령은 프로시저를 호출한다.
    -스택에서 다음 명령의 오프셋을 push한다.
    -호출된 프로시저의 주소를 EIP로 복사한다.
  • 프로시저에서 RET 명령이 반환된다.
    -반환할 때에 EIP에 있는 스택의 top에서 복귀 주소를 pop한다.

CALL 명령어가 오프셋 0000 0020에 있다고 가정해보자.

main PROC
	00000020 call MySub
	00000025 mov eax,ebx
	.
	.
main ENDP

MySub PROC
	00000040 mov eax,edx
	.
	.
	ret
MySub ENDP

 

여기서 0000025는 CALL 명령 바로 뒤에 있는 명령의 오프셋이고
00000040은 MySub 내부의 첫 번째 명령의 오프셋이다.

CALL 명령어가 실행될 때, call의 다음 주소 0000 0025가 스택에 푸쉬되고  MySub의 주소가 EIP에 적재된다.
MySub은 RET까지 쭉 실행된다.

 

RET 명령어가 실행되면 ESP가 가리키는 스택 값이 EIP로 pop된다.
ESP는 감소되고, 스택의 이전 값을 가리킨다. (공백)

 

 

중첩된 프로시저 호출

이와 같이 호출된 프로시저가 처음 프로시저로 반환되기 전에 다른 프로시저를 호출하는 경우를 말한다.

Sub3이 호출될 때까지 스택에는 다음 세 가지 리턴 주소가 모두 포함된다.

이렇게 스택 구조는 프로그램이 특정한 순서로 수행한 단계를 재추적해야 하는 상황에서 사용된다.

 

 

Local and Global Labels

로컬 레이블은 동일한 프로시저 내의 문에만 표시되고, 글로벌 레이블은 어디에서나 표시될 수 있다.

main PROC
	jmp L2 		; error
L1:: 			; global label
	exit
main ENDP

sub2 PROC
L2: 			; local label
	jmp L1 		; ok
	ret
sub2 ENDP

 

 

레지스터 인수의 프로시저 전달

좋은 프로시저는 여러 프로그램에서 재사용할 수 있다.
그러나 특정 변수의 이름을 참조하는 경우, 코드가 유연하지 않게 되고 유지보수가 어려워진다.

그래서 우리는 변수에 의존하는 대신, 런타임에 변경될 수 있는 매개 변수 값을 사용하여 프로시저를 유연하게 만들 것이다.

ArraySum PROC
	mov esi,0 				; array index
	mov eax,0 				; set the sum to zero
	mov ecx,LENGTHOF myarray 		; set number of elements
    
L1: add eax,myArray[esi] 			; add each integer to sum
	add esi,4 				; point to next integer
	loop L1 				; repeat for array size
    
	mov theSum,eax				; store the sum
	ret
ArraySum ENDP

 

위 코드에서는 esieax 두 레지스터를 변수처럼 사용한다.
여기서 esi는 배열 인덱스를 나타내고, eax는 합계를 나타낸다.
esieax를 특정 변수의 대표로 사용하여 매개변수의 의미를 갖게 한다.

 

 

ArraySum PROC
; Receives: ESI points to an array of doublewords, 
; ECX = number of array elements.
; Returns: EAX = sum
;-----------------------------------------------------
mov eax,0 		; set the sum to zero
L1: add eax,[esi] 	; add each integer to sum
add esi,4 		; point to next integer
loop L1 		; repeat for array size
ret
ArraySum ENDP

이 버전의 ArraySum은 주소가 ESI인 모든 doubleword 배열의 합을 반환한다. 그 합은 EAX로 반환된다.
(여기서 ECX와 ESI는 프로시저의 시작에서 스택에 푸시되고, 끝에서 팝된다.)

 

 

USES 연산자

이 연산자는 우리에게 프로시저 내에서 수정되는 모든 레지스터 이름을 열거하게 한다.

USES는 프로시저의 시작에 스택에 레지스터를 저장하는 PUSH 명령어를 생성하고, 프로시저 끝에 레지스터 값을 복원하는 POP 명령어를 생성하는 것으로 레지스터들을 같은 줄에 빈칸으로 구분하여 나열한다.

ArraySum PROC USES esi ecx
mov eax,0 ; set the sum to zero
etc.

이렇게 USES를 사용하여 코드를 입력하면

ArraySum PROC
	push esi
	push ecx
	.
	.
	pop ecx
	pop esi
	ret
ArraySum ENDP

MASM은 USES 연산자로 인해 프로시저 내에서 수정되는 레지스터를 보호하기 위해 스택에 해당 레지스터 값을 저장하는 명령어들을 자동으로 생성한다. 따라서 프로시저의 시작에는 push esi와 push ecx 명령어가 추가된다. 이는 esi와 ecx 레지스터 값을 스택에 저장하여 나중에 복원할 수 있도록 한다.

 

즉, USES 연산자를 사용하여 esiecx 레지스터를 보호하고, 프로시저의 시작과 끝에서는 자동으로 이러한 레지스터 값을 스택에 저장 및 복원하는 명령어들이 추가된다.

 

 

 


 

 

 

Irvine32 라이브러리 프로시저

링크 라이브러리는 기계어 코드로 어셈블된 프로시저(서브루틴)을 포함하는 파일이다.

이것은 하나 이상의 소스 파일로 시작하며, 소스 파일은 오브젝트 파일로 어셈블된다. 오브젝트 파일은 링커 유틸리티가 인식하는 특별한 형식의 파일에 삽입된다.

 

프로그램이 WriteString 프로시저를 호출한다고 하자. 프로그램 소스는 WriteString 프로시저를 식별하는 PROTO 디렉티브를 포함해야 한다.
다음에 CALL 명령을 사용하여 각 프로시저를 호출한다.

WriteString PROTO
call WriteString

 

프로그램이 어셈블될 때에 어셈블러는 CALL 명령어의 목표 주소를 빈칸으로 두고, 이 곳은 링커에 의해서 채워진다.

링커는 링크 라이브러리에서 WriteString을 찾아서 적절한 기계어 명령어들을 링크 라이브러리로부터 프로그램 실행 파일로 복사한다.
만약 우리가 호출한 프로시저가 링크 라이브러리에 없다면 링커는 오류를 발생시키고 실행파일을 만들지 않는다.

아무튼 프로그램이 라이브러리 파일과 링크하게 된다면 동적 링크 라이브러리 dll파일에서 함수들을 실행한다.