델파이에서 어셈블러 사용하기 (3)

사용자 삽입 이미지
이 아티클은 귀도 자이벨스(Guido Gybels)의 Using Assembler in Delphi를 번역한 것으로, 총 4개의 장으로 되어 있는 시리즈 아티클들 중 세번째입니다. 번역 및 전재를 하도록 허락해주신 귀도씨에게 감사드립니다.

원문 : http://www.guidogybels.eu/asmch3.html

3장. 로컬 변수

어셈블리 코드 안에서도 일반적인 파스칼 루틴에서처럼 로컬 변수를 사용할 수 있습니다. 로컬 변수는 파스칼 루틴에서와 같은 방식인 var 섹션으로 선언합니다. 이 장에서는 로컬 변수들이 asm 블럭에서 어떻게 구현되는지와 사용되는지에 대해 자세히 살펴보겠습니다.

3.1. 로컬 변수와 스택 프레임

로컬 변수를 위한 저장 공간은 스택에 할당되며, 컴파일러가 생성한 엔트리 코드에서 생성합니다(그리고 엑시트 코드에서 해제됩니다). AnsiString과 같은 일부 복잡한 타입들에서는 할당된 공간은 실제 데이터에 대한 포인터일 수도 있다는 점(스트링은 힙에 존재하고 스트링 변수는 그 데이터에 대한 단순한 포인터입니다)과 실제 데이터를 할당하고 지정하는 데에는 추가로 동작이 더 필요할 수 있다는 점도 기억해둡시다. 앞 장에서 설명한 내용에 이어, 다음과 같은 코드를 살펴봅시다:


이전 장에서 살펴봤던 것처럼 pascal 콜링 컨벤션을 사용하면 프로시저가 호출되기 전에 파라미터들이 스택에 푸시됩니다. 호출 명령은 리턴 주소를 스택에 푸시합니다. 다음으로 엔트리 코드는 ebp의 값도 스택에 푸시합니다. 그후 ebp는 스택 프레임의 데이터를 액세스하기 위한 베이스 포인터로 설정됩니다. 이 시점에서 스택 프레임은 다음과 같은 상태입니다:

1009298052

그리고 우리는 지역 변수 SomeTemp를 선언했기 때문에 해당 변수를 위한 공간을 예약하기 위해 컴파일러는 코드를 더 추가하게 됩니다(예를 들면 push ecx):

1248044020

앞에서 썼다시피 ebp는 스택 프레임의 데이터를 액세스하기 위한 베이스 포인터를 가지고 있습니다. 스택은 아래 방향으로 커지므로 더 큰 주소에는 파라미터들이 들어가고 반면 낮은 주소에는 로컬 변수들이 들어갑니다. 우리 예제에서는, 스택 프레임은 다음과 같은 슬롯들이 할당됩니다:

파라미터:
First  = ebp + $10 (ebp + 16)
Second = ebp + $0C (ebp + 12)
Third  = ebp + $08 (ebp + 8)
로컬 변수:
SomeTemp = ebp - $04 (ebp - 4)

다음 로컬 변수는 ebp -8에 할당되고 그렇게 계속됩니다. 스택의 파라미터들과 마찬가지로 스택의 실제 위치 대신 변수 이름을 사용할 수 있습니다(그래야 합니다):

컴파일러는 위 라인을 아래와 같이 번역합니다:

주의할 것은, 이 변수들의 내용은 일반적으로 초기화되지 않으며 정의되지 않은 상태라는 것입니다. 초기화가 필요한 경우 여러분이 직접 해야 합니다.
로컬 변수를 사용하는 것은 스택 프레임을 생성하고 관리해야 하는 오버헤드를 가져오므로, 로컬 저장공간이 필요한지 여부를 결정하기 위해 여러분의 알고리즘을 주의깊게 분석할 필요가 있습니다. 사용 가능한 레지스터를 제대로 사용하고 코드 설계를 잘 할 경우 로컬 변수의 사용을 피할 수 있는 경우가 종종 있습니다. 로컬 변수를 할당하고 관리하는 오버헤드를 피하는 목적 외에도, 데이터를 레지스터 사이에서 옮기는 것이 메인 메모리의 데이터를 액세스하는 것보다 훨씬 빠릅니다. 오브젝트 파스칼 코드를 작성할 때 델파이 컴파일러는 가능할 때마다 레지스터를 사용하려고 시도하는 최적화를 수행합니다. 루프 카운터 변수가 그런 대표적인 경우이며, 그런 경우에는 레지스터를 우선 사용해야 합니다. 물론, asm..end 블럭 안에서는 모든 것이 여러분에게 달려 있으며 컴파일러는 그런 최적화를 수행하지 않습니다. 잘 구성된 코드는 가능한 한 레지스터를 더 많이 활용하려고 노력해야 하며, 가장 자주 사용되는 데이터는 특히 더 그렇습니다.

3.2. 단순 타입의 로컬 변수

많은 데이터 타입들은 로컬 변수를 선언할 때 단순히 스택 프레임에 공간 할당만 하면 됩니다. ShortInt, SmallInt, LongInt, Byte, Word, DWord, Boolean, ByteBool, WordBool, LongBool, Char, AnsiChar, WideChar가 이런 부류에 속합니다.

이 타입들 모두가 32비트 크기는 아니지만 스택 공간의 예약은 항상 32비트 단위로 이루어집니다. 다시 말해 여러분이 byte나 word 같은 더 작은 타입들을 사용하는 경우 할당된 공간 중 사용되지 않는 부분은 정의되지 않는다는 것입니다. 예를 들어, 다음과 같이 로컬 변수를 선언한다면:


AValue는 한 바이트만을 필요로 하지만 dword 전체가 스택 프레임에 할당됩니다. 이런 방식은 스택의 데이터가 항상 dword 경계로 정렬되도록 하여 성능을 개선시키고 로직이 변수 위치를 더 쉽게 계산할 수 있게 해줍니다(또한 간접 주소 지정의 크기 조절도 쉽게 해줍니다). 하지만 이것은 궁극적으로 구현의 이슈이기 때문에 할당된 공간의 남은 부분(패딩)을 사용해서는 안됩니다. 컴파일러의 향후 버전에서는 다르게 동작할 수도 있습니다. 추가적인 저장 공간이 필요하다면 적절한 더 큰 타입을 사용해야 합니다.

이 dword 할당의 규칙은, 스택 프레임에 저장된다고 해도 레코드 타입의 로컬 변수들에는 적용되지 않는다는 점을 기억해두기 바랍니다. 레코드의 멤버 필드들의 정렬(alignment)은 얼라인먼트 스위치({$A} 지시어)의 상태에 따라 결정됩니다. 바로 다음 절에서 더 자세히 살펴볼 것입니다.

얼라인먼트 동작은 로컬 변수를 지역 변수의 오프셋을 수작업으로 계산하지 않고 변수 이름으로 액세스해야 할 또다른 주요 이유입니다. 컴파일러가 여러분 대신 정확한 오프셋을 계산해줍니다. 위의 예제에서 AValue는 단 한 바이트만을 차지합니다. 따라서 할당된 dword 공간 중 가장 하위의 한 바이트만이 사용됩니다. 따라서, 아래의 명령은:

컴파일러에 의해 다음과 같은 코드로 만들어집니다:

asm…end 바깥의 일반적인 파스칼 루틴에서는, 컴파일러가 로컬 변수를 레지스터로 최적화하여 스택 프레임에 변수의 공간이 할당되지 않을 수도 있습니다. asm…end 블럭 안에서는 그런 최적화가 이루어지지 않지만, CPU 윈도우로 컴파일러가 생성한 코드를 살펴볼 때는 이런 최적화가 되었을 수 있음을 염두에 둬야 합니다.

비슷하게, 어떨 때는 컴파일러가 ebp로부터의 오프셋이 아닌 esp를 직접 사용하는 코드를 생성하는 수도 있습니다. 이것은 ebp의 초기화 시간을 아끼기 위한 것인데요. 이전에 설명했던 대로 esp를 여러분의 어셈블리 코드에서 직접 사용하는 것은 비추천입니다. 코드를 읽거나 관리하기에 극도로 어려워지고, 찾기도 디버깅하기도 어려운 미묘한 에러를 일으킬 수 있기 때문입니다. 컴파일러가 생성한 코드를 살펴보는 것은 매우 유용하지만, 여러분은 기계가 아닌 인간 프로그래머라는 점도 기억합시다. 기계는 정확한 오프셋을 제대로 계산해내는 데 뛰어나지만 인간은 대부분 그렇지 않죠. 대부분의 경우 스택 프레임 오버헤드는 병목의 원인이 되지는 않고, 특히 여러분이 코드를 주의 깊게 설계한다면 그럴 가능성은 더 줄어듭니다. 여러분의 애플리케이션에서 스택 프레임 오버헤드가 성능 이슈를 일으킨다고 판단된다면, 여러분의 알고리즘을 다시 고려해야 합니다. 말만 들어서는 아주 명백하게 들리겠지만, 사실 너무나 많은 프로그래머들이 전체적인 애플리케이션 성능에서 효과도 없는 코드 최적화에 시간을 허비합니다.

3.3. 레코드 타입의 로컬 변수

단순 타입의 경우와 마찬가지로 로컬 레코드 변수도 스택 프레임에 저장됩니다. 그런 점에서 레코드 타입도 단순 타입들과 근본적으로 다르지는 않습니다(앞의 절을 참고). 하지만 컴파일러의 레코드 얼라인먼트 메커니즘은 더 복잡합니다. 이런 차이점이 프로그래머가 직접 오프셋을 코딩할 때 문제를 심각하게 복잡하게 만들 수 있습니다.

컴파일러의 레코드 얼라인먼트 동작을 지정하는 두 가지 핵심적인 요소가 있습니다. 그것은 얼라인먼트 지시어({$A} 혹은 {$ALIGN})와 packed 변경자입니다. 그리고 레코드 멤버 필드들의 실제 얼라인먼트는 필드의 타입을 따르게 됩니다. 예를 들어, 다음의 레코드 선언을 살펴봅시다:


이 레코드의 각 멤버 필드들의 얼라인먼트 경계는 각각의 타입과 크기에 따릅니다. 위 예에서, FirstValue와 ThirdValue는 DWord 타입으로, 32비트 타입입니다. 이들은 dword 경계에 따라 정렬됩니다. 이 두 멤버들 사이에는 바이트 크기의 필드인 SecondValue가 있는데, 컴파일러는 3개의 채움 바이트(padding bye)를 추가하여 ThirdValue가 적절히 정렬되도록 해줍니다. 다음의 그림은 정렬된 상태에서 이 레코드의 메모리 할당 상태를 보여줍니다:

1178352422

레코드 선언에 packed 변경자를 추가하면 레코드의 멤버 필드들이 정렬되지 않습니다. 다음의 그림에서 채움 바이트(padding byte)가 존재하지 않는다는 것을 볼 수 있을 것입니다:

1327959721

비슷하게, packed 변경자가 없더라도 {$A-} 지시어를 사용하여 얼라인먼트를 꺼버리면 레코드 멤버 필드들 사이에 채움(padding)이 없어지게 됩니다. 다행히도, 단순 필드들과 마찬가지로 레코드 멤버 필드들을 각각의 이름으로 가리킬 수 있으며, 컴파일러가 정확한 오프셋을 대신 계산해줍니다. 하지만 여러분의 피연산자가 정확한 크기인지 항상 주의해야 하며, 예를 들면 피연산자의 크기를 명시적으로 지정해줄 수도 있습니다. 그런 방법으로 향후에 얼라인먼트가 변경되거나 packed 변경자가 추가되더라도 코드가 정확하게 동작하도록 할 수 있게 됩니다:


 

3.4. 힙 할당 타입의 로컬 변수

델파이의 동적 변수, 긴 스트링, 와이드스트링, 동적 배열, variant, 인터페이스는 힙(heap) 메모리에 저장되는 변수 타입들입니다. 이런 타입들을 이용하려면 실제 변수 데이터에 대한 포인터 같은 참조 변수를 이용해야 합니다. 어셈블리에서는 메모리와 그 내용의 할당과 관리에 대해 여러분이 직접 책임을 지게 됩니다.

다른 말로 하자면, 힙에 할당되는 타입을 로컬 변수로 사용하면, 스택 프레임에 그 변수에 대한 참조(포인터)를 위한 메모리가 할당됩니다. 하지만 그 메모리에 대한 실제 할당과 해제 및 그 내용의 초기화에 대해서는 여러분이 책임져야 합니다. 파스칼에서는 이 타입들 대부분은 자동으로 관리되므로 할당과 해제는 여러분이 신경쓰지 않아도 됩닌다만, 어셈블리 블럭에서는 분명히 그렇지 않습니다.

메모리를 할당하기 위해 GetMem을 호출하고 새로 할당된 메모리에 대한 포인터를 리턴할 수 있습니다. eax에 필요한 메모리의 양을 전달하고, GetMem로부터 리턴된 후 eax 레지스터에는 그 포인터가 들어있으며, 스택 프레임의 적절한 슬롯에 포인터를 저장하면 됩니다.

1 comment for “델파이에서 어셈블러 사용하기 (3)

답글 남기기

이메일 주소는 공개되지 않습니다.