16비트 시절에는 잘 알다시피 int 포함 machine word의 크기가 말 그대로 2바이트였다. 그리고 근거리/원거리 포인터가 존재했으며 복잡한 메모리 모델을 따져야 했다. 여기까지는 도스든 윈도든 공통이다. 그럼, 그 시절에 Windows 프로그래밍은 어떠했을까?
16비트 Windows는 잘 알다시피 모든 프로그램이 단일 스레드를 공유하면서 단일 메모리 주소 공간에서 실행되었다. 이런 열악한 환경에서 멀티태스킹을 구현하는 것은 결코 쉬운 일이 아니었다.
그래서 그 시절에는 콜백 함수를 운영체제에다 지정해 주는 것조차도 보통일이 아니었다. 오늘은 오랜만에 이 부분에 대해서 예전에 몰랐다가 최근에 본인이 추가로 알게 된 이야기를 좀 하겠다.
Windows API는 C언어 함수 형태로 만들어졌으니 그때는 지금으로 치면 C++ 가상 함수가 할 일을 함수 포인터로 다 처리하고 있었다. 윈도우 프로시저, 대화상자 프로시저 같은 것 말이다.
그런데 같은 프로그램이 두 번 중첩 실행되면, 코드는 동일하더라도 그 코드가 다루는 스택(데이터) context는 결국 프로세스별로 서로 달라질 수 있게 된다. 이를 어떻게 구분해야 할까?
32비트 이후부터는 가상 메모리 기술이 워낙 발달하여 그 context가 CPU 차원에서 알아서 따로 잘 처리된다. 그러나 16비트 기계엔 그런 개념이 없었다.
결국은, 실제 콜백 함수가 호출되기 전에 그 콜백 함수가 처리할 자신의 데이터 세그먼트 영역을 CPU 레지스터에다 써 주는 stub, 일명 썽킹(thunking) 코드를 소프트웨어적으로 별도로 만들어야 했다. C++로 치면, 클래스 멤버 함수 호출을 위해 this 포인터의 값을 EAX 레지스터에다 써 주는 것과 개념적으로 비슷하다.
우리가 만든 콜백 함수는 그 썽킹 함수를 통해서 호출해야 한 것이다. 운영체제에다가도 당연히 썽킹 함수를 알려 주고 말이다.
이것이 바로 16비트 시절 코드를 보면 밥 먹듯이 보이는 MakeProcInstance와 FreeProcInstance API 함수의 정체이다.
별도의 메모리를 추가로 할당해서 기계어 코드를 추가해 준 것이기 때문에, 메모리의 해제까지 해 줘야 한다.
대화상자 하나를 출력하더라도 아래처럼 말이다.
FARPROC pfnThunkProc = MakeProcInstance( (FARPROC)My_Actual_Dialog_Proc, hInstance); //내 대화상자 프로시저가 우리 인스턴스 핸들을 context로 동작하게 한다
DialogBox(hInstance, "Dialog_Resource_Name", hWndParent, (DLGPROC)pfnThunkProc );
FreeProcInstance(pfnThunkProc);
요즘 같으면 아래의 절차 하나만으로도 충분했을 텐데 말이다.
DialogBox(hInstance, "Dialog_Resource_Name", hWndParent, My_Actual_Dialog_Proc );
결국 My_Actual_Dialog_Proc라는 코드는 시스템 전체를 통틀어서 단 하나 유일하게 존재하겠지만,
이 프로그램을 여러 번 실행하더라도 각 프로그램들은 그 함수로부터 파생된 자신만의 고유한 콜백 함수를 통해서 대화상자를 호출하게 된다.
단, Windows 3.1에서는 함수명을 export해 주는 것만으로 이렇게 콜백 함수에 대한 썽킹을 매번 해 줘야 하는 번거로움을 생략할 수 있었다고 한다.
지금이야 프로그래밍에서 export 키워드라 하면, C++에서 템플릿의 선언과 정의를 번역 단위를 넘나들며 공유할 수 있게 하려 했던 비현실적인 흑역사 키워드일 뿐이다. Windows 플랫폼으로 문맥을 넓힐 경우, export는 클래스나 함수 심벌을 빌드되는 파일 차원에서 외부로 노출하여 GetProcAddress 함수로 노출이 되게 하는 속성 지정자이다. 비주얼 C++ 기준으로 __declspec(dllexport) 라고 쓴다.
지금이야 이런 export 속성은 말 그대로 DLL을 만들 때에나 쓰인다.
그러나 16비트 시절에는 EXE도 운영체제로부터 호출을 받아야 하는 콜백 함수를 넘겨 줄 목적으로 심벌 export를 즐겨 활용했었다.
export된 함수는 외부로부터 호출받는 게 당연시될 거라 여겨졌으므로, 링커와 운영체제가 자체적으로 데이터 세그먼트 보정을 하는 전처리 루틴을 추가해 줬다. 따라서 코드가 훨씬 더 깔끔해진다.
이런 이유로 인해 Windows 3.x 실행 파일들을 헥스 에디터로 들여다보면 대체로 ...WNDPROC, ...DLGPROC 같은 식으로 끝나는 웬 이상한 함수 이름들이 내부에 들어있는 걸 확인할 수 있다. 32비트 이후부터는 찾을 수 없는 관행이다. 자기 내부에서만 쓰는 콜백 함수들을 왜 저렇게 노출해 놓겠는가?
어떤 함수에 export 속성을 부여하기 위해서는 결국 C/C++ 언어에다가 없는 문법을 새로 추가하거나, 아니면 소스 코드는 그대로 놔 두고 컴파일러가 아닌 링커만이 인식하는 부가 정보 같은 게 주어져야 할 것이다. 전자는 소스 코드의 이식성을 떨어뜨린다는 부담이 있지만, 그래도 뒤끝이 없고 한 소스에 빌드에 필요한 모든 정보가 한데 모이니 편리하다. 그래서 그 당시에는 __export라는 비표준 MS 확장 키워드가 도입되었고, 이를 보통 EXPORT라는 매크로로 감싸서 사용하곤 했다. 이런 식으로 말이다.
long FAR PASCAL EXPORT MyWindowProc(HWND hWnd, UINT nMsg, WPARAM wParam, LPARAM lParam); //그 당시 콜백 함수는 반드시 '파스칼' 방식이라는 함수 호출 규약대로 선언되어야만 했다.
한편 후자는 바로 그 이름도 유명한 DEF(모듈 정의) 파일이다.
지금도 DEF 파일이 안 쓰이는 건 아니지만 정말 DLL을 만들 때가 고작이다. 그 반면 16비트 시절에는 DEF가 훨씬 더 중요하게 쓰였던 모양이다.
export하는 콜백 함수뿐만 아니라 이 프로그램에 대한 소개문, 그리고 필요한 스택/힙 크기까지 정확하게 명시되어야 했다.
32비트 이후부터는 스택/힙이 부족하면, 프로그램 로딩 시간이 좀 더 걸릴 뿐이지 실시간으로 추가 할당하여 working set의 확장이 가능한 반면, 16비트 시절에는 메모리 양도 부족하고 또 한 주소 공간에서 여러 프로그램들이 뽁짝뽁짝 복잡하게 지내다 보니 이런 게 핀트가 어긋나면 프로그램 실행이 아예 불가능했을 것 같다.
옛날의 New Executable 포맷에는 자체적으로 name table이라 불리는 범용적인 문자열 테이블이 있었고, import/export하는 심벌 이름은 물론 이 EXE/DLL에 대한 간단한 소개문까지 그런 데에 들어갈 수 있었다.
지금으로 치면 버전 리소스에 들어가는 description과 유사한데 역할 중복인 셈이다. 그런 것까지 DEF 파일에다 명시해 줬었다.
자.. 설명을 들으니 어떤 생각이 드시는지?
16비트 Windows용 실행 파일을 분석하는 도구는 그리 충분치 않다. 일단 개발자들의 친구인 Dependency Walker가 이를 전혀 지원하지 않으며, 그나마 괜찮은 물건으로는 옛날에 Windows 98에 내장되어 있던 QuickView라는 프로그램이 적격이었다. 기본적인 사항은 다 잘 출력해 줬다.
16비트 시절에는 잘 알다시피 HINSTANCE는 각 프로그램의 데이터 세그먼트를 식별하는 번호표에 가까웠고, 개념적으로 지금 같은 포인터가 아니라 오늘날로 치면 오히려 프로세스를 식별하는 HANDLE처럼 널리 쓰였다. (비록 Close를 할 일은 없었겠지만 말이다) 게다가 EXE는 HINSTANCE, DLL은 HMODULE로 성격도 달랐다. 그래서 LoadLibrary 함수의 리턴값은 분명 HMODULE이다.
그때는 EXE/DLL로부터 리소스를 얻어 오는 과정도 정말 복잡했고 Load/Free 절차뿐만 아니라 lock/unlock까지 있었다. 메모리의 절대적인 양이 충분치 않기도 하고 가상 메모리 같은 시스템이 없던 관계로, 단편화를 방지하기 위해 평소에 즉각 쓰지 않는 메모리 블록은 재배치가 가능하게 놔 두는 관행이 더 보편적이었기 때문이다.
한편으로 그때는 시스템 전체에 영향을 끼치기가 쉬웠다. 특히 DLL이 이 분야에 관한 한 지존이었다.
DLL에서 선언한 전역변수는 모든 프로세스가 한데 공유되었으며, DLL이 실행하는 코드는 저런 EXE처럼 여러 컨텍스트에서 중첩 실행될 일이 없고 애초에 데이터 세그먼트 구분을 할 필요가 없었다!
그리고 16비트 시절엔 애초에 콜백 함수를 지정해 주는 과정이 처음부터 저렇게 번거로웠던 만큼, 시스템 전체의 동작 방식을 바꾸는 global 훅을 설치하더라도 훅 프로시저를 반드시 DLL에다가만 만들어야 한다는 제약이 없었다.
그럼에도 불구하고 16비트 시절은 32비트 시절보다 편한 것보다는 불편한게 더 많았다는 것이 부인할 수 없는 사실이다.
끝으로 여담.
이렇듯, 본인은 초등학교 아주 어릴 적부터 프로그래밍을 시작한 관계로, 그 시절에 대한 향수가 있고 '레트로'-_- 컴퓨팅 쪽으로 관심이 많다.
그런데, 그런 것에서조차도 양덕후들의 기상은 갑이다. the old new things 같은 블로그는 이 바닥의 지존이라고 봐야 하고, Windows 3.x로도 모자라서 2.x나 1.x용 프로그램을 만들었다고 인증샷 올리는 사람도 있다.
1985년에 출시된 Windows 1.x의 경우, VGA 카드조차도 없던 시절에 만들어진 관계로 가장 좋은 비디오 모드가 640*350 해상도짜리 EGA이다. 그런데 그걸 640*480 VGA 내지 심지어 800*600 SVGA에서 동작하게 드라이버 패치를 만든 사람도 있다.
참고로 Windows 1.x 정도 되면.. 프로그램 개발을 위한 컴파일러는 그야말로 1986년에 나온 MS C 4.x를 써야 하고, 얘는 함수 호출 인자도 ANSI 스타일이 아니라 K&R 스타일만 써야 하는 진짜 무지막지한 골동품이라고 한다. Windows 1.x는 파스칼로 개발되었다고 들었는데 그래도 그때부터 C 형태의 SDK는 있었던 모양이다.
Posted by 사무엘