x64 프라이머
64비트 윈도우를 프로그래밍 하기 위해서 알아야 할 모든 것들
이 글은 아래의 내용을 다루고 있습니다:
64비트 윈도우의 기초적인 내용 x64의 간략한 내부 구조 Visual C++ 2005로 x64용 소프트웨어 개발 x64용 소프트웨어를 위한 몇 가지 디버깅 기술들 |
이 글은 아래의 기술들을 사용합니다: Windows, Win64, Visual Studio 2005 |
목차 |
새 로운 64비트 윈도우에서 일했던 경험의 좋았던 점 중의 하나는, 새로운 기술이 어떻게 동작하는지 눈으로 확인할 수 있다는 것이었습니다. 저 자신은 특히 어떤 운영체제 밑바닥에 대해서 조금이라도 알기 전까지는, 그 운영체제에 대해서 그렇게 편안함을 느끼지 못하는 편입니다. 그래서, 64비트 Windows XP와 Windows Server™ 2003이 나타났을 때, 저는 아주 열심히 그 운영체제에 대해서 연구하였습니다.
Win64 와 x64 CPU의 좋은 점은, 그 전의 CPU와 조금 다른 구조를 가지고 있지만, 그 차이를 배우는데 그렇게 많은 시간이 요구되지 않는 다는 점입니다. 저희 같은 개발자들에게는, x64로의 이동이 단지 컴파일만 다시 하면 끝나는 그런 작업이었으면 좋겠지만, 그렇게 생각하고 작업을 하신다면, 앞으로 디버거에서 너무 많은 시간을 보내셔야 할 것 같습니다.
이 글에서, 저는 Win64와 x64의 내부구조에 대한 저의 지식을 종합해서 유능한 Win32® 프로그래머가 x64로 이동하기 위해서 꼭 필요한 지식들 제공하겠습니다. 저는 여러분이 이미 Win32의 개념과 기본 x86개념, 그리고 왜 여러분의 코드가 Win64에서 동작 해야 하는지에 대해서 이미 알고 계신다고 가정하겠습니다. 그렇게 해야지 제가 좀 더 핵심적인 것들에 집중할 수 있거든요. 여기서의 이 요약이 x86 내부구조와 Win32에 대한 여러분의 지식과 비교해서 상대적으로 중요한 차이점에 대한 고찰이라고 생각하시기 바랍니다.
x64 시스템에서 한 가지 좋은 점은, 아이템니윰(Itanium)기반 시스템과는 틀리게 여러 분이 심각한 효율 저하에 대한 고민을 하지 않고, Win32 혹은 Win64를 동일한 기계에서 이용하실 수 있다는 점입니다. 그리고 인텔과 AMD의 x64 구현에 조금 불명확한 몇 가지의 차이가 존재함에도 불구하고, x64용 윈도우는 둘 중 어느 곳에서나 동작합니다. 여러분이 AMD x64와 Intel x64 시스템을 위해서 각각 다른 버젼의 윈도우가 필요하지 않습니다.
저는 여기서 크게 세 가지 영역으로 이 글을 나누어서 진행하도록 하겠습니다: 운영체제의 구현의 몇 가지 자세한 내용들, x64 CPU 내부 구조에 대한 개괄적인 설명, 그리고 Visual C++로 x64용 프로그램 개발.
x64 운영체제
저 는 어떤 윈도우 내부구조를 설명하더라도, 처음에는 메모리와 주소 공간에서 부터 시작합니다. 비록 64비트 프로세서가 이론적으로는 16 엑사 바이트(exabytes) 의 메모리에 접근할 수 있다고 하더라고, Win64는 현재 16 테라바이트(terabytes), 44비트 만을 지원하고 있습니다. 왜 64비트 전부를 사용해서, 16 엑사바이트 전부를 쓸 수 없었을 까요? 거기에는 몇 가지 이유가 존재합니다.
맨 처음 이유로는, 현재 x64 CPU들이 물리적인 메모리 공간에 접근할 때, 오직 40비트(1테라바이트)만을 허용합니다. 그 제한이 없어진다고 하더라고, 지금 현재의 하드웨어가 아닌, 향후에 나올 수 있는 CPU들의 내부구조들이 오직 52비트 (4페타바이트)만큼만 확장될 수 있습니다.그 정도만 해도, 그 많은 메모리를 매핑하기 위한 페이지 테이블의 사이즈는 어마 어마 할 것입니다.
Win32 에서와 같이, 접근할 수 있는 주소 공간은, 사용자와 커널의 영역으로 나누어 집니다. 커널 모드의 코드가 8 테라바이트 상위에서 모든 프로세스에 의해 영역되고, 각각의 프로세서는 8 테라바이트 이하에 자신의 고유 영역을 가집니다. 64비트 윈도우의 각 버젼들은 그림 1 과 그림 2 에서 보여지는 것 처럼, 서로 다른 물리적인 메모리 한계를 가지고 있습니다.
Win32 에서와 같이, x64의 페이지 사이즈는 4KB 입니다. 처음의 64KB 공간은 절대로 매핑 되지 않기 때문에, 여러분이 볼 수 있는 맵핑 주소중 가장 낮은 번지는 0x10000입니다. Win32에서는 다르게, 시스템 DLL들은 사용자 모드 주소 공간의 제일 위 부분과 근접해 있는, 기본 로드 어드레스라는 것이 없습니다. 그 대신, 시스템 DLL들은 4GB 위의 공간에 적재 됩니다. 통상적으로, 그 주소는 0x7FF00000000 (8 테라바이트) 근처입니다.
새 로운 x64 프로세서의 좋은 기능 중에 하나는, 윈도우가 하드웨어적으로 데이터 실행 보호(Data Execution Protection-DEP)를 할 수 있게 지원해 준다는 것입니다. x86 플랫폼에서는, 많은 버그와 바이러스가 CPU가 데이터를 올바른 코드 바이트로 인식하고 실행했기 때문에 존재할 수 있었습니다. 실수이든 혹은 고의적인 버퍼 오버런(buffer overrun)이 원래는 데이터 저장을 목적으로 했던 메모리 블락을 CPU에서 명령어로 인식하고 실행해 버리는 결과가 발생하곤 했었습니다. 이 DEP의 도움으로, 운영체제는, 의도한 코드 영역의 경계를 명료하게 설정할 수 있고, 이 의도된 경계를 벗어나는 코드 실행에 대해서는 CPU가 일종의 덫을 놓을 수 있게 되었습니다. 이 기능은 윈도우를 악의적인 공격에 덜 취약하게 만드는데 큰 도움을 줄 수 있습니다.
에 러들을 잡기 위해서 고안된 장치들 중에 하나가, x64 링커가 실행파일에 대한 기본 적재 주소를 32비트 (4GB) 이상으로 설정한 것입니다. 이것은 코드가 Win64로 포팅된 후, 기존에 있던 코드에서 위에서 이야기한 보안에 문제를 일으키는 부분을 찾기 쉽게 만들어 줍니다. 특히, 만약 포인터가 32비트 값(예를 들어, DWORD)값으로 저장되어 있으면, 그러면, 그 값은 Win64 빌드를 동작시킬 때, 값이 일부분이 짤림 으로서, 포인터를 무효화 시키고, 접근위반(access violation)을 일으킵니다. 이러한 잔기술은 지저분한 포인터 버그를 찾기 쉽게 만들어 줍니다.
포 인터와 DWORD에 대한 주제들도 Win64의 타입을 이야기하는데 빠질 수 없습니다. Win64에서 포인터의 크기가 얼마나 될까요? LONG형의 길이는요? 그리고, 핸들과 HWND의 크기는 얼마나 될까요? 다행스럽게도, 마이크로 소프트가 Win16에서 Win32로의 좀 지저분한 변환을 하면서, 새로운 자료형의 모델에 대해서는 64비트 까지의 확장이 쉽게 될 수 있도록 만들었습니다. 일반적으로 이야기 해서, 몇 가지 예외를 제외하고, 새로운 64비트 세계에서도, 포인터와 size_t를 제외한 모든 나머지 자료형 들은 Win32에서와 동일한 길이를 가지고 있습니다. 즉, 64비트 포인터는 8바이트인 반면에, int,long, DWORD는 아직도 4바이트입니다. 후반부에 Win64 개발에 대해서 이야기 하면서, 이 자료형에 대해서 좀 더 이야기 하도록 하겠습니다.[편집자 주 - 5/2/2006: 핸들은 포인터 값으로 선언되었습니다. 그래서, Win64에서는 4바이트가 아닌 8바이트 값입니다.]
Win64 의 포맷은 PE32+라고 불립니다. 거의 모든 관점에서 이 포맷은 Win32PE 파일과 거의 구조적으로 동일합니다. ImageBase 같은 몇 몇의 포맷은 더 크기가 커졌고, 한 필드는 없어졌고, 그리고 다른 하나의 필드는 다른 CPU 타입을 반영할 수 있도록 변경되었습니다. 그림 3 은 바뀐 필드들을 보여 줍니다.
PE 헤더를 말고는, 그렇게 많이 바뀐 부분이 없습니다. IMAGE_LOAD_CONFIG, IMAGE_THUNK_DATA같은 몇 몇 구조체들은 단순히 필드들을 64비트로의 확장을 했을 뿐입니다. PDATA 섹션의 추가는 Win32와 Win64 구현의 주된 차이점중의 하나를 두드러지게 한다는 점에서 아주 흥미롭습니다: 그 차이는 바로 예외 처리(exception handling) 입니다.
x86 세계에서는, 예외 처리는 스택에 기반했었습니다. Win32 함수가 try/catch 혹은 try/finally 코드를 가지고 있을 때, 컴파일러는 스택에 작은 데이타 블락을 만들어 놓는 코드를 생성했었습니다. 추가로, 각각의 데이타 블락은 이전 try 데이타 구조체를 가리켰었습니다. 그래서, 최근에 추가된 구조체가 리스트의 헤드가 되는 링크드 리스트를 생성했었습니다. 함수가 불려지고, 종료될 때, 링크드 리스트의 헤더는 계속 갱신이 되었었습니다. 그러다, 예외가 발생하는 경우, 운영체제가 스택에 있는 링크드 리스트의 블락을 살펴서, 적절한 핸들러를 찾는 방식으로 이루어져 있었습니다. 저의 1997년 MSJ article 에서 좀 더 자세한 내용을 찾아 보실 수 있습니다.
Win32 예외처리와는 반대로, Win64 (x64와 아이템니움(Itanium) 버젼 둘 다 해당됩니다.)에서는 테이블 기반의 예외 처리를 사영합니다. 더 이상 스택 위에 있는 try 데이타 블락의 링크드 리스트는 없습니다. 대신, 각각의 Win64 실행파일은 런타임 함수 테이블을 가지고 있습니다. 각각의 함수 테이블은 함수의 시작 주소와 끝 주소를 가지고 있을 뿐만 아니라, 함수의 스택 프레임 레이아웃과 예외 처리 코드에 관련된 데이타들의 위치 역시 가지고 있습니다. 이 구조체들의 핵심을 보기 위해서 WINNT.H안에 들어 있는 x64 SDK안에 WINNT.H 안에 있는 IMAGE_RUNTIME_FUNCTION_ENTRY 구조를 살펴 보시기 바랍니다.
예 외가 발생했을 때, 운영체제는 쓰레드 스택을 하나씩 탐색합니다. 스택을 탐색하면서 각각의 프레임을 탐색하고, 저장된 인스트럭션 포인터를 찾아서, 운영체제가 어떤 실행 모듈안에 인스트럭션 포인터가 있는지를 결정합니다. 그리고 나서, 운영체제는 런타임 함수 테이블을 그 모듈에서 찾아서, 적절한 런타임 함수를 찾아서, 데이타로 부터 적절한 예외 처리 결정을 내려 줍니다.
만 약, 여러분이 로켓 과학자이고, PE32+ 모듈 없이 직접적으로 메모리에서 코드를 생성했으면 어떻게 될까요? RtlAddFunctionTable API를 이용하여, 운영체제에게 여러분이 동적으로 생성한 코드에 대해서 알려 줄 수 있습니다.
테 이블 기반의 예외 핸들링의 단점은 (x86 스택 기반 모델에 비해 상대적으로) 함수 테이블을 찾아 보는 것이, 링크드 리스트에서 값을 찾는 것 보다 훨씬 시간이 많이 걸린다는 점입니다. 장점은, 함수를 실행시킬 때 마다 매 번 try 데이타 블락을 생성시키는 오버헤드가 없다는 점입니다.
꼭 기억하세요! 이 글이 아무리 재미있고, 흥미로워도, 이 글은 x64 예외 핸들링에 대한 자세한 설명이라기 보다, 간단한 소개에 불과합니다. x64의 예외 핸들러에 대한 좀 더 깊은 지식을 알고 싶으시다면, Kevin Frei의 블로그을 꼭 한 번 읽어 보시기 바랍니다.
x64 에 호환되는 윈도우에 새로운 API가 그렇게 많지는 않습니다; 거의 모든 새로운 Win64 API들은 아이템니움(Itanium) 프로세서를 위한 윈도우 출시 때 이미 추가되었던 것들 입니다. 간단하게, 그 API들 중 가장 중요한 두 개의 API는 IsWow64Process와 GetNativeSystemInfo 입니다. 이 함수들은 Win32 어플리케이션이 자기 자신들이 Win64에서 돌고 있는지의 여부를 알려 줍니다. 그래서, 만약 64비트 환경에서 동작하고 있다면, 시스템의 진짜 사양(capability)를 올바르게 결정할 수 있게 해 줍니다. 반면에, 32비트 프로세스는 GetSystemInfo 함수를 호출하고, 오직 32비트 시스템인 것 처럼, 시스템의 사양(capability)를 볼 수 있습니다. 예를 들어, GetSystemInfo는 32비트 프로세스 주소 영역만을 보고 합니다. 그림 4 는 x86에서는 사용할 수 없고, 오직 x64에서 쓸 수 있는 API들을 보여 주고 있습니다.
전 부 다 64비트로 동작하는 윈도우 시스템이 아주 멋지게 들리겠지만, 현실적으로 여러분은 잠시 동안 Win32 코드를 필요로 하게 될 것 같습니다. 그러한 작업을 위해서, x64 버젼의 윈도우는 Win32와 Win64 프로세스를 동시에 동일한 시스템에서 동작시킬 수 있는 WOW64 서브시스템이 포함되어 있습니다. 그러나, 여러분의 32비트 DLL을 64비트 프로세스로 올리거나 혹은 반대의 일들은 지원되지 않습니다. (저를 믿으세요, 아주 좋은 일입니다.) 그리고 마침내 여러분은 구닥다리 16비트 코드에게 잘 가라고 인사를 할 수 있게 되었습니다!
x64 버젼의 윈도우에서는, 프로세서는 오직 Win64 DLL들만 로딩할 수 있는, Explorer.exe 같은 64비트 실행 파일에서 부터 시작될 수 있습니다. 반면에, 32비트 실행 파일에서 시작한 프로세스는 오직 Win32 DLL 들만 로딩할 수 있습니다. Win32 프로세스가 커널 모드의 함수를 호출할 때-예를 들어서 파일을 읽는다든지-WOW64는 그 함수를 조용히 가로채서, 올바른 x64 코드의 주소를 주어서 호출하게 합니다.
물 론, 서로 다른 종족(32비트와 64비트) 프로세서들 끼리 통신할 일도 생길 수 있습니다. 운좋게도, Win32에서 여러분이 사랑하고 좋아했던 모든 프로세스간 통신 방법은 Win64에서도 동작합니다. 쉐어드 메모리(shared memory), 네임드 파이프(named pipe), 그리고, 기타 이름이 있는 동기화 객체들을 포함해서 말입니다.
여 러분이 혹시 "그럼 시스템 디렉터리도 Win32와 Win64가 동일한가?"라고 생각하실 지도 모르겠습니다. 동일한 디렉터리가 32 비트와 64 비트 KERNEL32 나 USER32 등과 같은 동일한 이름의 시스템 DLL들을 동시에 가질 수 없습니다, 그렇지요? WOW64는 요술같이 파일 시스템의 리다이렉션(redirection)을 통해서 이 문제를 해결합니다. Win32 프로세스에서의 파일에 대한 쓰기 혹은 읽기 등이 발생하면, SysWow64라는 디렉토리에 있는 커널의 함수를 호출하는 것이 아닌, System32에 있는 커널의 함수를 호출하게 합니다. WOW64가 안보이게 SysWow64 디레토리로 요청한 것을 조용히 바꾸어 주는 것입니다. 그래서, Win64 시스템이 효과적으로 두 개의 시스템 디렉터리, 하나는 x64 용 바이너리들과 또 하나는 Win32용의 바이너리를 가지는 것 입니다.
약 간 혼란스러울 수 있지만, 이러한 내부적인 처리는 상당히 부드러운 것 처럼 보입니다. 제가 System32 디렉터리의 Kernel32.dll에서 Dir을 실행했을 때, SysWow64 디렉터리에서 했던 것과 정확히 똑같은 결과를 볼 수 있었습니다. 파일 시스템의 리다이렉션이 이런 방식으로 동작하는 것을 정확히 이해하기 까지, 제 자신은 머리가 좀 많이 아팠었습니다. 여러분이 x64 어플리케이션에서 32비트 Windows\System32 폴더를 알기를 정말로 원하신다면, GetSystemWow64Directory 라는 API가 여러분께 정확한 경로를 전달해 줄 것 입니다. 그래도, 전체 내용을 알기 위해서 MSDN 문서를 꼭 읽어 보시기 바랍니다.
파 일 시스템의 리다이렉션이외에도, WOW64가 해주는 또 다른 마법 중의 하나가 레지스트리 리다이렉션입니다. 제가 아까 Win64 프로세스에서는 Win32 DLL들을 불러오지 않는다고 했던 말을 생각해 보시고, 그리고 COM 과 in-process 서버 DLL을 불러올 때, 레지스트리를 이용하는 것을 생각해 보시기 바랍니다. 만약, Win32 DLL에 구현되어 있는 COM 오브젝트를 64비트 어플리케이션이 CoCreateInstance를 이용해서, 생성하려고 하면 어떻게 될까요? DLL이 올라올 수 없습니다, 맞지요? WOW64는 Win32 어플리케이션으로 부터의 접근을 \Software\Classes 레지스트리 노드로 리다이렉션 해 줍니다. 결과적으로 Win32 어플리케이션에서 보는 레지스트리 구조는 x64 어플리케이션에서 보는 것과 서로 다르게 됩니다. 그리고, 여러분이 기대하시는 대로, 운영체제는 32비트 어플리케이션이 RegOpenKey와 그 계열 함수군을 이용하여, 실제로는 64비트인 레지스트리에 접근하려고 할 때,내부적으로 새로운 플래그 값을 주어서, 그 값들에 접근할 수 있게 합니다.
약 간만 더 깊숙이 들어가서, 쓰레드 로컬 데이타 영역도 살펴 보아야 합니다. x86 버젼의 윈도우에서는, FS 레지스터가 각 쓰레드의 메모리 영역과 가장 마지막 에러(GetLastError로 확인할 수 있는 에러 값), 그리고 쓰레드의 지역 저장 영역(TLS:Thread Local Storage, TlsGetValue로 값을 얻을 수 있는) 에 사용되었습니다. x64 버젼의 윈도우에서는, FS 레지스터는, GS 레지스터로 교체되었습니다. 그 외에는 거의 동일한 방식으로 x32와 x64의 운영체제가 동작합니다.
비 록, 이 글이 x64의 사용자 입장에 초점을 두고 있기는 하지만, 커널 모드의 내부 구조에서 한 가지 추가된 중요한 점이 있습니다. PatchGuard라고 불리는 새로운 기술이 x64 윈도우에 추가되었습니다. 이 기술은 보안과 견고함을 위한 목적으로 추가되었습니다. 작게는 syscall 테이블이나 인터럽트 디스패치 테이블(interrupt dispatch table-IDT)를 변경하는 사용자 프로그램이나 드라이버들은 보안상의 문제와 잠재적인 안정성의 문제를 일으켜 왔었습니다. x64의 내부에서는, 그러한 방식으로 커널의 메모리를 지원되지 않는 방식으로 바꾸는 방식이 허용되지 않습니다. 이러한 것을 강화시키는 기술이 PatchGuard 입니다. 이 기술은 중요한 커널 메모리의 위치가 바뀌는 것은 커널 모드의 쓰레드에서 항상 감시합니다. 그리고 메모리가 바뀌면, 시스템은 버그체크를 통하여 멈춰 버립니다.
모 든 것을 고려해 보아도, 만약 여러분이 Win32의 내부 구조에 어느 정도 알고 있고, 어떻게 코드를 쓸 줄 알고, 동작하는지를 알고 있으면, Win64로의 이동에 있어서 크게 놀라지 않으실 겁니다. 거의 대부분은 좀 더 넓은 환경으로의 이동이라고 간주하셔 됩니다.
x64의 간략한 내부 구조
자 이제, CPU의 구조 자체에 대해서 조금 살펴 보기로 하겠습니다. 왜냐하면, 기본적인 CPU의 명령어(instructions)에 대해서 알고 있는 것이, 개발(특히 디버깅!)을 훨씬 쉽게 만들기 때문입니다. 처음에 여러분이 알아 차릴 수 있는 것은, 컴파일러가 생성한 x64 코드가 여러분이 알고 있고, 사랑하는 x86 코드와 거의 흡사하다는 점입니다. IA64 코딩의 경우는 그렇게 유사하지 않았었습니다.
그 리고, 두번째로 여러분이 알아차릴 수 있는 것은, 레지스터 이름이 여러분이 사용하던 것들과 조금씩 다르고, 레지스터 자체도 조금 많다는 점 입니다. 일반적인 용도의 x64 레지스턷들의 이름은 R로 시작합니다. 예를 들어서, RAX, RBX, 이런 것들이 있습니다. 이것들은 E이름을 가지고 있는 32비트 x86 레지스터들의 확장입니다. 아주 오래 전에, 16비트 AX 레지스터가 32비트 EAX가 되고, 16비트 BX 레지스터가 32비트 EBX가 되었던 것 처럼 말입니다. 32비트로 전이가 될 때 생겨난 E 레지스터들은 64비트로 이동하면서는 R 레지스터들이 된 것이죠. 그래서, RAX는 EAX의 계승자이고, RBX는 EBX의 계승, RSI는 ESI, 그런 식으로 확장되었습니다.
추가로, 8개의 일반적인 용도의 레지스터 (R8-R15)가 추가되었습니다. 64비트에서 주로 쓰이는 일반적인 용도의 레지스터들은 그림 5 와 같습니다.
물론 32비트 EIP 레지스터도 RIP 레지스터가 되었습니다. 그리고 32비트 명령어도 여전히 계속 동작하고 32비트 레지스터는 물론이고16비트 레지스터도 (EAX,AX,AL,AH등과 같은) 여전히 유효합니다.
그 래픽 작업을 하거나 혹은 과학적인 연산이 필요한 고수들이 사용할 수 있도록, x64 CPU는 여전히 XMM0에서 XMM15로 명명된 16개의 128비트 SSE2 레지스터를 가지고 있습니다. 그 외 여기서 이야기하지 않는 다른 x64 레지스터들에 대한 모든 정보들은 WINNT.H 안에서 _CONTEXT로 적적하게 #ifdef된 구조체에서 찾아 보실 수 있습니다.
아 뭏튼, x64 CPU는 언제라도 구형의 32비트 모드 혹은 64비트 모드 둘 다 에서 동작할 수 있습니다. 32비트 모드에서, CPU는 다른 x86 CPU처럼 명령어를 해석하고, 이에 기반하여 동작합니다. 64비트 모드에서는, CPU는 새로운 레지스터와 명령어를 지원하기 위해서 어떤 특정 명령어 인코딩에 대해서 약간의 사소한 조정을 하였습니다.
만 약 여러분이 CPU 오피코드 인코딩 다이아그램(opcode encoding diagram)에 익숙하시다면, 아마도, 새로운 명령어 인코딩을 위한 공간은 빨리 없어진다는 것과 새로운 명령을 위해 여덟 개의 새로운 레지스터를 쥐어짜는 것은 쉬운 일은 아니라는 것을 기억하실 겁니다. 새로운 명령어를 추가하는 방법 중의 하나는, 거의 쓰이지 않는 명령어를 삭제하는 것 입니다. 그래서, x64에서는 기존의 CPU에서 사용되던 몇 개의 명령어가 삭제되었고, 지금까지, 제가 오직 그리워 하는 명령어는 스택의 일반용도 레지스터의 값을 모두 저장했다가, 다시 복원해 주는, 64비트 PUSHAD와 POPAD 입니다. 또 다른 방법 명령어 인코딩 공간을 확보하는 방법은, 64비트에서 더 이상 쓰이지 않는 세그먼트관련 레지스터들을 전부 제거해 버리는 것 입니다. 그래서, CS, DS, ES, SS, FS,그리고 GS 레지스터가 더 이상을 쓰지 않습니다. 그렇게 많은 사람이 이 레지스터들을 그리워할 것 같지는 않군요.
64비트 주소가 사용됨에 따라, 여러분이 코드 사이즈에 대해서 궁금해 할지도 모르겠습니다. 예를 들어서, 아래의 경우는 흔한 32비트 명령어 입니다:
CALL DWORD PTR [XXXXXXXX]
00401000: CALL DWORD PTR [00020000h]
x64 레지스터의 가장 큰 장점 중의 하나는, 컴파일러가 스택 보다 모든 파라미터들을 레지스터에 전달하는 코드를 마침내 생성할 수 있다는 점 입니다. 스택에 파라미터를 구겨 넣는 것은 메모리 억세스를 필요로 합니다. 그리고, 우리는 CPU 캐쉬에 없는 메모리 억세스는 램에서 그 내용을 가져올 때 까지, 몇 사이클 동안 CPU를 잠시 서있게 한다는 사실도 알고 있습니다.
함 수 호출 방식(Calling Convention) 의 경우, x64 내부구조를 이용하여 _stdcall, _cdecl, _fastcall, _thiscall 같은 기존에 존재하는 Win32 함수 호출 방식을 모조리 정리할 수 있는 기회를 가졌습니다. Win64의 경우는, 딱 하나의 함수 호출 방식이 존재합니다. _cdecl 같은 방식은 그냥 컴파일러에서 무시됩니다. 이러한 함수 호출 방식의 단일화는 무엇보다도 디버깅을 원활하게 하는데 큰 혜택입니다.
x64의 함수 호출 방식은 fastcall 방법과 유사합니다. x64 호출 방식에서는, 처음 네 개의 정수 인자가 이 목적을 위해 디자인된 레지스터에 전달됩니다:
RCX: 1 번째 정수 인자 RDX: 2 번째 정수 인자 R8: 3번째 정수 인자 R9: 4번째 정수 인자
네 개 이상의 정수 인자는 스택을 통해서 전달됩니다. 그리고 this 포인터는 정수 인자로 간주되어 항상 RCX 레지스터에서 발견될 수 있습니다.
부동 소수점 인자들에 대해서는, 처음 네 개의 인자들은 XMM0에서 부터 XMM3 레지스터를 통해서 전달되고, 나머지 부동 인자들은 쓰레드 스택을 통해서 전달 됩니다.
함 수 호출 방식에 대해서 조그만 더 깊숙이 들어가면, 인자들이 레지스터를 통해서 전달될 수 있음에도 불구하고, 컴파일러는 RSP 레지스터를 감소시키면서, 여전히 스택에 공간을 예약해 놓습니다. 최소한, 각각의 함수는 반드시 32바이트 (네 개의 64비트 값)을 예약해 놓아야 합니다. 이 공간은 레지스터들이 함수에 전달되어, 잘 알려진 스택 위치에 쉽게 복사되도록 합니다. 물론, 불리는 함수 측에서 함수의 인자를 채우지는 않습니다. 하지만, 필요한 경우에 이러한 스택 공간의 예약은, 함수 인자들이 레지스터에서 쉽게 스택으로의 복사를 가능하게 합니다. 물론, 네 개 이상의 인자가 전달된다면, 적절한 스택의 추가 공간이 반드시 예약되어야 합니다.
예 를 한 번 들어 보겠습니다. 어떤 함수가 자식 함수에게 두 개의 정수 인자를 전달하는 경우가 있다고 가정을 한 번 해보겠습니다. 컴파일러는 두 개의 인자를 각각 RCX와 RDX에 각각 전달할 뿐만 아니라, RSP 스택 포인터 레지스터에서 32바이트를 빼놓습니다. 불리는 함수 입장에서는, 파라미터의 값을 RCX와 RDX 레지스터를 통해서 접근 가능합니다. 만약, 불리는 함수 코드가 레지스터가 다른 이유에서 필요할 경우, 이 값들은 예약된 32바이트 스택 영역에 복사됩니다. 그림 6은 6개의 정수 인자가 전달된 뒤의 레지스터와 스택을 보여 주고 있습니다.
Figure 6 Passing Integers
x64 시스템에서의 파라미터 스택 정리는 약간 재밌는 모습을 보여 주고 있습니다. 기술적으로는, 불리는 함수(callee)가 아닌, 부르는 함수(caller)가 스택의 정리를 책임지고 있습니다. 그러나, 여러분은 프로롤그와 에필로그 코드를 제외하고 다른 부분에서 RSP를 조정하는 모습을 거의 보기가 힘들 것 입니다. PUSH와 POP 명령어로 스택에서 인자를 더하거나 빼주는 x86 컴파일러와 다르게, x64 코드 생성기는 (파라미터의 입장에서 보면) 얼마든지 큰 대상 함수에서도 쓸 수 있을 만큼 충분한 스택을 예약해 놓았습니다. 그래서, 자식 함수를 호출 시에, 파라미터를 설정하기 위해 똑같은 스택 영역을 계속 반복해서 씁니다.
짧게 말해서, RSP 레지스터는 거의 변하지 않습니다. 이 점은 ESP 레지스터 값이 파라미터가 스택에 추가되거나 정리되면서, 계속 변하는 x86 코드와 상당히 틀립니다.
예 를 하나 들어 보겠습니다. 세 개의 다른 함수를 호출하는 x64 함수가 있다고 생각해 보시기 바랍니다. 처음 함수는 네 개의 인자(0x20 바이트=32바이트)를 받습니다. 두번째 인자는 열 두개의 인자(0x60바이트=96바이트)를 받습니다. 세번째 함수는 여덟 개의 인자(0x40=64바이트)를 받습니다. 프롤로그에서는, 생성된 코드는, 스택에 단지 96바이트만 예약해서 대상 함수가 인자들을 찾을 수 있도록, 96 바이트 안의 적절한 위치에 인자들을 복사해 놓습니다.
x86 함수 호출 방식에 대한 좀 더 자세한 내부 구조는 Raymond Chen's blog에 서 찾을 수 있습니다. 이 이상은 더 자세히 설명하지는 않겠습니다만, 몇 가지만 중요한 점을 더 말씀 드리자면. 첫 번째, 함수의 인자들 중, 처음 네 개의 인자들 중에서, 64비트 보다 적은 정수 인자들은 부호 확장(sign extended)이 일어나고, 적절한 레지스터를 통하여 전달할 수 있습니다. 두 번째로, 64비트 얼라인을 지키기 위해서, 절대로 8바이트의 정수배가 아닌 함수 인자가 스택에 존재해서는 안됩니다. 구조체를 포함해서, 1, 2, 4, 혹은 8 바이트가 아닌 인자들은 래퍼런스를 통해서 전달됩니다. 그리고 마지막으로, 8,16,32, 64비트의 구조체와 고용체는 동일한 크기의 정수 인 것 처럼, 전달됩니다.
함 수 결과 값은 RAX 레지스터에 저장됩니다. 부동 소수점 형식의 값은 예외적으로 XMM0으로 돌려 받습니다. 함수 호출을 통하여, 제가 말씀드리는 레지스터들은 예약되어 있어야 합니다: RBX, RBP, RDI, RSI, R12, R13, R14, 그리고 R15. 그리고 지금 말씀드리는 레지스터들은 휘발성이고, 값이 없어질 수 있습니다:RAX, RCX, RDX, R8, R9, R10, 그리고 R11.
위 에서 제가 예외 처리 메커니즘의 일환으로, 운영체제가 스택 프레임을 검사한다고 말씀 드렸습니다. 여러분이 한 번이라도, 스택을 검사하는 코드를 써보신 적이 있다면, 거의 임시적인 Win32 프레임의 레이아웃이 프로세스를 다루기 힘들게 한다는 것을 아실 겁니다. 이러한 상황이 x64에서는 더 좋아졌습니다. 만약 함수가 스택 공간을 할당하고, 다른 함수를 호출하고, 어떤 레지스터를 예약하거나, 예외 처리를 이용하다면, 그 함수는 반드시 표준화된 프롤로그와 에필로그를 생성하기 위하여 잘 정의된 명령어 집합(well-defined set of instructions)을 써야 합니다.
표 준화된 함수의 스택 프레임을 사용하도록 강제하는 것은, 운영체제가 스택을 언제든지 탐색할 수 있는 것을 보장하는 한 방법입니다. 이러한 일관성에, 표준화된 프롤로그를 이용하여 컴파일러와 링커는 관련된 테이블에 데이터를 생성해야 합니다. 궁금하신 분들을 위해서, 좀 더 자세히 설명하면, 테이블의 모든 함수 정보들은 winnt.h에 정의되어 있는 IMAGE_FUNCTION_ENTRY64의 배열 테이블에 저장되어야 합니다. 어떻게 그 테이블을 찾는지 궁금하시다구요? 그 테이블은 PE헤더의 데이터 디렉터리 영역 안에 IMAGE_DIRECTORY_ENTRY_EXCEPTION의 엔트리가 지정하고 있습니다.
상당히 짧은 분량이지만, 내부구조의 많은 부분을 다루었습니다. 그러나, x86에 대한 큰 개념과 32비트 어셈블리 언어에 대한 지식이 있으신 분들은, 상당히 짧은 시간 안에 x64 명령어를 이해하실 수 있으실 겁니다.
Visual C++로 x64용 어플리케이션 개발
Visual Studio® 2005 이전에도 마이크로 소프트에서 나온 C++ 컴파일러로 x64용 코드를 생성하는 게 가능했지만, IDE와 완벽히 통합은 되지 않았었습니다. 이 글에서는, 저는 여러분이 Visual Studio® 2005를 가지고 있고, 여러분이 x64용 도구 (기본 설치 옵션이 아닙니다.)을 선택했다고 가정하고 이 글을 진행하겠습니다. 그리고, 여러분이 기존에 Win32 사용자 모드에서 C++를 이용한 프로젝트 경험이 있다고 가정하겠습니다.
x64 를 위한 첫 번째 단계는, 64비트 빌드 환경을 구축하는 것 입니다. 이미 다 아시겠지만, 여러분 프로젝트에는 기본으로 두 개의 환경이 있습니다. Debug와 Retail이 그것입니다. 여러분이 여기에서 더 해야 하는 것은, 두 개의 환경을 더 생성하는 것 뿐입니다. x64를 위한 Debug와 Retail 를 추가하는 것 입니다.
기 존의 프로젝트 혹은 솔루션을 한 번 열어 보시기 바랍니다. 빌드 메뉴에서, Configuration Manager를 선택해 보십시오. Configuration Manager 다이얼로그 박스에서, Active Solution Plaftfrom 콤보 박스를 눌러서, New를 선택하시기 바랍니다(그림 7). 그러면, 여러분은 New Solution Plaftform이라고 명명된 다른 다이얼로그를 보실 수 있을 것 입니다.
Figure 7 Creating a New Build Configuration
x64 를 새 플랫폼(그림 8)로 선택하고, 다른 설정은 그냥 놔두시기 바랍니다; 그리고 OK를 클릭하세요. 그게 전부랍니다! 여러분은 이제 네 가지의 가능한 빌드 환경을 구축하셨습니다: Win32 Debug, Win32 Retail, x64 Debug, x64 Retail. Configuration Manager 를 이용해서, 쉽게 다른 환경으로의 변경도 가능합니다.
자, 이제 여러분의 코드가 어떻게 64비트에 적용 가능한지 한 번 살펴 보기로 하겠습니다. x64 Debug 설정을 기본으로 놓으시고, 프로젝트를 빌드해 보시기 바랍니다. 프로젝트 자체가 아주 가볍지 않은 이상, Win32 환경에서 볼 수 없었던 컴파일 에러들을 보실 수 있을 겁니다. 여러분이 포팅이 불가능할 정도의 C++코드를 쓰지 않는 이상, 상대적으로 쉬운 에러 몇 개만 발생해서, 여러분은 쉽게 Win32와 x64에 대응하는 코드를 가지게 될 것 입니다. 특별히 조건부 컴파일 같은 것을 이용하지 않아도 말이죠.
Figure 8 Selecting the Build Platform
Win64 호환되는 코드 만들기
아 마도, Win32 코드에서 x64로 컨번팅할 때 가장 문제가 되는 부분은, 타입 정의를 변경하는 일이 될 것 같습니다. 제가 혹시 앞에서 Win64의 자료형에 대해서 이야기 했던 것에 대해서 기억하시나요? C++ 컴파일러의 원래 자료형 (int, long 기타 등등) 을 쓰는 것 보다, 윈도우에서 정의한 typedef로 정의된 자료형을 쓰는 편이 깨끗한 Win32 x64 코드를 생성하는데 쉽습니다. 그리고 Win32의 자료형을 쓰실 때, 일관성있게 쓰셔야 할 필요가 있습니다. 예를 들어서, 윈도우가 HWND을 넘겨줄 때, 단지 편하고 쉽다고 해서, FARPROC 형식의 변수에 이 값을 저장하지 마십시요.
많 은 코드를 업그레이드 하면서, 아마도 제가 흔히 그리고 쉽게 보는 에러는, 포인터 값이 32비트 데이타 타입인 int 혹은 long, 그리고 DWORD에 저장되어 있는 것 이었습니다. Win32와 Win64에서의 포인터는 사이즈는 틀리지만, 정수형은 동일한 크기를 유지합니다. 그러나, 컴파일러에게 포인터 값을 정수형에 저장하는 것은 금지하는 것 역시 가능하지 않습니다.
이 런 상황을 해결하기 위해서, 윈도우 헤더에는 _PTR 타입이 선언되어 있습니다. 예를 들면, DWORD_PTR, INT_PTR, 그리고 LONG_PTR 같은 타입들이 대상 플랫폼에 따라서, 안전하게 포인터 변수를 이용하게 해줍니다. 예를 들어서, DWORD_PTR타입은 Win32에서 컴파일 되었을 때는, 32비트지만, Win64에서는 64비트입니다. 저의 경우는, 그동안의 연습으로, 어떤 자료형을 선언할 때, "내가 여기서 DWORD를 선언해야 할까? 아니면 DWORD_PTR를 선언해야 할까?"라고 물어보는 것이 버릇이 되어 버렸답니다.
기 대하시는 대로, 정수형에서 얼마나 많은 바이트를 원하는지 계산하는데, 약간의 문제가 있을 수 있습니다. DWORD_PTR를 정의하는 헤더파일(basetsd.h)에서 역시 INT32, INT64, INT16, UINT32, DWORD64 같은 여러 형태의 정수를 선언해 놓고 있기 때문입니다.
자 료형의 크기에 관련된 또 다른 문제는 printf와 sprintf의 포맷팅(formatting)입니다. 저는 확실히 과거에 %X 혹은 %08X등을 포인터 값을 나타내는데 사용했다는 점에서 약간의 죄책감(?)을 느끼고 있고, 그 코드는 x64 시스템에서 문제를 일으키고 있습니다. 옳은 방식은 대상 플랫폼의 포인터의 크기를 자동으로 계산해 주는 %p를 사용하는 것 입니다. 추가로, printf와 sprintf는 사이즈에 의존하지 않는 타입인 'I' 프리픽스(prefix)를 가지고 있습니다. 예를 들어서, 여러분은 %Iu를 UINT_PTR 변수를 출력하기 위해서 사용할 수 있습니다. 이와 동일한 방식으로, 여러분이 특정 변수가 언제 64비트 부호 있는 값이 될 것이라는 알고 있다면, %I64d를 쓰실 수 있습니다.
자 료형의 불일치에서 일어난 에러들을 정리하는 것만으로는 Win64가 준비되었다고 말할 수 없습니다. 여러분은 아직은 아마도 x86에서만 돌아갈 수 있는 소스코드를 가지고 계실겁니다. Win64로의 포팅을 위해서, 특정 코드에서는 여러분이 Win32와 x64를 위한 두 가지 버젼의 함수를 쓰실 수도 있습니다. 이 때야말로, 전처리자(preprocessor)가 아주 유용하게 쓰입니다:
_M_IX86 _M_AMD64 _WIN64
전처리자 매크로를 사용할 때, 뭘 원하는지 한 번 열심히 생각해 보시기 바랍니다. 예를 들어서, 이 코드가 정말로 x64 프로세서만을 위한 것이라면, 아래와 같이 매크로를 쓰시기 바랍니다:
#ifdef _M_AMD64
반면에, 동일한 코드가 x64와 아이템니움(Itanium)에서 동작하기를 원한다면, 여러분은 아래와 같이 쓰시는게 좋을 것 같습니다:
#ifdef _WIN64
이러한 매크로를 쓸 때 제가 유용하게 쓰는 버릇이 하나 있습니다. 바로 제가 무엇을 잊어 버렸을 때를 대비해서, 명시적으로 모든 경우에 대해서 #else 케이스문을 써주는 것입니다. 아래에 코드를 한 번 살펴봐 주시기 바랍니다:
#ifdef _M_AMD64
// My x64 code here
#else
// My x86 code here
#endif
#ifdef _M_AMD64
// My x64 code here
#elif defined (_M_IX86)
// My x86 code here
#else
#error !!! Need to write code for this architecture
#endif
그 리고, 마지막으로 저의 경우는 Win32코드 중에서 x64로 쉽게 포팅되지 않는 부분 중의 하나는 인라인 어셈블러 부분이었습니다. Visual C++가 x64 를 위한 인라인 어셈블러를 지원하지 않거든요. 대신에 64비트 MASM이 제공되고, MSDN에 문서화가 되어 있습니다. ML64.exe와 다른 x64툴들은 (CL.EXE와 LINK.EXE를 포함해서) 커멘드라인에서도 쓸 수 있습니다. 단지 VCVARS64.BAT를 실행시키세요. 그러면, 여러분의 패스에 이 파일들의 경로를 포함해 줄 것 입니다.
디버깅
여 러분은 마침내, 여러분의 코드를 Win32와 x64 양쪽에서 깨끗하게 컴파일 할 수 있게 되었습니다. 이 대장정의 마지막은 "어떻게 디버깅을 하느냐?" 입니다. 여러분이 x64 에서 x64 버젼을 빌드 했는지의 여부에 상관없이, 여러분은 x64 모드에서 디버깅 하기 위해서는, Visual Studio의 리모트 디버깅 기능이 필요합니다. 운좋게도, 여러분이 64비트 컴퓨터에서 Visual Studio를 동작시키면, IDE가 내부적으로 이 단계를 여러분을 위해서 해줍니다. 어떤 이유로, 리모트 디버깅을 할 수 없을 때, 여러분의 또 다른 옵션은 x64용 WinDbg를 사용하시는 방법도 있습니다. 그러나, 그렇게 되면, Visual Studio 디버그의 많은 훌륭한 기능들을 포기하셔야 합니다.
여러분이 리모트 디버깅을 한 번도 해보신 적이 없다고 하더라도, 걱정하실 필요 없습니다. 한 번만 설치 되면, 리모트 디버깅도 로컬처럼 쉼없이 잘 동작합니다.
첫 번째 단계는 64비트 MSVSMON을 대상 컴퓨터에 설치하는 것 입니다. 이것은 일반적으로 VisualStudio와 같이 오는 RdbgSetup을 동작시키면, 알아서 해 줍니다. 일단 MSVSMON이 설치되면, Tools 메뉴에서 적절한 보안 설정(혹은 lack)등을 설정할 수 있습니다.
다음에는, Visual Studio 안에서, 여러분이 x64코드를 위해서 리모트 디버깅을 위한 설정을 하셔야 합니다. 이것은 프로젝트의 프로퍼티(그림 9)에서 할 수 있습니다.
Figure 9 Debugging Properties
여 러분의 64비트 구성을 선택해서, Configuration 프로퍼티 아래에 있는 Debugging을 선택하시기 바랍니다. 위쪽에 Debugger to launch라고 되어 있는 부분이 있습니다. 일반적으로 이 부분이 Local Windows Debugger로 설정되어 있습니다. 이 설정을 Remote Windows Debugger로 변경하시기 바랍니다. 그 아래에서, 여러분이 디버깅을 시작할 때의 리모트 명령(예를 들어서, 프로그램 이름)과 리모트 시스템의 이름과 연결 속성을 선택해 줄 수 있습니다.
모 든 것이 다 제대로 설정되었다면, 여러분의 x64 어플리케이션을 여러분이 Win32 프로그램에서 하듯이 디버깅할 수 있습니다. 여러분은 MSVSMON이 매 번 성공적으로 디버그와 연결이 될 때, "connected"라는 메세지를 보내 주기 때문에, 연결이 되었는지의 여부를 판별할 수 있습니다. 여기서 부터는, 여러분이 알고 있고, 사랑하는 Visual Studio 디버거와 거의 모든 것이 동일합니다. 64비트 레지스터를 보기 위해서, 그리고 비슷하지만 조금 다른 x64 어셈블리 코드를 보기 위해서 레지스터 창과 디스어셈블러 창을 띄우는 것을 잊지 마세요.
주 의: 64비트 미니덤프는 32비트 덤프처럼 Visual Studio에서 직접 불러 올 수 없습니다. 대신, 여러분은 리모트 디버깅을 이용해야 합니다. 네이티브(native)와 매니지드(managed) 64비트 코드의 상호호환은 현재로서 Visual Studio 2005에서 지원되지 않습니다.
매니지드 코드는 어떻게 하나요?
마 이크로 소프트 .NET 프레임 웍에서 코딩 하는 것의 장점 중의 하나는, 깔려있는 운영제제의 많은 부분이 일반적인 목적으로 코드로 추상화 되어 사라져 버렸다는 점입니다. 추가로 IL 명령어 포맷은 CPU에 의존적이지 않습니다. 그래서, 이론적으로는, Win32에서 제작된 .NET에 기반을 둔 바이너리 파일은 x64시스템에서 수정하지 않고 동작해야 합니다. 그러나, 현실은 아주 복잡합니다.
x64 버젼의 .NET 프레임웍 2.0을 제 x64시스템에 설치한 뒤에, 저는 제가 Win32에서 실행했던 똑같은.NET 실행파일을 동작시킬 수 있었습니다. 정말 좋지요? 물론, 모든 .NET에 기반을 둔 프로그램이 Win32와 x64에서 컴파일 하지 않고, 동일하게 동작 한다는 보장은 없습니다만, 다만 합리적인 범위의 시간 안에 그냥 동작합니다.
만 약 여러분의 코드가 명시적으로 native code를 호출한다면 (예를 들어서, C# 혹은 Visual Basic 에서의 P/Invoke) 64비트 CLR에서 실행 시에 문제에 부딫힐 확률이 커집니다. 그러나, 다행스럽게도 컴파일러 스위치에 여러분의 코드가 어떤 플랫폼에서 동작할지를 명시하는 부분이 있습니다. 예를 들어서, 여러분은 64비트 CLR이 존재함에도 불구하고, 여러분의 코드가 WOW64에서 동작하기를 원한다고 명시할 수도 있습니다.
최종 정리
모 든 것을 고려해 보아도, x64 버젼의 윈도우로 이동하는 것은, 비교적 저에게는 어렵지 않은 과정이었습니다. 일단, 여러분이 운영체제 구조와 툴의 상대적으로 미미한 차이점을 한 번 훑어 보기만 하면, 하나의 코드를 바탕으로 해서 두 개의 플랫폼에서 동작하는 것이 어렵지 않습니다. Visual Studio 2005가 이 노력의 과정을 훨씬 쉽게 만들어 주고 있고, x64에 특화된 디바이스 드라이버나 툴, 예를 들면 Sysinternals.com의 Process Explorer은 매일 새롭게 나타나고 있습니다, 그러니 이 쪽으로 뛰어 들지 않을 이유가 없습니다!
0