<64bit 기반 프로그래밍>
[WIN32 vs WIN64]
이 책이 쓰여질 당시에는 64비트 운영체제로 점차 갈아타고 있는 시점이었다.
그리고 지금은 64비트 운영체제가 거의 보편화되었고, 32비트 운영체제를 찾아보기가 힘들 정도다.
그렇지만 아직도 32비트 운영체제와의 호환성은 생각해야 할 문제다.
[64비트와 32비트]
생각해보면 숫자가 높으면 그냥 다 좋다고 생각할 때가 있었다.
나도 한때는 그랬고 더 빠르고 더 좋은줄만 알았지 왜 좋은지는 생각을 해본 적이 없었다.
도대체 32비트와 64비트 시스템은 뭘 기준으로 나누는 것일까?
1) 한 번에 송/수신할 수 있는 데이터의 크기 - (버스 시스템)
2) 데이터 처리 능력 - CPU
이 둘을 한 문장으로 정리하면 다음과 같다.
"한 번에 송/수신할 수 있는 데이터의 크기와
한 번에 처리할 수 있는 데이터 크기를 기준으로 32, 64비트 컴퓨터를 구분한다."
[프로그래머 입장에서의 64비트 컴퓨터]
오랜만에 주소값을 표현하기 위해 몇 비트를 사용하면 메모리의 크기는 얼마가 되는지에 관련한 내용이 있었다.
학부생 때는 대충 설명을 듣고 나서 기억하고 있었는데 오늘 이 내용을 다시 보니 무슨 내용인가 싶었다.
다행히도 쉽게 설명해놓으신 분이 있었다.
우선 내용을 설명해주신 분에게 감사드리며, 링크를 올려둔다.
https://st-lab.tistory.com/198
이걸 보고 나도 내용을 복기하면서 그제서야 이해할 수 있었다.
그리고 내가 나름대로 풀어서 내용을 정리하고자 한다.
책에서 들었던 예시는 다음과 같다.
"현재 메모리는 1GB 정도가 있는데, 주소값을 표현하기 위해 4비트가 사용된다.
그러면 메모리에 할당할 수 있는 주소값의 개수는 $2^4$개로 총 16바이트 밖에 사용할 수 없다."
이게 뭔 소리인가 싶은 분들이 분명히 있을 것이다.
32비트와 64비트를 예로 들어보겠다.
1비트는 0과 1을 구분할 수 있는 단위를 말하고, 0~1까지 표현이 가능하므로 경우의 수가 2가지가 된다.
그렇다면 32비트는 표현할 수 있는 경우의 수가 어떻게 될까?
0000 0000 0000 0000 0000 0000 0000 0000 부터 1111 1111 1111 1111 1111 1111 1111 1111까지 표현할 수 있다.
경우의 수를 따져보면 $2^{32}$가지를 표현할 수 있는 것이다.
그렇다면 64비트는? $2^{64}$가지 경우의 수를 표현할 수 있다.
그래서 주소값의 개수는 위의 경우의 수를 말하는 것이다.
이제 메모리의 크기를 계산해보자.
메모리 한 칸은 1바이트(Byte)의 크기를 가지고 있다.
이 메모리 한 칸을 표현할 수 있는 경우의 수가 32비트의 경우에는 $2^{32}$가지이다.
$2^{32}$바이트, 즉 4GB($2^2 * 2^{10} * 2^{10} * 2^{10}$)의 메모리 크기까지 주소값을 가리킬 수 있는 것이다.
그래서 32비트 운영체제에서는 물리적인 메모리는 4GB까지만 인식할 수 있다.
실제로 메모리를 8GB를 꽂았는데 4GB만 인식되는 일을 겪어보신 분은 근래 들어서 얼마 안될 것이라고 본다.
주소값에 사용되는 비트 수와 메모리 크기에 대한 이야기를 쓰다보니 글이 길어졌다.
다시 본론으로 돌아와서, 프로그래머의 입장에서 주소값의 범위가 넓으면 넓을수록 좋은 상황이 된다.
위에서 설명했듯이 주소값이 커진다는 것은 사용할 수 있는 메모리의 크기가 늘어나기 때문이다.
즉, 사용가능한 자원이 늘어나기도 하고 연산 속도도 빨라지기 때문에 당연히 주소값이 큰 것이 좋은 것이 된다.
앞에서 이야기했던 32비트 컴퓨터에서는 32비트의 주소값의 이동과 연산을 한 번에 처리가 가능하다.
그럼 64비트 컴퓨터에서는? 마찬가지로 64비트의 주소값의 이동과 연산을 한 번에 처리할 수 있는 것이다.
[프로그램 구현 관점에서의 WIN32 vs WIN64]
읽고 있는 책에서도 그랬지만, 내가 대학을 다니던 시점에도 32비트에서 64비트의 과도기였다.
당시 프로그래밍을 배우면서 32비트 환경이랑 64비트 환경에서는 큰 차이가 있을거라는 환상이 있었다.
근데 그런건 하나도 없었다.
이 책에서도 마찬가지로 64비트 기반의 프로그래밍 스타일은 큰 차이가 없다는 것이었다.
마이크로소프트에서도 64비트 기반의 운영체제에서도 32비트 시스템과의 호환성을 중시했기 때문이다.
[LLP64 vs LP64]
지금까지 프로그래밍 언어를 배우면서 int와 long은 4바이트로 알고 있다.
그리고 포인터는 32비트에서는 4바이트, 64비트에서는 8바이트로 알고 있고.
포인터는 사용하는 시스템에 따라서 달라질 수 있지만, 기본 자료형은 그대로 유지가 된다.
Windows에서는 LLP64라는 데이터 표현 모델을 따르기 때문이다.
실제로 포인터를 제외한 이전 32비트의 기본 자료형의 크기는 그대로 유지하고 있다.
포인터만 주소값의 크기에 맞춰서 8바이트로 바뀐 것말고는 없다.
그리고 LP64는 UNIX 기반의 시스템에서 사용하는 데이터 표현 모델이다.
long을 8바이트로 표현한다는 것 정도만 알아두면 좋다.
[64비트와 32비트 공존의 문제점]
#include <stdio.h>
int main()
{
int arr[10] = { 0, };
int arrVal = (int)arr; // 64비트 환경이라면 데이터 손실
printf("Pointer: %d\n", arrVal);
return 0;
}
엄청 큰 의미가 있는 코드는 아니고 책에 실려있는 코드를 그대로 가져왔다.
실제로도 큰 의미가 있는 코드가 아니다.
arr이라는 배열의 주소값을 강제로 int로 형변환해서 arrVal에 저장하고 이를 출력하는 예제다.
과거 32비트 환경에서 이 코드를 실행했으면 큰 문제가 없다.
포인터도 4바이트, int형도 4바이트였기 때문에 데이터의 손실이 발생하지 않기 때문이다.
하지만 64비트 환경에서 이를 수행하게 되면 포인터는 8바이트, int형은 4바이트가 되므로 4바이트의 손실이 생긴다.
정말로 운이 좋게 배열이 4GB 이하의 메모리 영역에 할당되었다고 치자.
그러면 4바이트로 주소값 표현이 가능한 영역에 주소값이 할당되므로 손실이 안생길 수도 있다.
하지만 메모리의 어떤 영역에 주소값이 할당될지는 운영체제가 결정하는 일이므로 십중팔구는 손실이 난다고 보면 된다.
그래서 64비트와 32비트가 공존할 때에는 위와 같은 문제점이 있을 수 있다고 한 것이다.
결론만 내리면 다음과 같다.
"64비트 시스템에서는 포인터가 지니고 있는 주소값을 4바이트의 데이터형으로 형 변환하면 안된다"
[Windows 스타일 자료형]
Windows에서 정의하는 자료형은 이전 글에서도, 책을 보시는 분이라면 이전 장에서 맛을 보셨을거라 생각한다.
굳이 기본 자료형을 쓴다는 것이 나쁜 것은 아니다.
하지만 Windows 시스템 프로그래밍을 하겠다고 하면, Windows에서 제공하는 기본 자료형을 쓰는 것은 좋은 선택이다.
범용성을 생각하고 만드는 것이 아니라 동일한 Windows 운영체제 내에서만 잘 돌아가면 되기 때문이다.
자료형과 관련된 부분은 MSDN에서 제공하는 공식 문서를 찾아보는게 더 좋으니 참고하시면 됩니다.
[Polymorphic 자료형]
Polymorphic은 '다양한 모습이 있는', '다형적'이라는 뜻으로 해석되는 단어다.
실제로 JAVA나 C++을 공부하면서 객체지향의 '다형성(Polymorphism)'을 들어보신 분들이 있을 것이다.
그런데 자료형이 다형적이라는 것은 무슨 뜻일까?
클래스도 아니고 자료형이 다형적이라는 것은 상황이나 환경에 따라서 자료형이 의미하는 바가 유동적이라는 것이다.
마이크로소프트(앞으로 MS라고 하겠다)에서는 Polymorphic 자료형의 정의 형태는 아래와 같다.
#if defined (_WIN64)
typedef __int64 LONG_PTR;
typedef unsigned __int64 ULONG_PTR;
typedef __int64 INT_PTR;
typedef unsigned __int64 UINT_PTR;
#else
typedef long LONG_PTR;
typedef unsigned long ULONG_PTR;
typedef int INT_PTR;
typedef unsigned int UINT_PTR;
#endif
단순하게 PTR이 붙었다고 해서 포인터라고 생각할 수 있는데, 절대 포인터가 아니다.
그럼 왜 PTR이라는 이름을 붙였을까?
붙인 이유는 포인터 값 기반의 산술 연산을 위해 정의된 자료형이기 때문에 PTR이라는 이름을 붙인 것이다.
그리고 32비트와 64비트 시스템의 포인터 정밀도(쉽게 말하면 포인터 크기)가 다르다.
그래서 이를 해결하기 위해 만든 자료형이다.
[PolymorphicType1.cpp]
/*
* Windows System Programming - 64비트 기반 프로그래밍
* 파일명: PolymorphicType1.cpp
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-02
* 이전 버전 작성 일자:
* 버전 내용: Windows의 Polymorphic 자료형에 대한 이해
* 이전 버전 내용:
*/
#include <stdio.h>
#include <tchar.h>
#include <Windows.h>
// 메모리 공간상 거리 차이를 계산하기 위한 함수 (x64, 64비트)
UINT64 CalcDistance(UINT64 a, UINT64 b)
{
return b - a;
}
// 메모리 공간상 거리 차이를 계산하기 위한 함수 (x86, 32비트)
UINT CalcDistance(UINT a, UINT b)
{
return a - b;
}
int _tmain()
{
INT val1 = 10;
INT val2 = 20;
// x86, 32비트 환경
_tprintf(_T("Position %u, %u \n"), (UINT)&val1, (UINT)&val2);
_tprintf(_T("Distance: %u \n"), CalcDistance((UINT)&val1, (UINT)&val2));
// x64, 64비트 환경
_tprintf(_T("Position %llu, %llu \n"), (UINT64)&val1, (UINT64)&val2);
_tprintf(_T("Distance: %llu \n"), CalcDistance((UINT64)&val1, (UINT64)&val2));
return 0;
}
위의 예제는 val1과 val2의 주소값을 강제로 형변환하여 출력하고 거리의 차를 계산하는 예제다.
32비트 환경 기준으로는 UINT64를 UINT로 바꿔서 실행하면 된다.
64비트에서의 실행 결과는 32, 32비트 환경이라면 12가 나온다.
이보다 중요한 것은 CalcDistance 함수다.
보다시피 32비트와 64비트 환경에 맞춰서 따로 구현하게 되었다.
C++에서는 함수 오버로딩이 가능하므로 위와 같이 구성하는 것이 가능하지만, 별로 좋은 선택지는 아니다.
그렇다고 조건부 컴파일을 통해서 해결한다면?
분명히 이것도 방법 중 하나지만 함수 오버로딩과 마찬가지로 그렇게 좋은 방법은 아니다.
그런데 이걸 32비트나 64비트 환경을 가리지 않고 해결할 방법이 있다.
그게 아까 위에서 보인 Polymorphic 자료형을 사용하는 것이다.
[PolymorphicType2.cpp]
/*
* Windows System Programming - 64비트 기반 프로그래밍
* 파일명: PolymorphicType2.cpp
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-02
* 이전 버전 작성 일자:
* 버전 내용: Windows의 Polymorphic 자료형에 대한 이해
* 이전 버전 내용:
*/
#include <stdio.h>
#include <tchar.h>
#include <Windows.h>
// 메모리 공간상 거리 차이를 계산하기 위한 함수 (x64, 64비트)
UINT_PTR CalcDistance(UINT_PTR a, UINT_PTR b)
{
// 32비트라면
//return a-b;
return b - a;
}
int _tmain()
{
INT val1 = 10;
INT val2 = 20;
_tprintf(_T("Distance: %u \n"), CalcDistance((UINT_PTR)&val1, (UINT_PTR)&val2));
return 0;
}
UINT_PTR은 Polymorphic 자료형이다.
그래서 64비트 환경에서는 64비트 환경에 맞는, 32비트 환경에서는 32비트에 맞는 자료형이 선언된다.
다시 말해서 호환성을 높이기 위한 방법으로 Polymorphic 자료형이 사용된 것이다.
앞으로 이런 식으로 자료형이 선언된 것을 보면 다음과 같이 생각하면 된다.
"32비트 시스템에서는 32비트로, 64비트 시스템에서는 64비트로 선언된다."
[오류의 확인]
프로그램을 만들다보면 항상 오류를 접하기 마련이다.
오류가 생기는 것은 당연한 일이다.
그보다 더 중요한 것은 오류가 생긴 원인을 파악하고 오류를 해결하는 것이다.
[GetLastError 함수와 에러코드]
Windows 시스템 함수를 호출하는 과정에서 오류가 발생하면 GetLastError 함수를 통해 원인을 확인할 수 있다.
많은 수의 Windows 시스템 함수는 오류 발생시 NULL을 반환한다.
NULL을 반환해서 오류가 발생한 사실은 알게 되지만, 원인은 모를 수 있다.
이 때 바로 GetLastError 함소를 호출하면 오류의 원인에 해당하는 에러코드를 확인할 수 있다.
MSDN에서는 시스템 에러코드의 종류와 해당 에러코드가 무엇을 의미하는지 확인할 수 있다.
https://learn.microsoft.com/en-us/windows/win32/debug/system-error-codes
에러 코드의 수만 무려 16000가지다.
에러가 발생했다면 여기서 해당 코드를 찾아서 어떤 종류의 에러인지를 확인하면 된다.
[GetLastError.cpp]
/*
* Windows System Programming - 64비트 기반 프로그래밍
* 파일명: GetLastError.cpp
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-02
* 이전 버전 작성 일자:
* 버전 내용: GetLastError 함수 사용 예시(1)
* 이전 버전 내용:
*/
#include <stdio.h>
#include <tchar.h>
#include <Windows.h>
int _tmain()
{
HANDLE hFile = CreateFile(_T("ABC.DAT"), GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
if (hFile == INVALID_HANDLE_VALUE)
{
_tprintf(_T("Error code: %d\n"), GetLastError());
return 0;
}
return 0;
}
여기서 HANDLE로 시작하는 이 부분은 C에서 파일 입출력과 마찬가지로 파일을 개방하는 부분이다.
CreateFile 함수는 C에서의 fopen을 생각하면 될 것 같다.
자세한 사용법은 아마 나중에 알게 될 것이니 지금은 연연할 필요는 없을 것 같다.
일단 하나 알아야 할 것은, fopen에서 파일을 읽어들이지 못하면 NULL을 반환한다.
마찬가지로 CreateFile 함수는 에러가 발생하면 INVALID_HANDLE_VALUE라는 것을 반환한다는 사실이다.
그리고 에러가 발생하면 GetLastError 함수 호출을 통해 에러 코드를 확인한다.
아마 실행을 하면 결과로 2가 나온다.
MSDN에서 검색하면 2는 FILE_NOT_FOUND로는 파일을 찾지 못했다는 뜻이다.
[ErrorStateChange.cpp]
/*
* Windows System Programming - 64비트 기반 프로그래밍
* 파일명: ErrorStateChange.cpp
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-02
* 이전 버전 작성 일자:
* 버전 내용: GetLastError 함수 사용 예시(2)
* 이전 버전 내용:
*/
#include <stdio.h>
#include <tchar.h>
#include <Windows.h>
int _tmain()
{
// 파일을 개방
HANDLE hFile = CreateFile(_T("ABC.DAT"), GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
_tprintf(_T("Error code: %d\n"), GetLastError());
// 파일을 새로 생성
hFile = CreateFile(_T("ABC2.DAT"), GENERIC_WRITE, FILE_SHARE_READ, NULL, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, NULL);
_tprintf(_T("Error code: %d\n"), GetLastError());
return 0;
}
이전 예제를 살짝 바꾼 예시.
마찬가지로 파일을 개방하는 부분에서는 똑같이 에러코드 2가 나온다.
그런데 주목할 점은 그 다음에 있는 파일을 새로 생성하는 부분이다.
여기서는 에러코드가 0으로 나온다.
에러코드 0은 ERROR_SUCCESS로 아무런 문제 없이 잘 수행되었다는 말이다.
결과를 확인했다면 다시 실행했을때, 에러코드 0이 아니라 80이 나온다.
80은 ERROR_FILE_EXISTS로 이미 파일이 존재하는 상황에서 나오는 에러코드다.
위 예제들을 통해서 GetLastError 함수를 사용하는 법을 확인할 수 있었다.
오류 발생시 GetLastError 함수 호출을 통해 오류코드를 확보할 수 있다는 것이다.