Notice
Recent Posts
05-05 22:18
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
Today
Total
관리 메뉴

프로그래밍 잡화점

0 - 4 기본적인 Assembly 개념 - 문법과 instruction Part 3 본문

Assembly

0 - 4 기본적인 Assembly 개념 - 문법과 instruction Part 3

Luana7 2023. 8. 24. 22:02

이번에 알아볼 것은 Call 이다. 간단하게 생각해서 C에서의 함수 호출이라고 볼 수 있다.

 

 

어셈블리에서 특정 위치로 분기하는 방법엔 여러가지가 있다.

Jmp instruction을 사용할 수도 있고 IP(Instruction Pointer) register를 변경할 수도 있다.

 

그리고 이번에 알아볼 Call도 분기라고 볼 수도 있을 것 같다.

 

 

C 함수를 먼저 생각해보자.

C에서 함수를 호출하면 무슨일이 벌어질까?

 

일단 기본적으로 함수는 이름(주소)과 매개변수, 그리고 종료(return)을 기본 틀로 잡는다.

함수가 이름으로 호출될때 매개변수를 받을 수 있으며, 함수의 내용이 끝난 뒤에 return을 통해 다시 호출자에게 반환된다.

 

또한, C의 함수 내부엔 "지역변수"라는 것도 존재한다.

 

 

자, 이제 어셈블리로 돌아와서 생각해보자.

 

"Call은 무슨 역할을 하는가?"

 

Call은 기본적으로 특정 위치로 분기하는 명령어이다.

그러나 JMP와 다르게 이 녀석은 분기할때 특정 작업을 한다.

 

스택에 IP를 푸시하는 작업을 한다.

이게 무슨 말이냐, 특정 위치로 분기하면서 돌아올 위치를 저장해둔다는 의미이다.

 

추후 함수 끝에서 ret을 만나면 스택에 있단 IP를 꺼내어서 해당 위치로 돌아가게 되는 것이다.

 

 

매개변수에 대해서 생각해보자.

사실 매개변수를 처리하는 방식은 여러가지로 나뉘며 32bits냐 64bits에 따라서 또 다르기도 하다.

 

32bits의 C Standard Library(ex : printf)의 경우에는 스택을 기본적으로 사용한다.

호출 인자에 들어갈 값들을 "역순"으로 스택에 저장한다.

 

그럼 함수 내에서는 매개변수에 어떻게 접근하냐?

그 질문에 대한 답은 지역변수를 설명을 듣고 난 뒤 답을 얻을 수 있을 것이다.

 

 

세번째로 함수 내의 지역변수를 생각해보자.

매개변수의 경우에는 스택에 대해서 알아야 한다.

int main() {
    int a = 0;
    
    return 0;
}

이 코드가 어셈블리로 컴파일 되면 다음과 같은 결과가 나온다.

main:
        push    ebp
        mov     ebp, esp
        sub     esp, 16
        mov     DWORD PTR [ebp-4], 0
        mov     eax, 0
        leave
        ret

( [ebp-4]는 C에서 역참조 연산자인 *라고 보면 된다. 즉 ebp-4주소에 있는 값을 가져온다는 의미이다.)

가만 보면 우리가 넣은 적이 없는 코드가 삽입되어 있다.

2~4, 7번째 줄은 우리가 넣은적이 없었다.

 

이 코드가 바로 함수의 지역변수의 공간을 만드는 역할을 하는 코드이다.

 

BP를 push함으로서 호출자의 함수 스택 위치를 저장하고 BP에 call 직후의 SP를 저장해둔다.

간단하게 설명하자만 그것이다.

BP를 저장하고, SP를 새로운 BP로 만든다. 즉, 새로운 스택을 구축한다고 볼 수 있다.

 

그 뒤에 SP를 빼줌으로서 스택에 공간을 할당해두는데, 이것이 바로 지역변수인 것이다.

 

7번째 줄을 보면 leave instruction을 볼 수 있는데, 이는 사실 축약된 것으로 실제론 다음과 같은 동작을 한다.

mov esp, ebp
pop ebp

스택을 만들때와는 반대로 수행하는 것을 볼 수 있는데, 함수의 스택을 정리하고 호출자의 스택으로 되돌리는 작업인 것이다.

 

왜 leave만 있냐고 할 수 있는데, 실제론 enter라는 instruction도 존재한다. 다만 enter의 경우에는 성능 저하의 이슈가 존재하여 사용하지 않는다고 한다. (더 궁금한 독자는 이곳을 참조해보자)

 

 

자 이제 매개변수에 대해서 유추해볼 수 있을 것이다.

새로운 스택을 만드는 과정에서 볼 수 있듯이, 스택에 매개변수를 넣고 call을 호출하면

Parameter3
Parameter2
Parameter1
return IP
Empty.

이런식으로 쌓여 있을 것이다. 그 후 새로운 스택을 만들게 된다면 Empty 부분부터 채워나가게 될 것이다.

BP가 Empty의 시작점을 가리키고 있기 때문에, BP+N으로 접근할 수 있다.

아키텍쳐에 따라 다르나 일단 32bits를 기준으로 보자.

 

먼저 [EBP+4]에는 돌아가야 할 주소인 EIP가 저장되어 있다.

그리고 [EBP+8]부터 매개변수가 들어가 있는 것을 볼 수 있다. 그 뒤로 4씩 올려가면서 다음 매개변수를 얻어올 수 있는 것이다.

 

 

마지막으로 함수에는 반환이란 것이 존재한다. 어셈블리에서 함수의 반환 값은 보통 AX 레지스터를 택한다.

 

 

자 그럼 이제 Add 함수를 구현해보자.

; int add(int a, int b) { return a + b; }
add:
    ; Make function stack
    push ebp
    mov ebp, esp
    
    mov eax, [ebp+8]  ; Get parameter 1
    mov ebx, [ebp+12] ; Get parameter 2
    
    add eax, ebx 	  ; Add two number
    ; Clear function stack
    leave
    ; Return
    ret

; int main() { add(1, 2); return 0; }
main:
    ; add(1, 2)
    push dword 2 ; Parameter 2
    push dword 1 ; parameter 1
    call add
    ; Clear push stack
    add esp, 8
    
    xor eax, eax ; Set eax to 0 for return
    ret

여기서 주의 깊게 봐야할 것이 2가지 있다.

 

첫번째로, 모든 함수가 지역변수를 갖는 것이 아니므로 굳이 스택을 만들어줄 필요가 없다.

그 예시중 하나가 main이다.

 

main도 함수이며 우리가 모르는 어딘가에서 call을 통해 호출을 받는다.

그러나 스택을 형성하지 않는데, 지역변수가 필요하지 않는 경우 스택을 생성할 필요가 없다.

 

두번째로, 함수를 호출한 뒤에 호출자에서 처리이다.

call add를 한 이후에 esp에 8을 더해주고 있는 것이 보인다.

 

함수를 호출하기 위해서 push를 통해 스택을 할당하고 있기 때문에, 이를 정리해주어야 한다.

push dword를 통해서 4바이트를 할당했고 2번 했기 때문에 총 8바이트를 정리해줘야 한다.

 

이 스택을 정리하는 것이 매우 중요하다.

스택을 정리하지 않으면 몇가지 문제가 발생하는데, 함수를 호출할 때마다 매개변수가 계속 스택에 쌓여 공간을 낭비할 수 있다.

또한, 스택을 형성하지 않은 함수에서 이를 정리하지 않으면, Segmentation Fault는 물론, 무한루프에 빠질 수도 있다.

 

따라서 스택을 활용해 call을 하였다면 push한 만큼 스택을 정리해주는 것이 중요하다.

 

 

저번 for문을 참고하여 1부터 N까지 더하는 함수를 만들어보자. C코드는 다음과 같다.

int sum(int n) {
    int r = 0;
    
    for (int i = 1; i <= n; i++) {
    	r += i;
    }
    
    return r;
}
더보기
더보기

정답은 다음과 같다

sum:
    ; Make function stack
    push    ebp
    mov     ebp, esp
    ; Reserve Variable (int r, i)
    sub     esp, 8
    
    mov dword [ebp-4], 0 ; Set variable r to 0
    
    ; For statement
    
    ; Init
    mov dword [ebp-8], 1 ; Set variable i to 1

for1:
    ; Loop check
    mov ecx, [ebp+8] ; Get parameter n
    cmp [ebp-8], ecx ; If (i > n)
    jg for1_end		 ; goto for1_end
    
    ; Body
    mov eax, [ebp-4] ; Get variable r
    add eax, [ebp-8] ; Add r + i
    mov [ebp-4], eax ; Result to r
    
    ; Increment
    ; 레지스터가 아닌 경우 dword를 넣어 크기를 명시해주어야 함.
    inc dword [ebp-8] ; i++
    
    ; Loop for
    jmp for1
    
for1_end:
    mov eax, [ebp-4] ; Set return value
    leave
    ret

 

추가적으로 더 해보고 싶은 독자는 피보나치 코드를 검색해 어셈블리로 구현해보자.

 

 

다음 강의부턴 "Hello, World"를 시작으로 본격적인 어셈블리 이야기를 해볼려고 한다.

Comments