태초의 프로그래밍 언어 어셈블리

assembly, 8086, x86

함수 호출 규약과 지역변수

이전에는 함수 호출을 위한 call,ret 명령에 대해서만 알아보기위해 개략적인 내용만 설명했습니다. 이제는 함수가 어떻게 호출되는지 제대로 알아보겠습니다. 함수를 호출할 때는 스택을 활용합니다. 스택이 어떻게 사용되는지를 설명하겠습니다.

함수 호출

함수를 배울 때 실습했던 예제가 있었습니다. 레지스터에 함수 인자를 저장하고 함수를 호출해서 함수내에서 곱셈을 실행하고 결과값을 ax 레지스터로 반환하는 예제입니다. 이 예제를 다음처럼 스택을 이용해서 인자를 전달하도록 바꿔보겠습니다. 사실은 이 예제처럼 스택에 인자를 저장하고 함수 내부에서는 스택 메모리에 접근해서 인자를 읽는 방식이 C의 표준적인 함수 호출 규약중 하나입니다. 이 규약을 잘 지키면 어셈블리 코드에서 libc의 라이브러리 함수를 호출하는 것도 가능합니다.

일단 에물레이터로 실행해보시기 바랍니다.

ORG    100h

MOV    dx, 1
push dx
MOV    dx, 2
push dx
CALL   m2
add sp, 4

push ax
mov dx, 2
push dx
call m2
add sp, 4

RET                   ; return to operating system.
 
m2     PROC

mov bp, sp
mov bx, [bp+2]
mov ax, [bp+4]

MUL    Bx             ; AX = AL * BL.
RET                   ; return to caller.
m2     ENDP
 
END

좀 복잡하지만 에물레이터로 한줄한줄 실행해보면 어렵지 않습니다.

가장 먼저 dx에 1을 저장한 후 스택에 저장합니다. 꼭 dx 레지스터를 사용할 필요는 없습니다. 그냥 스택에 1을 저장하기 위해 아무 레지스터나 사용한 것입니다. 그리고 스택에 2를 저장합니다. 이제 함수에 전달할 인자 1과 2가 스택에 저장되었습니다. 인자를 준비했으니 함수 m2를 호출합니다.

에물레이터에서 stack 버튼을 눌러서 스택의 상황을 보고 계신가요? 그럼 call 명령이 호출된 후에 스택에 내가 저장하지 않은 이상한 값이 들어간 것을 볼 수 있습니다. 스택 메모리 0fffch에 1이 저장되고 0fffah에 2가 저장되고 그리고 0fff8h에 10bh 값이 저장되었을 것입니다. 10bh값이 뭔지는 잠시 후에 말씀드리기로 하고 지금은 함수 인자가 스택에 있다는 것만 기억하시기 바랍니다.

이제 함수에서 함수 인자를 읽어야 합니다. 그런데 pop 명령을 사용하면 스택에 있는 10bh 값이 읽혀집니다. 뭔가 역할이 있는 값일테니 pop 명령을 써서 날려버리면 안되겠지요. 그래서 스택을 건드리지않고 메모리에 있는 값만 읽도록 해야합니다. 이럴 때는 주로 bp 레지스터를 씁니다. bp 레지스터는 그냥 변수 주소를 넣을 때도 쓸 수 있지만 사실은 이렇게 스택에 있는 함수의 인자를 읽기 위해서 스택의 포인터의 복사본을 저장하는데 주로 사용됩니다. [bp]로 메모리의 값을 읽으면 10bh 값이 읽어질거니까 그건 건너뜁니다. 그래서 함수의 인자는 [bp+2] 와 [bp+4]가 됩니다.

혹시 굳이 bp를 사용하지 않고 [sp+2], [sp+4]로 읽어도 된다고 생각하지 않으시나요? bp를 사용하는 이유는 지역변수를 이야기할 때 말씀드리겠습니다. 무조건 bp를 사용해서 함수 인자를 읽는다고 생각하셔야 합니다.

함수의 첫번째 인자는 [bp+4]이고 두번째 인자는 [bp+2]입니다. 처음 스택에 넣은 데이터가 좀더 큰 주소에 있습니다. 두개의 인자를 적당히 읽어서 함수가 해야하는 처리를 합니다. 예제에서는 곱셈입니다. 그리고 곱셈의 결과가 ax에 저장됩니다.

이제 함수가 끝납니다. ret 명령이 함수의 끝에 반드시 있어야 된다고 말씀드렸고 ret 명령이 실행되면 call 명령의 다음 명령으로 점프한다고 말씀드렸습니다. 에물레이터에서 ret 명령을 single step으로 실행하고 sp 레지스터의 값을 확인해보겠습니다. 0fff8h였던 sp의 값이 0fffah가 되었습니다. 즉 ret 명령은 스택에서 10bh 값을 꺼내는 일을 합니다. 그리고 ip 값을 보시면 10bh 가 되어있습니다. 10bh는 어디인가요? call m2 다음 명령인 add sp, 4 명령이 있는 위치입니다. 즉 call 명령은 자기 다음의 명령의 주소를 스택에 저장하고 ret 명령은 스택에서 복귀 주소를 꺼내서 실행하는 것입니다. 이렇게 call 명령과 ret 명령이 함수를 호출하고 복귀하는 것입니다. 별로 어렵지 않은 것을 좀 어렵게 설명한 기분이지만 기분탓입니다.

그리고 함수가 끝나고 해야할 일은 스택에 있던 인자들을 지워주는 것입니다. 인자들을 지우지 않으면 함수를 호출 할 때마다 스택이 점점 작아지겠지요. pop 을 두번해서 스택을 되돌리는 방법도 있고 예제처럼 sp 레지스터에 4를 더해서 sp의 값을 예전 값으로 되돌리는 방법도 있습니다. 이왕이면 2개보다는 1개 명령이 실행되는게 좋겠지요.

또다시 m2를 호출하겠습니다. 이번에는 이전의 결과값이 ax에 있으므로 ax를 스택에 넣습니다. 그리고 다시 2를 넣습니다. 그리고 m2를 호출하면 ax에는 2*2=4가 저장되겠네요. 마지막으로 다시 스택을 복구시킵니다.

스택은 이렇게 함수 호출에 이용됩니다. 심심하신 분들은 C로 무한 재귀 함수를 만들어보시기 바랍니다. 스택 복구하는 코드가 실행되지 않고 계속 스택을 사용하기만 하므로 스택 영역을 모두 사용해서 세그먼트 폴트가 발생합니다. 스택을 몰랐다면 함수가 무한히 재귀적으로 실행되도 무한히 실행되고 문제가 없을것 같은데 그게 아니라메모리 문제가 발생한다는 것을 알 수 있습니다.

 

함수의 지역 변수

이제 함수 내부에서 인자에 접근하기 위해 왜 sp가 아닌 bp를 사용하는지 말씀드리겠습니다. 바로 함수의 지역 변수를 스택에 만들기 때문입니다.

다음 예제는 인자로 받은 값을 swap해주는 함수입니다. swap함수는 무조건 지역 변수가 하나 있어야 된다는거 아시지요?

ORG    100h

lea ax, var1 ; push &var1
push ax
lea bx, var2 ; push &var2
push bx
CALL   swap
add sp, 4

ret

var1 dw 11h
var2 dw 22h

 
swap     PROC

mov bp, sp
mov si, [bp+2] ;si = &var2
mov di, [bp+4] ;di = &var1

mov ax, 0 ; temp=[bp-4]
push ax

mov ax, word ptr [si] ; ax = var1
mov bx, word ptr [di] ; bx = var2
mov word ptr [bp-2], ax ; temp = var1
mov word ptr [si], bx ; *&var1 = var2
mov dx, word ptr [bp-2] ; dx = temp
mov word ptr [di], dx ; *&var2=temp

mov sp, bp

RET                   ; return to caller.
swap     ENDP
 
 

두개의 변수 var1, var2를 만듭니다. 각각 11h, 22h 값이 들어있습니다. 이제 swap을 호출해서 22h, 11h로 값을 바꾸겠습니다. 값을 바꾸려면 swap 함수에 변수의 값을 넘겨야할까요 아니면 변수의 주소를 넘겨야 할까요? 당연히 주소를 넘겨야합니다. C 언어를 모르셔서 주소를 넘기는 이유를 모르시는 분들은 그냥 그렇다고만 생각하셔도 좋습니다.

swap 함수가 시작됩니다. bp에 스택 주소를 저장합니다. 함수 인자는 변수의 주소입니다. 따라서 [bp+2]는 var2의 주소이고 [bp+4]는 var1의 주소입니다. 각각 읽어서 si와 di에 저장합니다.

그리고 스택에 0을 집어넣습니다. 값이 0인 것은 중요하지 않습니다. 단지 스택에 공간을 하나 만든 것입니다. 그리고 이게바로 함수의 지역 변수입니다. C언어를 아시는 분은 지역 변수의 정의를 생각해보시면 왜 스택에 지역변수를 저정하는지 아실겁니다. 지역 변수는 함수 내부에서만 사용하다가 함수가 끝나면 사라지는 변수입니다. 따로 메모리를 할당하고 해지할 필요가 없는 특징이 있습니다. 바로 스택 메모리의 특징과 같습니다. 스택에 지역 변수를 저장하므로 그런 특징들이 생겨난 것입니다.

sp 의 값은 지역 변수를 만들 때마다 계속 바뀔 것입니다. 따라서 함수가 호출된 직후에 초기 sp의 값을 저장해놓았다가 함수가 끝났을 때 복구해야합니다. 그래야 ret 명령으로 복귀 주소를 읽을 수가 있습니다. 그래서 초기 sp의 값을 bp에 저장해놓는 것입니다. 그리고 bp는 항상 일정한 값이므로 bp를 기준으로 +를 하면 함수 인자를 읽게되고 -를 하면 지역 변수를 읽을 수 있습니다. [bp]를 그대로 읽으면 복귀 주소가 되겠지요. 결국 [bp-2]가 지역 변수가 됩니다.

si에서 워드값을 읽으면 var1 변수의 값입니다. di에서 읽으면 var2의 값입니다. 그리고 지역 변수 [bp-2]에 var1 값을 저장합니다. 여기까지가 temp = a 동작입니다. C로 한줄이면 되는게 몇줄이 되버립니다.

si에는 var1의 주소가 있으므로 mov word ptr [si], bx 명령으로 var1의 값을 바꿉니다. 그리고 dx에 지역 변수에 저장했던 var1의 값을 읽어와서 [di]에 저장합니다. 그러면 var2의 값이 바뀌는 것입니다.

마지막으로 sp를 bp로 바꾸면 스택이 초기화되고 복귀 주소를 꺼낼 수 있게 됩니다.

좀 복잡하지만 뭔가 생각나는게 많으시리라 믿습니다. C 언어의 포인터가 뭔지 간접 레퍼런스니 하는 개념들이 뭔지 등등 C의 주요 개념들을 다시한번 되새겨보시는 기회가 되었으면 합니다.

 

레지스터 복구

swap 프로그램에서 만약에 swap을 2번 호출한다면 어떻게 해야할까요?

ORG    100h

lea ax, var1 ; push &var1
push ax
lea bx, var2 ; push &var2
push bx
CALL   swap
add sp, 4

lea ax, var1 ; push &var1
push ax
lea bx, var2 ; push &var2
push bx
CALL   swap
add sp, 4

ret

이렇게 똑같은 호출을 2번 하면 될까요? 그래도 되긴 합니다. 하지만 ax, bx 레지스터에 값을 설정하는 부분이 중복됩니다. 따라서 중복 부분을 없애면 더 좋겠지요.

ORG    100h

lea bx, var2 ; push &var2

lea ax, var1 ; push &var1

push ax
push bx
CALL   swap
add sp, 4

push ax
push bx
CALL   swap
add sp, 4

ret

ax, bx를 설정하고 필요할 때마다 함수 호출을 반복하면 더 좋을것 같습니다. 그런데 막상 실행해보면 안됩니다. 왜냐면 첫번째 swap이 ax, bx 의 값이 바꾸기 때문입니다. 함수들은 항상 레지스터를 쓰기 마련인데 그렇게 되면 함수 호출 이전에 있는 레지스터의 값들이 날아가 버립니다. 따라서 라이브러리 함수일 경우 호출된 다음에 레지스터가 어떻게 바뀌었는지 모르게 될 수도 있고, 내가 만든 함수라도 일일이 함수 호출 전에 레지스터의 값들을 스택에 넣어줘야하는 불편도 있습니다. 

그래서 이런 불편을 없애기 위해 모든 함수는 자기가 사용할 레지스터들을 스택에 보존했다가 함수 종료 직전에 복구하는 규칙이 있습니다.

swap을 다시 만들어보겠습니다. swap에서는 bp, si, di, ax, bx, dx를 사용합니다. 따라서 함수 초기에 이런 레지스터들을 스택에 보존하는 일을 해야합니다.

 
swap     PROC

mov bp, sp

mov ax, 0 ; temp=[bp-4]
push ax

push bp

push si

push di

push ax

push bx

push dx

mov si, [bp+2] ;si = &var2

mov di, [bp+4] ;di = &var1

mov ax, word ptr [si] ; ax = var1

mov bx, word ptr [di] ; bx = var2
mov word ptr [bp-2], ax ; temp = var1
mov word ptr [si], bx ; *&var1 = var2
mov dx, word ptr [bp-2] ; dx = temp
mov word ptr [di], dx ; *&var2=temp

pop dx

pop bx

pop ax

pop di

pop si

pop bp

pop ax

mov sp, bp

RET                   ; return to caller.
swap     ENDP

이렇게 각 함수들이 자기들이 사용할 레지스터의 값들을 복구시켜주면 함수를 호출하는 입장에서는 함수가 무슨 레지스터를 사용할지 신경쓸 필요가 없으므로 편리합니다.

이렇게 함수 호출할때 인자 전달 방법, 지역 변수 생성과 레지스터의 복구까지가 함수 호출 규약에 정해져있는 것들입니다.

댓글

댓글 본문
작성자
비밀번호
버전 관리
gurugio
현재 버전
선택 버전
graphittie 자세히 보기