스택으로 값을 넘기는 호출을 고급 언어의 함수 호출처럼 한 줄로 쓰게 해 주는 받아쓰기 도구다. 부르는 쪽은 프로시저 이름과 넘길 값만 적고, 불리는 쪽은 받을 값에 이름을 붙여 둔다. 값을 스택에 넣고, 기준점에서 몇 칸 떨어졌는지 세고, 끝나고 치우는 줄은 어셈블러가 대신 써 넣는다. 손으로 세다 틀리는 일이 줄지만, 기계가 실제로 하는 일이 바뀐 것은 아니어서 넘기는 값의 개수와 크기가 선언과 맞아야 한다.
예시로 보기
두 수를 더하는 프로시저를 스택 매개변수로 쓰면, 부르는 쪽은 push 두 줄과 call을, 불리는 쪽은 push ebp, mov ebp,esp, [ebp+8], [ebp+12], pop ebp, ret 8을 손으로 적어야 한다. 넣는 순서나 거리 하나만 틀려도 다른 값을 읽는다.
같은 프로시저를 지시어로 쓰면 이렇다.
AddTwo PROC,
val1:DWORD, val2:DWORD
mov eax,val1
add eax,val2
ret
AddTwo ENDP
부르는 쪽은 한 줄이다.
INVOKE AddTwo, 5, 6 ; EAX = 11
어셈블러가 이 두 덩어리를 아래처럼 풀어 쓴다1.
; INVOKE AddTwo, 5, 6 ; AddTwo PROC, val1:DWORD, val2:DWORD
push 6 AddTwo PROC
push 5 push ebp
call AddTwo mov ebp,esp
mov eax,[ebp+8] ; val1
add eax,[ebp+12] ; val2
mov esp,ebp
pop ebp
ret 8
AddTwo ENDP
인수는 뒤에서부터 넣는다. 그래서 첫 인수 5가 맨 나중에 들어가 [ebp+8]에 놓이고, 이것이 첫 매개변수 val1이다. ret은 인수 8바이트를 치우는 ret 8로 바뀐다. 프로그램 틀처럼 .model flat,stdcall로 시작하는 프로그램에서는 불린 쪽이 치우기 때문이다.
ret 12로 돌아오는 것 — 46_invoke-proc-proto_verify.py (풀어 쓴 코드를 파이썬 흉내로 돌렸다. 어셈블러가 실제로 만드는 코드는 확인하지 못했다)정의
INVOKE. 32비트 모드에서 CALL을 대신해 인수 여러 개를 한 줄로 넘기는 지시어다.
INVOKE 프로시저이름 [, 인수목록]
인수목록은 쉼표로 나눈다. 인수 자리에 올 수 있는 것은 네 가지다.
| 인수의 종류 | 예 |
|---|---|
| 즉시값, 정수 식 | INVOKE Sub3,eax,(10 * 20)의 둘째 인수 |
| 변수 이름 | INVOKE Sub1,byteVal,wordVal |
주소 식, ADDR 식 |
INVOKE Sub4,[ebx], INVOKE Sub2,ADDR byteVal |
| 레지스터 이름 | INVOKE Sub3,eax,(10 * 20)의 첫째 인수 |
ADDR 변수는 변수의 주소를 넘긴다. 스택 매개변수의 참조로 넘기기를 INVOKE 안에서 쓰는 방법이다. (10 * 20) 같은 식은 번역할 때 200으로 계산된다1.
PROC. 프로시저를 선언하면서 매개변수에 이름과 자료형을 붙인다.
이름 PROC 매개변수목록
매개변수 하나의 꼴: 매개변수이름 : 자료형
자료형은 BYTE, SBYTE, WORD 같은 기본 자료형이거나 그것을 가리키는 포인터(PTR BYTE)다. 매개변수목록을 다음 줄로 넘길 때는 PROC 뒤에 쉼표를 붙이고, 매개변수를 여러 줄에 나눠 쓸 때는 줄마다 끝에 쉼표를 붙인다(마지막 줄 빼고).
이름 PROC,
매개변수1:자료형1,
매개변수2:자료형2,
매개변수n:자료형n
PROTO. 프로시저의 이름과 매개변수목록만 먼저 알려 주는 선언이다. 몸통은 없다. INVOKE가 프로시저 정의보다 앞에 나올 때, 어셈블러가 인수의 개수와 자료형을 맞춰 볼 수 있게 프로그램 앞쪽에 적어 둔다2. 프로그램 틀의 앞머리에 있는 ExitProcess 줄이 바로 이것이다.
ExitProcess PROTO, dwExitCode:DWORD
...
INVOKE ExitProcess,0
ExitProcess는 운영체제가 가진 프로시저라 내 프로그램 안에 몸통이 없다. PROTO 줄이 “이름은 ExitProcess이고 4바이트 인수 하나를 받는다”를 알려 주기 때문에 INVOKE ExitProcess,0을 쓸 수 있다.
예제
FillArray. 바이트 배열의 주소, 채울 값, 배열 크기를 받아 배열을 그 값으로 채운다.
FillArray PROC,
pArray:PTR BYTE, fillVal:BYTE,
arraySize:DWORD
mov ecx,arraySize
mov esi,pArray
mov al,fillVal
L1: mov [esi],al
inc esi
loop L1
ret
FillArray ENDP
INVOKE FillArray, ADDR array, 0AAh, 6으로 부른다고 하면 프레임은 이렇게 된다1.
든 값 EBP에서 본 자리
00000006 arraySize [EBP+16]
000000AA fillVal [EBP+12]
배열의 주소 pArray [EBP+8]
복귀 주소 [EBP+4]
부른 쪽의 EBP [EBP] ← EBP
fillVal은 BYTE지만 스택에서는 4바이트 한 칸을 차지한다. 스택에는 4바이트 단위로 넣기 때문이다. 그래서 매개변수 셋에 12바이트이고 프로시저는 ret 12로 끝난다. 배열 8칸 가운데 앞 6칸만 0AAh가 되고 나머지 2칸은 그대로다.
활용
- 손으로 쓴
push·call·[ebp+8]과 지시어로 쓴 코드는 같은 기계어가 된다. 지시어는 번역할 때 풀려서 사라진다1. 지시어와 명령어 비교 - 지역 변수를 이름으로 쓰게 해 주는
LOCAL도 같은 종류다. 매개변수는PROC의 목록으로, 지역 변수는LOCAL로 이름을 붙이면[ebp±n]을 한 번도 직접 쓰지 않고 프로시저를 짤 수 있다. 지역 변수 - Java의 메서드 선언
int addTwo(int val1, int val2)와 호출addTwo(5, 6)이 각각PROC의 매개변수목록,INVOKE와 같은 자리다1. 필드와 메서드 - 흔한 실수.
INVOKE가 인수를 넣을 때 EAX와 EDX를 임시로 쓸 수 있다. 32비트보다 작은 인수를 넓혀 넣을 때 그렇다. 그 레지스터에 든 값을 다음 인수로 넘기면 덮인 값이 넘어간다2.- 매개변수 이름이 붙은 주소를
OFFSET으로 구하려 한다.LEA나ADDR을 쓴다. OFFSET과 LEA 비교 PROC,뒤나 줄 끝의 쉼표를 빼먹는다.
연결
- 선수: 스택 매개변수, 호출 규약
PROTO와INVOKE가 처음 나온 자리: 프로그램 틀- 레지스터를 맡기는
USES도PROC줄에 함께 적는다: 레지스터 매개변수와 USES
확인 문제
C1 INVOKE, PROC의 매개변수목록, PROTO가 각각 무엇을 대신 써 주거나 알려 주는지 한 줄씩 쓰라.
답: INVOKE: 인수를 스택에 넣는 push들과 call을 대신 쓴다. PROC의 매개변수목록: 매개변수에 이름을 붙여 [ebp+8] 같은 거리를 대신 세고, 프레임을 세우고 걷는 줄과 ret n을 대신 쓴다. PROTO: 몸통 없이 이름과 매개변수목록만 먼저 알려 줘서, 정의보다 앞에서(또는 몸통이 다른 곳에 있어도) INVOKE를 쓸 수 있게 한다.
C2 Sub3 PROC, a:DWORD, b:DWORD, c:DWORD가 있다. INVOKE Sub3, 1, 2, 3을 push와 call로 풀어 쓰고, 프로시저 안에서 a, b, c가 각각 EBP에서 몇 칸 떨어진 자리인지 쓰라. 프로시저의 ret은 무엇으로 바뀌는가?
답: push 3 / push 2 / push 1 / call Sub3. a = [ebp+8], b = [ebp+12], c = [ebp+16]. ret 12.
이유: 뒤 인수부터 넣으므로 첫 인수가 맨 나중에 들어가 EBP에서 가장 가깝다. 4바이트 인수 셋이라 12바이트를 치운다(STDCALL)1.
C3 fillVal:BYTE는 1바이트 매개변수인데 왜 FillArray는 ret 9가 아니라 ret 12로 끝나는가?
답: 32비트 모드의 스택에는 4바이트 단위로 넣는다. 1바이트 값도 4바이트 한 칸을 차지하므로 매개변수 셋은 4 × 3 = 12바이트다. 프로시저는 그 한 칸의 맨 아래 1바이트만 fillVal로 읽는다.
이유: 칸 크기가 섞이면 ESP가 4의 배수에서 벗어나고, [ebp+8], [ebp+12]처럼 4씩 세는 거리가 맞지 않게 된다1.