이 아티클은 귀도 자이벨스(Guido Gybels)의 Using Assembler in Delphi를 번역한 것으로, 총 4개의 장으로 되어 있는 시리즈 아티클들 중 두번째입니다. 번역 및 전재를 하도록 허락해주신 귀도씨에게 감사드립니다.
원문 : http://www.guidogybels.eu/asmch2.html
2장. 파라미터의 전달
보통 프로그래머가 작성하는 대부분의 루틴들은 입력으로서 하나 혹은 그 이상의 파라미터를 전달받습니다. 이 장에서는 그런 파라미터들이 어떻게 여러분의 어셈블리 루틴으로 전달되는지에 대해 개략적으로 살펴봅니다. 많은 루틴들은 호출자(caller)에게 결과값을 돌려주기도 하지요. 결과값을 돌려주는 과정에 대해서는 4장에서 살펴볼 것입니다.
2.1. 콜링 컨벤션
코드의 동작 과정에서 가장 일반적인 작업들 중 하나는 지정된 동작을 하는 서브루틴을 호출하는 것입니다. 그러기 위해서 메인 코드는 제어권을 서브루틴으로 넘겨주어 서브루틴이 실행될 수 있도록 하고, 그런 후 이전의 메인 실행 경로로 돌아가야 합니다. 이 과정을 위해서는 서브루틴에 어떻게 정보를 넘겨줄 것인가, 필요한 경우 결과값을 어떻게 돌려줄 것인가, 그리고 누가 메모리 할당과 청소(cleanup)를 할 것인가에 대한 호출자(caller)와 피호출자(callee) 사이의 합의가 필요합니다. 서브루틴 호출을 다루기 위한 다양한 규약(convention)들이 존재하는데 이것을 콜링 컨벤션(calling convention)이라고 부릅니다. 델파이는 register, pascal, cdecl, stdcall, safecall 등 다양한 콜링 컨벤션들을 지원합니다. 당연히 호출자와 피호출자가 제대로 동작하기 위해서는 동일한 콜링 컨벤션을 사용해야 합니다.
이미 언급했던 것처럼 콜링 컨벤션은 서브루틴 호출의 여러 측면들을 정의합니다:
– 파라미터가 어디에 존재하는가: 레지스터 혹은 스택
– 파라미터가 어떤 순서로 전달되는가: 오른쪽에서 왼쪽으로, 혹은 왼쪽에서 오른쪽으로
– 파라미터를 해제하는 책임이 어느쪽에 있는가: 호출자 혹은 피호출자
표 2에서 델파이에서 지원하는 콜링 컨벤션들 각각에 대해 개략적으로 설명합니다.
2.2. 레지스터로 파라미터 전달하기
델파이에서 파라미터를 전달하기 위해 CPU 레지스터를 사용하는 콜링 컨벤션은 register 하나뿐입니다. 파라미터 전달 목적으로 사용할 수 있는 레지스터는 eax, edx, ecx의 3개이며, 이 순서대로 사용됩니다. (ecx가 가장 마지막에 사용되기 때문에 이 레지스터가 가장 오래 사용가능하며, 이 레지스터를 루프 카운터 변수로 사용하는 경우가 종종 있기 때문에 편리합니다) 사용 가능한 레지스터들보다 더 많은 파라미터를 전달해야 하는 경우, 나머지는 이후에 설명할 스택을 통해 전달되게 됩니다. 하지만 객체 메소드에 대해 register 콜링 컨벤션을 사용하는 경우에는 eax는 Self에 대한 포인터를 가지게 되므로, 파라미터 전달 목적으로는 2개의 레지스터만이 사용될 수 있습니다. 모든 데이터 타입들이 레지스터로 전달될 수 있는 것은 아닙니다. 표 3은 어떤 타입들이 레지스터로 전달될 수 있는지 보여줍니다. 파라미터를 참조로 전달할 경우 실제로는 변수에 대한 포인터를 전달하게 된다는 점을 기억해두십시오. 포인터 타입은 레지스터로 전달 가능하므로 레퍼런스로 전달되는 변수는 언제나 레지스터로 전달 가능합니다. 메소드 포인터인 경우는 예외입니다.
전달되는 파라미터의 수가 사용 가능한 레지스터의 수(독립 루틴의 경우 3개, 메소드의 경우 2개)와 같거나 적으면 파라미터 전달을 위해 스택 프레임을 설정할 필요가 없어집니다. 이런 경우 루틴 호출시의 오버헤드를 줄일 수 있습니다. 하지만 파라미터 전달이 스택 프레임 설정의 유일한 원인은 아니라는 점을 유의하십시오. 지역 변수를 선언하는 경우에도 스택 프레임이 필요하게 되며 따라서 스택 프레임을 관리하는 추가 오버헤드는 여전히 발생하게 됩니다.
덧붙여, 많은 구조 타입들의 경우 데이터 자체는 스택이나 힙에 존재하면서 해당 변수는 실제 데이터에 대한 포인터인 경우가 많습니다. 이런 포인터는 32비트 크기이므로 레지스터 크기에 들어맞습니다. 다시 말해, 대부분의 파라미터 타입들은 레지스터를 통해 전달 가능하다는 것입니다. 하지만 메소드 포인터(두 개의 32비트 포인터로 되어 있으며 하나는 객체 인스턴스를 가리키며 다른 하나는 메소드 엔트리 포인트를 가리킵니다)는 항상 스택으로 전달됩니다.
이 글에서는 32비트 모드를 전제로 하므로 레지스터들은 32비트 크기입니다. 레지스터 하나를 다 채우지 않는 크기의 정보를 전달할 때는(예를 들어 바이트 크기이거나 워드 크기의 값일 경우), 바이트는 하위 8비트(예를 들면 al)에 들어가고 워드는 하위 워드 위치로 들어갑니다(예를 들면 ax). 포인터는 항상 32비트 값이므로 레지스터 하나 전체를 채웁니다(예를 들면 eax). 바이트 혹은 워드 크기의 변수의 경우에는 레지스터의 나머지 공간의 값은 알 수 없으며(unknown) 그 상태에 대해 어떤 추정도 해서는 안됩니다. 예를 들어 al을 통해 함수에 한 바이트를 전달할 때, eax의 나머지 24비트는 알 수 없으며(unknown), 따라서 0으로 초기화되었을 거라는 가정을 해서는 안됩니다. and 연산을 이용하면 레지스터의 나머지 24비트를 강제로 초기화시킬 수 있습니다.
1 |
and eax,$FF {AL에 부호 없는 바이트 값을 남기고 상위 24비트는 지움} |
혹은,
1 |
and eax,$FFFF {AX에 부호 없는 바이트 값을 남기고 상위 16비트는 지움} |
부호 있는 값들(shortint 및 smallint)을 전달할 때는 계산을 쉽게 하기 위해 32비트 값으로 확장시키고 싶을 수 있습니다. 하지만 그러려면 부호를 남겨둘 필요가 있게 되지요. 부호 있는 바이트 값을 부호 있는 더블 워드로 확장시키려면 두 개의 명령을 사용해야 합니다:
1 2 |
cbw {al을 ax로 확장시킴} cwde {ax를 eax로 확장시킴} |
나머지 비트들에 주의해야 한다는 점은 간단히 증명 가능합니다. 아래와 같이 Test 루틴을 코딩합니다:
1 2 3 4 |
function Test(Value: ShortInt): LongInt; register; asm end; |
다음으로, 폼에 버튼 하나와 레이블 하나를 추가하고 버튼의 OnClick 이벤트 핸들러에 다음과 같은 코드를 써넣습니다:
1 2 3 4 5 6 |
var I: ShortInt; begin I:=-7; Label1.Caption := IntToStr(Test(I)); end; |
프로젝트를 실행하고 버튼을 클릭해봅니다. Test 루틴은 al을 통해 ShortInt 값을 전달받습니다. 그리고 eax 레지스터를 통해 integer 값을 리턴하는데(결과 값의 리턴에 대해서는 4장에서 살펴봅니다), 이 루틴은 즉시 리턴하므로 eax의 값은 변함이 없습니다. 리턴될 때 eax에 예상치 못한 값이 들어있는 것을 알게 될 것입니다. 이제 Test 함수를 아래와 같이 수정하고 프로젝트를 다시 실행해봅니다:
1 2 3 4 5 |
function Test(Value: ShortInt): LongInt; register; asm cbw cwde end; |
이제 Test 루틴은 정확한 값을 리턴하게 됩니다.
요약하자면, register 콜링 컨벤션을 사용할 때는 eax, edx, ecx 레지스터를 통해 최대 3개의 파라미터를 전달할 수 있습니다. 따라서, 다음과 같은 루틴 선언은,
1 2 3 4 |
procedure DoSomething(First: Integer; Second: ShortInt; Third: Pointer); register; asm ... end; |
First를 eax로, Second를 dl로, Third를 ecx로 전달하게 됩니다. 다음으로, 메소드 선언의 예를 살펴봅시다.
1 2 3 4 |
procedure TSomeClass.DoSomething(First, Second: Integer); register; asm ... end; |
이 경우에는, eax는 Self를 전달하고, edx는 First를, Second는 ecx를 전달하게 됩니다. register 콜링 컨벤션은 파라미터들을 레지스터를 통해 전달하도록 하므로, 레지스터의 내용을 덮어쓰는 순간 파라미터 정보를 잃게 된다는 점을 유의해야 합니다. 아래의 코드를 봅시다.
1 2 3 4 5 6 7 8 |
procedure DoSomething(AValue: Integer); register; asm {eax에 AValue 값이 넘어옴} ... mov eax, [edx+ecx*4] {여기서 eax를 덮어쓰게 됨} ... end; |
eax가 덮어써진 후에는, 더 이상 AValue 파라미터의 값을 액세스할 방법이 없어집니다. 이 파라미터의 값을 보관해둘 필요가 있다면 eax의 내용을 스택이나 로컬 저장소에 저장해두어야 합니다. 또한 다음과 같은 흔한 실수를 하지 않도록 주의합시다:
1 |
mov eax, AValue |
컴파일러는 위 라인에 대해 단순히 아래와 같은 코드를 생성하게 됩니다.
1 |
mov eax, eax |
이것은 지정된 콜링 컨벤션에 따라 컴파일러는 단지 AValue 값이 eax로 전달되었다는 것만을 알고 있기 때문입니다.
2.3. 스택으로 파라미터 전달하기
모든 종류의 콜링 컨벤션에서 파라미터 전달을 위해 스택을 사용하게 될 수 있습니다. register 콜링 컨벤션은 먼저 CPU 레지스터를 사용하려고 시도하지만, 모든 변수 타입이 레지스터로 전달하기에 적합하지는 않고, 또한 어떤 경우에는 사용 가능한 레지스터의 수보다 더 많은 파라미터를 전달해야 할 경우도 있기 때문입니다. register를 제외한 모든 다른 콜링 컨벤션들에서는 피호출자에게 모든 파라미터들을 스택을 통해 전달합니다.
이전 장에서 설명했던 대로, 컴파일러는 스택 프레임을 관리하기 위해 엔트리 및 엑시트 코드를 생성합니다. 따라서, ebp는 스택 프레임에 대한 베이스 포인터로 초기화되어 파라미터들과 스택의 다른 정보들을 쉽게 액세스할 수 있게 해줍니다(3장에서 살펴보겠지만 여기에는 로컬 변수들도 포함됩니다). 스택에 있는 파라미터들을 참조하면 컴파일러는 ebp에서 적절한 오프셋을 생성합니다. 다음과 같은 선언을 봅시다:
1 |
function Test(First, Second, Third: Integer): Integer; pascal; |
여기서 콜링 컨벤션은 pascal인데, 이것은 서브루틴을 호출하기 전에 호출자가 세 파라미터를 선언된 순서대로 스택에 푸시한다는 것을 의미합니다(스택은 아래 방향으로 쌓이므로 첫번째 파라미터는 최상위 주소에 있게 됩니다):
다음으로, call 명령은 리턴 주소를 스택에 푸시하고 실행을 서브루틴으로 넘깁니다. 따라서 엔트리 직후의 스택은 다음과 같은 상태입니다:
컴파일러가 생성한 엔트리 코드(1장 참고)는 ebp의 현재 값을 저장해두며, 다음으로 esp의 값을 ebp에 복사하므로 ebp를 스택 프레임의 파라미터 데이터를 액세스하는 목적으로 사용할 수 있습니다:
이 시점부터, 우리는 스택 프레임에 있는 파라미터들을 ebp로부터의 오프셋으로 액세스할 수 있게 됩니다. 현재의 스택 최상위와 실제 파라미터들 사이에 리턴 주소가 있으므로 다음과 같은 방법으로 파라미터들을 액세스할 수 있습니다:
1 2 3 |
First = ebp + $10 (ebp + 16) Second = ebp + $0C (ebp + 12) Third = ebp + $08 (ebp + 8) |
하지만 이 파라미터들을 간단히 이름으로 부를 수도 있습니다. 컴파일러가 각 파라미터를 ebp로부터의 정확한 오프셋으로 치환해줍니다. 따라서 위의 예제 코드에서 아래와 같은 코드를 사용하면:
1 |
mov eax, First |
컴파일러는 다음과 같이 번역하게 됩니다:
1 |
mov eax,[ebp+0x10] |
이로 인해 여러분은 직접 오프셋을 계산해야 하는 골치아픈 일로부터 벗어날 수 있으며 또한 코드를 알아보기도 훨씬 좋아지므로, 오프셋을 하드 코딩하는 대신 최대한(실제로는 항상) 스택으로 전달되는 파라미터들의 이름을 사용하시기 바랍니다. 하지만 주의할 점도 있습니다. register 콜링 컨벤션을 사용하는 경우, 파라미터들 일부는 레지스터를 통해 전달되게 됩니다. 레지스터로 전달되는 파라미터들에 대해서는 변수를 가리키기 위해 해당 레지스터를 직접 사용하는 것이 좋습니다. 이것은 코드에서의 모호함을 막기 위한 것입니다. 다음 예제 코드를 봅시다:
1 |
procedure DoSomething(AValue: Integer); register; |
이 선언은 register 콜링 컨벤션을 사용하기 때문에 AValue 파라미터는 eax 레지스터로 전달되게 됩니다. 이런 경우 이 파라미터를 가리키기 위해 코드에서 명시적으로 eax를 코딩하는 것이 더 나은 경우가 많습니다. 이것은 다음과 같은 잠재적인 버그를 피할 수 있게 해줍니다:
1 |
mov eax, AValue |
이 코드는 선언에 의해 다음과 같은 코드로 번역됩니다:
1 |
mov eax, eax |
요약하자면, 레지스터로 전달되는 파라미터들을 가리킬 때는 레지스터 이름을 사용해야 합니다. 스택으로 전달되는 파라미터들을 가리킬 때는 변수 이름을 사용하십시오. (그리고 ebp 레지스터가 파라미터 정보 액세스를 위해 사용될 수 있도록 ebp는 사용하지 마십시오)
스택 공간은 언제나 32비트 덩어리로 할당되며, 따라서 스택으로 전달되는 데이터는 항상 dword 크기 단위를 점하게 됩니다. 여러분이 프로시저에 바이트를 전달하더라도 스택에는 4바이트가 할당되고 상위 3바이트는 정의되지 않은 값을 가집니다. 이 정의되지 않은 부분이 0으로 초기화되었다거나 어떤 특정 값을 가지고 있을 거라고 가정해서는 안됩니다.
2.4. 값에 의한 전달 vs. 참조에 의한 전달
앞에서, 저는 값에 의한 전달(pass by value)과 참조에 의한 전달(pass by reference)의 차이점에 대해 언급했었습니다. 참조로 전달할 경우(var 지시어를 이용), 혹은 const를 사용하는 경우의 일부 경우, 해당 루틴으로 실제 데이터가 복사되어 전달되는 대신 데이터에 대한 포인터가 전달됩니다. 이 차이점은 당연히 상당히 중요합니다. 아래와 같은 함수 선언을 예로 살펴봅시다:
1 |
function MyFunction(I: Integer): Integer; register; |
register 콜링 컨벤션이 사용되었으므로 I 파라미터의 값이 eax 레지스터로 전달됩니다(표 3 참고). 따라서, I=254인 경우, 엔트리 시점에서 eax에는 $000000FE라는 값이 들어있을 것입니다. 하지만 위 선언을 다음과 같이 수정하면:
1 |
function MyFunction(var I: Integer): Integer; register; |
eax에는 I의 값 대신 I가 저장된 메모리 위치에 대한 포인터가 들어있게 되며, 예를 들면 $0066F8BC 이런 식입니다. var나 const를 이용하여 파라미터를 참조로 전달하면 32비트 포인터가 사용됩니다.
변수를 읽기 전용으로 지정하는 const를 사용하는 경우, 컴파일러는 두 방식을 모두 사용합니다. 델파이의 온라인 헬프에서 쓴 표현을 보면 오해를 일으킬 수 있고 const가 항상 실제 데이터에 대한 32비트 포인터를 전달한다고 가정하는 경우도 있는데, 그렇지 않습니다. 표 3을 참고하십시오.
const를 사용하는 것은 프로그래머가 컴파일러에게 해당 데이터는 읽기만 할 것이라고 알려주는 것입니다. 하지만 asm..end 안에 있을 때 const 파라미터가 AnsiString이나 레코드같은 구조 데이터에 대한 포인터일 경우에는 읽기 전용 특성을 위반하는 코드를 작성하더라도 컴파일러가 막지 않습니다. const로 전달된 정보의 읽기 전용 특성을 지키도록 주의하지 않으면 골치아픈 버그가 발생할 수 있습니다. 또한 당연히 읽기 전용으로 지정해놓고 이후에 변경하는 것은 아주 나쁜 설계입니다. 이것은 AnsiString처럼 참조 카운트(reference count)되어 copy-on-write 방식으로 동작하는 타입을 사용할 때는 특별히 더 중요합니다.
여기까지 설명한 것처럼, 값으로 전달하는 방식과 참조로 전달하는 방식의 차이점을 주의해서 고려해야 합니다. 예를 들어, integer와 12의 합을 계산하는 함수를 생각해봅시다. integer 파라미터를 값으로 전달하는 경우, 코드는 다음과 같이 될 것입니다:
1 2 3 4 |
function MyFunction(I: Integer): Integer; register; asm add eax, 12 end; |
결과의 리턴에 대해서는 4장에서 살펴볼 것입니다. 이 시점에선 이 경우 결과는 eax 레지스터를 통해 호출자에게 리턴된다는 점만 알아두면 되겠습니다. eax 레지스터로부터 직접 I의 값을 읽고 있습니다. 하지만 이 함수를 참조에 의한 전달 방식으로 바꾸면 아래와 같이 될 것입니다:
1 2 3 4 5 |
function MyFunction(var I: Integer): Integer; register; asm mov eax, [eax] {I의 값을 포인터를 통해 읽어들임} add eax, 12 end; |
eax가 I의 값이 아닌 I가 저장된 메모리 위치에 대한 포인터를 가지고 있으므로, 받은 포인터를 통해 값을 얻게 됩니다.
표 2: 콜링 컨벤션
이 표는 델파이 컴파일러에서 지원되는 콜링 컨벤션들을 요약합니다. 파라미터 순서에서 “left-to-right”는 파라미터들이 선언된 순서대로 레지스터 혹은 스택으로 푸시된다는 것을 의미하며, 반대로 “right-to-left”는 파라미터들이 역순으로 푸시된다는 것을 의미합니다.
파라미터 순서 | 청소(cleanup) | 통상적인 사용 목적 | |
register | left-to-right | 피호출자 | 델파이 애플리케이션들 |
pascal | left-to-right | 피호출자 | 하위 호환성 목적 |
cdecl | right-to-left | 호출자 | C 및 C++ 라이브러리 |
stdcall | right-to-left | 피호출자 | Windows API 호출 |
safecall | right-to-left | 피호출자 | COM 및 듀얼인터페이스 루틴들 |
표 3: 파라미터 전달
이 표는 파라미터가 전달되는 방법에 대해 정리합니다. 구분 기준은 값에 의한 전달하는 경우, const로 선언된 경우, 참조에 의해 전달하는 경우입니다. “레지스터에 값 가능?” 컬럼은 해당 타입이 값에 의해 전달되는 경우 레지스터로 전달 가능한가의 여부입니다(applicable only to the “By Value” and “Const” columns!). 참조로 전달하는 경우 32비트 포인터를 이용하므로 항상 레지스터로 전달 가능합니다. 이것은 모든 타입에 해당하며 항상 스택으로만 전달되는 메소드 포인터의 경우만 예외입니다. 예를 들어, 아래의 표에 따르면 int64 타입은 값으로 전달하는 경우 레지스터로 전달이 가능하지 않습니다. 하지만 참조로 전달하는 경우 실제로는 int64 값에 대한 32비트 포인터를 전달하는 것이므로 int64를 참조로 전달하는 경우 레지스터로 전달 가능합니다.
(1) 32비트보다 작은 데이터 타입도 32비트를 차지합니다. 실제 값은 스택 위치 혹은 레지스터의 하위 부분에 저장되며 나머지 부분의 내용은 정의되지 않습니다.
(2) 포인터는 값의 하위 dword를 가리킵니다. 상위 dword는 다음 위치에 저장됩니다.
(3) 메소드 포인터는 항상 스택으로 전달됩니다. 메소드 포인터는 실제 메소드 포인터보다 먼저 푸시되는 인스턴스 포인터와 실제 메소드 포인터로 구성됩니다. 따라서 메소드 포인터는 스택에서가장 낮은 주소에 위치하게 됩니다.
(4) 셋(Set)의 내용이 하나의 byte/word/dword에 맞는 경우, 8/16/32비트 값으로서 즉시 전달됩니다. 다른 경우에는 셋에 대한 32비트 포인터가 전달됩니다.
(5) 레코드의 내용이 하나의 byte/word/dword에 맞는 경우, 8/16/32비트 값으로서 즉시 전달됩니다. 다른 경우에는 레코드에 대한 32비트 포인터가 전달됩니다.
(6) 배열의 내용이 하나의 byte/word/dword에 맞는 경우, 8/16/32비트 값으로서 즉시 전달됩니다. 다른 경우에는 배열에 대한 32비트 포인터가 전달됩니다.
(7) 오픈 배열(Open array)은 2개의 파라미터로 전달됩니다. 첫번째 파라미터는 실제 배열에 대한 포인터이며, 두번째 파라미터는 배열의 요소 갯수입니다. 따라서 오픈 배열 파라미터를 전달하는 것은 실제로는 2개의 파라미터 슬롯을 차지하게 됩니다. 예를 들어, 여러분이 register 콜링 컨벤션을 사용하고 하나의 오픈 배열 파라미터를 전달한다면 eax는 배열에 대한 포인터가, edx는 요소의 갯수가 들어가게 됩니다. 2장에서 콜링 컨벤션에 대한 자세한 내용을 살펴볼 수 있습니다.
(8) 값 자체는 10바이트만 차지하지만 실제로는 12바이트(3 dword)가 할당됩니다. 마지막 2바이트의 내용은 정의되지 않습니다.
이 아티클은 귀도 기벨스(Guido Gybels)의 Using Assembler in Delphi를 번역한 것으로, 총 4개의 장으로 되어 있는 시리즈 아티클들 중 두번째입니다. 번역 및 전재를 하도록 허락해주신 귀도씨에게 감사드립니다.
원문 : http://www.guidogybels.eu/asmch2.html