« Previous : 1 : ... 9 : 10 : 11 : 12 : 13 : Next »

스레드 동기화


컴퓨터가 충분히 발전하여 오늘날의 운영체제 시스템 개념을 실현하게 된 것은 중앙 처리 장치가 최소한 32비트로 커지고부터이다. 보호 모드, 가상 메모리, 스레드 같은 것들. 4GB 정도는 돼야 주소 공간이 아쉬운 대로 넓다고 말할 수 있기 때문이다. 아무리 메모리 부족하고 열악한 임베디드 기기라 해도, 일단 최소한 디스플레이가 있는 general-purposed 컴퓨터라면, 16비트 시절의 원거리/근거리 포인터 같은 기법을 재도입할 정도로 열악하지는 않다.

물리 메모리와 가상 메모리의 대응을 운영체제가 CPU 차원의 지원으로 즉각 수행할 수 있게 되고 시분할 동시작업도 하드웨어 차원에서 할 수 있게 된 것은 소프트웨어 개발자에게 매우 큰 편의와 잠재성을 제공하게 되었다. 이를 입증하는 윈도우 3.1과 95의 큰 차이는 마우스 포인터 모양만 봐도 알 수 있다. 옛날에는 프로그램을 로딩하고 있을 때는 마우스 포인터가 완전 모래시계로 바뀌고 시스템 전체가 응답 불가 상태였지만, 지금은 화살표 오른쪽에 작은 모래시계가 붙고 시스템은 여전히 사용 가능하다(물론 좀 느려지기는 하지만). 또한, 모래시계 포인터는 해당 프로그램 안에서만 모래시계이지 다른 응용 프로그램에서는 여전히 화살표이다. 이른바 선점형 멀티태스킹 덕분이다.

단일 스레드 환경에서 사용자의 입력도 받으면서 작업도 동시에 수행하려면 타이머로 정말 찔끔찔끔 작업을 하거나, 작업 루틴 내부에서 수시로 입력을 체크(윈도우 API로는 PeekMessage 같은)해 주어야 했다. 효율은 효율대로 떨어지고 코드는 더욱 복잡해질 수밖에 없었다. 멀티스레드에서는 그런 번거로운 짓을 할 필요가 없다. 하지만 단일 스레드에서 전혀 신경쓸 필요가 없던 또다른 걱정거리가 생겼는데, 그것은 바로 스레드 동기화이다.

실행되고 있는 여러 스레드에 CPU 시간이 어떻게 분배되는지는 정말 전혀 예측할 수 없다. 심지어 a++; 같은 간단한 문장도 기계어로는 두세 개로 번역되는데 이 명령을 수행하던 도중에 스레드의 context가 바뀔 수가 있다. 여러 스레드가 동일한 데이터에 동시에 접근하여 값을 읽고 쓰다 보면, 다른 스레드에 의해 계산 전 값이 덮어써지거나, 아직 처리가 덜 끝난 중간 상태가 다른 스레드에 의해 뒤엉켜 버릴 수가 있다.

쉽게 말해서 a=0이고 a의 값을 1 증가시키는 스레드를 100개를 만들어서 아무 통제도 없이 이들을 동시에 실행시켰다고 가정해 보자. 그렇다면 스레드 실행이 모두 끝난 후 a의 값이 100이 되어 있을까? 천만의 말씀이다. 한 스레드가 a의 값이 0임을 인지만 한 찰나에 다른 스레드로 context가 넘어가고, 그 스레드 역시 a의 값을 0으로 인식하게 된다. 그러면 두 스레드는 모두 1이라는 값을 기록하게 되고.. 그렇다면 a는 도저히 100이 될 수가 없어짐이 명확하다. a++ 같은 지극히 단순한 연산이 이러한데 하물며 링크드 리스트나 트리 같은 복잡한 자료 구조를 변형하는 루틴에 여러 스레드가 동시에 접근한다면 어떤 재앙이 벌어질까?

멀티스레드 환경에서는 필연적으로 한 데이터를 여러 스레드가 공유하게 된다. 문서를 입력 받는 GUI 스레드와, 내부적으로 문서의 맞춤법 검사를 하는 스레드. 작업을 진행하는 스레드와 그로부터 작업 상황을 출력하는 스레드 등. 컴퓨터는 사용자가 프로그램을 짜 준 작업 중 어느 구간이 반드시 원자성이 보장되어야 하는지, 어느 구간이 반드시 여러 스레드들이 순차적으로 실행되어야 하는지를 알지 못한다. 이는 사용자가 지정해 주어야 하며, 운영체제는 스레드 동기화를 위한 여러 기법들을 제공하고 있다.

아무 통제 없는 무법천지인 멀티스레드 환경만으로는 프로그래머가 뭔가 의미 있는 작업을 하는 게 불가능하기 때문이다. 흔히 멀티스레드 환경을 여러 사람이 사용하는 한 화장실에다 비유하는데 무척 재미있고 적절한 비유 같다. 사람은 바글바글한데 화장실 문을 잠글 수 없다면 누가 감히 용변을 볼 수 있을까?

먼저, 여러 스레드들이 공유하여 시시때때로 값을 바꿀 수 있는 변수라면 C/C++의 경우 volatile로 선언하는 것이 필수이다. 고급 언어로 한 메모리 영역으로 표현되는 변수라 할지라도, 기계상으로는 메모리와 레지스터라는 차이가 존재할 수 있기 때문이다. 아울러 멀티스레드를 고려하다 보면 프로그램 모듈간의 독립성도 결국 고려하지 않을 수 없게 된다. 당장 쓰기 쉽다고 습관적으로 남발하던 전역/static 변수들은 멀티스레드 환경에서는 재앙으로 돌아올 가능성이 높다.

윈도우 운영체제는 정수 변수값을 1 증가하거나 감소시키는 것을 원자성이 보장되게 수행해 주는 InterlockedIncrement/Decrement라는 함수를 제공한다. 앞서 말했듯이 심지어 a++; 같은 간단한 연산조차도 컴퓨터에서는 CPU 한 사이클로 해결되지 않으며, 연산 수행 도중에 스레드 context가 바뀌고 결과가 꼬일 수 있다. 여러 스레드가 동시 접근할 수 있는 COM 오브젝트를 만든다면 reference count를 관리하는 함수를 이 함수를 이용해서 만들면 될 것이다. 고작 숫자를 1 더하고 빼는 것밖에 없지만 이것이 운영체제가 제공하는 가장 간단한 형태의 스레드 동기화 기능이라고 볼 수 있다.

이것보다 좀더 복잡하고 임의적인 루틴에 동기화 제동을 걸고 싶다면 임계 구간(critical section)이 좋은 선택이 될 수 있다. 크리티컬 섹션 오브젝트를 전역 변수로 선언하여 초기화해 준 후, 한 번에 한 스레드만이 차례대로 접근해야 하는 구간의 앞에 EnterCriticalSection을 해 주고, 끝에 LeaveCriticalSection을 해 주면 된다. 방법도 쉽다. 아까의 Interlocked* 함수는 크리티컬 섹션이 가미된 a++ 연산이라고 보면 된다.

이 정도면 다 된 것 같은데 동기화와 관련해서 또 필요한 게 있을까? 크리티컬 섹션은 간단하고 쓰기 쉽고 성능도 매우 좋은 반면, 타 스레드가 임계 구간을 빠져나올 기미가 안 보이더라도 한없이 기다리는 수밖에 없다. 또한 동일한 코드에 대한 스레드 접근만 제어할 수 있지, 다른 스레드(심지어 다른 프로세스)가 임의의 다른 작업을 끝낼 때까지 나의 스레드 실행을 멈추는 식으로 제어를 할 수는 없다.

이쯤 되면 그림이 나온다. 스레드 동기화 기능들을 수행하는 중요한 역할 중 하나는 busy waiting을 없애는 것이다. XT, 286 시절에는 컴퓨터 실행을 좀 느리게 하기 위해서 for i=1 to 5000: next 같은 문장을 썼지만 지금은 그건 큰일 날 짓이다. 자발적으로 일정 시간 CPU 시간을 내어 주고 프로그램 실행을 멈추는 Sleep 같은 운영체제 함수를 써야 한다. 어떤 스레드가 작업을 끝낼 때까지 쉬는 것도, while(bProcessing); 이런 식으로 수도 없이 뺑뺑이를 도는 polling은 절대 피해야 한다.

그렇기 때문에 일정 조건이 만족될 때까지 스레드를 CPU의 사용 없이 자동으로 기다리게 하는 함수가 있으며 이와 관련된 스레드 동기화 오브젝트들이 커널 차원에서 제공된다. “어떤 스레드가 임계 구간을 빠져나갈 때까지”란, 그 조건의 subset일 뿐인 것이다.

커널 오브젝트는 단순 크리티컬 섹션보다는 느리다. 이를 사용하기 위해서는 user 모드와 kernel 모드 사이의 전환 overhead를 감수해야 한다. 하지만 이들은 쓰임이 훨씬 더 범용적이며, 단순한 변수가 아니라 핸들과 이름으로 식별하기 때문에 inter-process하며 시스템 전체에서 통용이 가능하다. (이런 커널 오브젝트의 존재 여부로 프로그램의 중복 실행을 감지할 수도 있을 정도이다.) 크리티컬 섹션은 스레드의 waiting이 아주 implicit하게 일어나지만, 커널 오브젝트는 이를 WaitForSingleObject 같은 함수로 explicit하게 지정 가능하며 기다리는 한도를 제어할 수 있다. 또한 다른 커널 오브젝트들과 덩달아 함께 기다리게 할 수도 있다.

뮤텍스는 크리티컬 섹션의 커널 오브젝트 버전에 가깝고, 세마포어는 임계 구간에 들어갈 수 있는 스레드의 최대 개수를 지정할 수 있어서 뮤텍스보다 더욱 범용적이다.
한편 이벤트는 굳이 임계 구간이 아니더라도 사용자가 임의의 신호를 날려서, 그 신호를 기다리며 잠들어 있던 스레드를 깨울 수 있게 한 원초적이고 general-purpose한 동기화 수단이다.

결국 스레드 동기화 수단이 여럿 등장했는데, 구현 형태는 달라도 결국 여기에 적용될 수 있는 동사는 딱 둘.. ‘잠그기, 풀기’로 요약된다. 그래서 MFC는 이런 것들을 CSyncObject라는 클래스로 요약하여, Lock과 Unlock이라는 가상 함수로 공통 기능을 추상화해 놓고 있다.

참고로 이런 것뿐만 아니라 프로세스, 파일, 스레드 핸들 그 자체 같은 다른 커널 오브젝트들도 동기화 오브젝트에 해당하는 것이 여럿 있다. 그래서 해당 프로세스의 실행이 끝날 때까지, 혹은 스레드의 실행이 끝나고 파일 트랜잭션이 끝날 때까지 프로그램을 기다리게 하는 게 가능하다.

Posted by 사무엘

2010/01/11 09:52 2010/01/11 09:52
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/74

우리는 C/C++ 언어에서 포인터란, 정수와 비슷한 형태이긴 하나 일반적인 숫자가 아니라 메모리 주소를 가리키는 특수한 자료형이라고 배운다. 포인터는 하드웨어 친화적이고(기계 입장에서 아주 직관적임) 매우 강력한 프로그래밍 요소이지만, 그만큼 길들여지지 않은 야생마처럼 위험 부담이 크다. 포인터 자체하고 포인터가 가리키는 메모리 영역이 따로 놀 가능성이 언제든지 있기 때문에 memory leak, dangling pointer 같은 위험에 노출되기가 매우 쉽다. 자유라는 약이 무질서라는 독으로 변질될 여지가 큰 것이다.

포인터의 값은 다른 지역/전역 변수의 주소로부터 얻어지거나 아니면 메모리 할당 함수를 통해 생성된다. 우리가 직접 상수 값을 대입하는 경우는 거의 없으며, 두 포인터의 값의 차이를 상대적으로 비교하는 경우는 있을지언정, 포인터가 나타내는 그 숫자 자체는 프로그래머에게 사실상 아무 의미가 없다.

사실 이 숫자가 무엇을 의미하는지는 C/C++ 언어의 영역이 아니라 그 언어를 돌리는 플랫폼 내지 운영체제의 영역으로 넘어가게 된다. 포인터란 그만큼 저수준인 존재이며, C++ 이후에 등장한 더 진보한 언어들은 포인터를 더 다루기 편하고 덜 위험한 형태로 어떻게든 감싸고 포장하고 더 상위 계층을 만들려고 애쓰고 있다.

우선 0은 NULL 값으로 유명하며, 윈도우 운영체제의 경우 0뿐만이 아니라 그 이후 0xFFFF 번지까지가 모두 오류로 간주된다. 즉, 이 영역은 운영체제가 어떤 경우에도 메모리를 가리키는 주소로 절대 인정하지 않으며, 오히려 응용 프로그램이 일반 숫자를 포인터로 착각하고 잘못 사용한 것으로 간주하여 무조건 오류를 일으켜 주겠다는 뜻이다. 이런 정책은 사실 프로그래머에게 굉장히 유용하다. 16비트 범위 안에 드는 작은 숫자는 메모리 주소보다는 인덱스 번호 같은 일반 숫자의 의미를 갖는 경우가 훨씬 더 많기 때문이다.

그 후 32비트 윈도우를 기준으로, 64KB부터 2GB까지는 응용 프로그램이 마음대로 사용할 수 있는 “사용자 영역”이다. 이 공간에 나의 EXE, DLL들이 로딩도 되고, 스택/힙 같은 메모리 공간이 할당되고 모든 일이 일어난다. 한 프로세스는 다른 프로세스의 이 영역을 넘볼 수 없다.

단 여기서 잠깐 예외가 있는데, 윈도우 9x는 앞부분의 64KB부터 4MB까지가 또 도스 및 16비트 윈도우 프로그램과의 호환성 유지를 위한 고정된 공용 주소로 예약이 되어 있다. 이런 이유로 인해 윈도우 9x는 4MB에 해당하는 0x400000보다 낮은 메모리 주소에다가는 32비트 바이너리를 불러올 수 없다. EXE/DLL의 preferred base가 이보다 더 낮은 주소인 데다가 재배치 정보까지 들어있지 않다면 그 바이너리는 윈도우 2000/XP 이상에서는 실행 가능하지만, 9x에서는 실행할 수 없게 된다. 비주얼 C++을 보면 EXE의 디폴트 기준 주소가 0x400000으로 잡혀 있는데, 이것은 윈도우 9x와의 호환성을 고려한 귀결이다.

NT급 윈도우는 0x80000000부터 커널이 사용하는 메모리 영역이 시작된다. 쉽게 말해 32비트 포인터로 가리킬 수 있는 4GB의 영역을 응용 프로그램이 2GB, 커널이 2GB로 반반씩 나눠 쓴다는 뜻이다. 물론 여기 부근에도 NT 계열과 9x 계열 윈도우는 메모리를 사용하는 방법이 대동소이한 차이가 존재한다.

NT급 윈도우에는 0x80000000 이전의 64KB 공간을 또 떼어서, 프로그래밍의 편의상 무조건 사용하지 않고 여기에 접근하는 것을 에러로 간주하는 영역을 또 두고 있다. 0~0xFFFF의 용도와 마찬가지이며, 말 그대로 사용자 영역과 커널 영역 사이에 안전을 위해 마련해 놓은 "메모리의 비무장 지대"인 셈이다.

한편 9x 계열은 그런 것은 존재하지 않는다. 대신 0x80000000부터 0xC0000000 사이의 1GB를 “공유 메모리 전용 영역”으로 지정하여, 일부 운영체제 커널 DLL과, 응용 프로그램들이 생성하는 ‘공유 메모리’(memory mapped file)를 이 영역에다 따로 두고 있다. 물론 NT 계열은 그런 것들도 다 구분 없이 사용자 영역에 저장된다. 실제로는 같은 물리 메모리를 가리키더라도 이를 가리키는 포인터의 값은 프로세스마다 다 다를 수 있다는 것이다. 이런 이유로 인해, 공유 메모리를 생성해 보면 9x 계열은 메모리 위치가 0x80000000을 상회하는 높은 주소인 반면, NT 계열은 심지어 자기 EXE가 로딩된 0x400000보다도 낮은 위치에 매핑이 된 경우도 볼 수 있다.

본인 생각에, 이것은 안정성을 약간 희생하여 좀더 작고 빠른 저사양 친화형 OS를 추구한 9x 계열의 고육지책으로 보인다. 사용자 영역에는 진짜로 각 프로세스마다 따로 돌아가야 하는 메모리만 넣고, 조금이라도 프로세스들이 공유할 여지가 있는 메모리는 여기에다가 따로 옮겨 둔 것이다.
이런 구조상의 차이로 인해 윈도우 9x는 NT 계열만치 커다란 메모리 맵 파일 내지 공유 메모리를 생성할 수 없다. 모든 응용 프로그램들이 1GB짜리 공간 안에서 바둥대며 살아야 하기 때문이다. 하지만 NT 계열은 설령 공유 메모리라 할지라도 마치 자기 개인 메모리 다루듯이 얼추 2GB 안에서 자유롭게 그런 것들을 만들어 보호도 잘 받으면서 쓸 수 있다.

나머지 영역은 전부 커널이 사용한다. 프로세스, 스레드 같은 각종 커널 오브젝트를 생성하고 가상 메모리 내지 페이지 파일들을 관리하기 위한 메모리이다. 쉽게 말해 메모리를 관리하기 위한 메모리. 무슨 커널이 최하 1GB에 넉넉잡아 2GB까지씩이나 주소를 차지하고 있어야 하냐 질문할지 모르는데, 결론부터 말하면 그 정도 공간은 반드시 있어야 하며 사실 2GB조차도 부족한 감이 있다.

NT급 운영체제는 커널 영역의 주소가 사용자 응용 프로그램으로부터 완벽하게 보호받고 있으며 user가 커널 영역 메모리로 접근을 시도하면 즉시 에러가 난다. 둘 사이에 앞서 언급한 "비무장 지대"까지 존재한다. 그러나 9x 계열은 그렇지 못하다.

BYTE *pb = (BYTE *)0xC0001000;
int i;
for(i=0; i<4096; i++) {
        printf("%02X ", *pb), *pb=*pb; pb++;
}

이런 간뎅이-_-가 배 밖에 나온 코드를 실행하면 NT급에서는 당연히 즉시 Access violaton 에러가 나고 프로세스가 사살되는 반면,
9x 계열은 놀랍게도 잘 실행된다. *pb=*pb로 해 줬으니 망정이지 다 0으로 덮어쓴다거나 하면 무슨 일이 벌어질까? 9x 계열이 왜 불안정할 수밖에 없는지 답이 딱 나올 것이다.

같은 32비트 안에서 사용자:커널이 2G:2G가 아니라 사용자한테 좀더 메모리를 많이 줘서 더 대용량의 데이터를 처리할 수 있게 한 3G:1G 부팅 방식도 있긴 한다. 사실 9x 계열도 앞서 말한 구조의 차이 때문에 커널 메모리는 1G이다.
하지만 이 경우 운영체제가 관리할 수 있는 가상 메모리의 이론적 최대치가 크게 감소하고 생성 가능한 커널 오브젝트(프로세스, 스레드, 공유 메모리 등)의 수도 더 줄어든다는 것을 알아야 한다. 또한 응용 프로그램도 large-address-aware하게 빌드되었다는 별도의 플래그가 있어야 한다.

Posted by 사무엘

2010/01/11 00:43 2010/01/11 00:43
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/59

윈도우 API로 다른 프로그램을 "실행", 즉 기술적으로 말하면 프로세스를 생성할 때 쓰는 함수는 크게 다음과 같다.

WinExec, LoadModule, CreateProcess, ShellExecute

어느 것을 사용하더라도 다음과 같은 정보를 받는 란은 꼭 존재한다:
실행 파일 이름, 명령인자, 그리고 메인 윈도우를 표시할 디폴트 방식(SW_SHOW 등).

즉, 윈도우 운영체제는 GUI 기반이라는 특성상 프로그램 메인 윈도우를 기본적으로 최대화하여 실행하라, 창을 숨겨 놓고 실행하라는 식의 지시를 내릴 수 있다. 이런 정보들은 WinMain 함수의 매개변수로 고스란히 그대로 전달된다. 물론 응용 프로그램이 그걸 무시하면 어쩔 수 없지만.

앞의 W..., L... 두 함수는 매개변수가 매우 단순한 편해서 쓰기는 편하나, 16비트 API 시절의 잔재로 치부되어 유니코드 버전조차 존재하지 않으며, 사용이 비추(discourage)되고 있다.

CreateProcess가 32비트 이상급 윈도우 API에서 표준으로 통용되는 가장 원초적인 프로그램 실행 함수이다. 그 대신 받아들이는 매개변수가 무진장 많으며 쓰기가 좀 어렵다. 여기에 대해서는 뒤에서 다시 좀 다루기로 하겠다.

끝으로 ShellExecute는 그 이름이 암시하듯, 커널 계층이 아닌 쉘이라는 꽤 상위 계층에서 구현된 함수로 단순히 파일을 실행하는 게 아니라 인터넷 URL을 기본 웹 브라우저에서 열거나 텍스트 파일을 메모장에서 여는 일도 다 담당한다. 동작 자체도 "open", "print" 등 아예 명령 문자열로 지정할 수 있다. 즉, 쓰임이 훨씬 더 포괄적이다. 이 함수도 EXE를 실행할 때는, 내부적으로 어차피 CreateProcess를 응당 호출한다.

C...는 리턴값이 프로그램의 실행 성공 여부를 나타내는 BOOL 형태인 반면, 나머지 세 함수들은 값이 약간 특이하다. 32보다 큰 정수를 되돌리면 성공을 뜻하고 그렇지 않으면 실패라는 뜻이다.
왜 이렇게 되었냐 하면 이 legacy 함수들은 원래 리턴값이 "인스턴스/모듈 핸들"이었으며 32 이하의 핸들값은 에러를 나타내는 값으로 의미가 예정되었었기 때문이다. 일종의 과거 잔재이다.

오늘날 윈도우 프로그래밍에서 HINSTANCE라고 부르는 핸들은, 과거에는 프로세스 ID와 비슷한 개념이었다. 이 핸들은 자신이 실행한 파일을 식별하는 정보도 있었던지라 동일 EXE를 중복 실행한 것을 WinMain 함수와 함께 넘어온 hPrevInstance로 분간할 수 있었다. 또한 EXE를 실행하여 생긴 인스턴스 핸들과, 그 EXE 안에서 DLL를 읽어들임으로써 이를 식별하는 모듈 핸들(HMODULE)도 별개의 존재였다.

하지만 32비트로 넘어오면서 운영체제의 메모리/파일 관리 모델이 완전히 바뀌었고 오늘날은 HINSTANCE와 HMODULE의 구분이 완전히 없어졌다. 단순히 프로세스 메모리 공간에 맵핑되어 있는 파일 이미지를 가리키는 포인터일 뿐이다. 메모리 영역 overlap에 따른 재배치만 일어나지 않는다면, 해당 DLL/EXE의 preferred base가 그대로 핸들값이 되는 것이다. 인스턴스 핸들이 이렇게 특정 프로세스 안의 주소 공간을 가리키는 포인터가 되는 바람에(national), 이제 이 핸들로 여러 프로세스들을 식별(international)하는 것은 불가능해졌다.

CreateProcess는 사용자가 보내준 파일 + 명령행 사이에다 null 문자를 잠시 삽입하여 토큰화를 했다가, 함수 실행이 끝난 후 문자열을 원래대로 되돌려 준다. C 언어의 strtok 함수를 떠올리면 된다. 이런 이유로 인해 명령행을 넘겨주는 포인터 영역이 read-only const여서는 안 되며 쓰기가 가능해야 한다. (물론 윈도우 NT 계열 운영체제에서 W 버전이 아닌 A 버전을 호출하면 어차피 쓰기 가능한 메모리 버퍼로 인코딩 변환이 일어나기 때문에 read-only 메모리를 넘겨줘도 문제될 건 없다.)

프로그램을 실행할 때는 이 프로그램에다 기본으로 넘겨 줄 각종 환경 변수, 콘솔 프로그램인 경우 표준 입출력 스트림의 핸들, 디버그 실행 여부 등 갖가지 고급 정보를 넘겨줄 수 있으며,
프로그램이 실행되었을 경우 생성된 프로세스의 핸들과 ID 등도 돌려받게 된다. 가령, 이 프로그램이 실행이 완전히 끝날 때까지 내가 기다려야 할 때 이 핸들에 대해 WaitForSingleObject를 호출하고 있으면 된다는 뜻이다. 단, 이 핸들을 Close하는 것도 우리 책임이다.

불필요하게 높은 계층에 자리잡고 있는 ShellExecute 대신, 커널 계층에 있는 CreateProcess를 좀더 간편하게 활용하기 위해 본인은 이 함수를 클래스로 감싸서 쓰고 있다. OpenFileName, TaskDialogIndirect (윈도우 비스타) 같은 복잡한 대화상자 UI 함수만큼이나 CreateProcess도

- 각종 디폴트 argument나 구조체들 챙기기
- 소멸자에서는 결과물로 받은 핸들들 닫아 주기
- 커맨드 라인을 알아서 자체 버퍼에다 생성하고, 필요한 경우 매개변수 전체를 따옴표로 싸 주기
- 이 프로그램의 실행이 끝날 때까지 기다리는 멤버 함수도 추가.

같은 자질구레한 일을 클래스로 감싸 놓으니, 프로그램 실행하기가 한결 편리하다.

Posted by 사무엘

2010/01/11 00:42 2010/01/11 00:42
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/58

예전에도 글로 쓴 적이 있지만, 16비트 윈도우 바이너리(exe/dll), 소위 NE 파일의 형태는 보면 볼수록 참 이상야릇하다고 느껴져서 흥미가 갑니다.

MZ로 시작하는 도스 EXE는 구조가 사실 매우 간단합니다. 맨 처음 32바이트 남짓한 구조체에다 몇몇 오프셋, 사이즈, 레지스터 초기값 따위를 넣고 재배치 정보(optional)만 넣어 주고 나면 그 뒤부터는 공통분모라는 게 없이 전적으로 프로그래머/컴파일러 재량입니다. COM은 아예 헤더란 것이 없고 곧장 코드+데이터가 등장하는 형태이니 초간단 패치 내지 램 상주 유틸리티, 혹은 심지어 바이러스를 만들 때 이보다 더 좋은 수단이 없었습니다.

하지만 좀더 복잡한 운영체제는 바이너리 파일에도 더 정교한 체계대로 구간별 역할을 딱딱 나누고 있습니다.
가령, EXE 뒤에다가 별도의 내장 데이터를 덧붙이는 것은 도스 시절에는 전적으로 컴파일러/링커 내지 해당 기능을 수동으로 제공하는 라이브러리의 재량이었습니다. 가령 볼랜드 컴파일러로 *.bgi 드라이버나 글꼴을 *.obj로 바꿔서 embed시키는 것은 운영체제보다 훨씬 더 상위 계층에서 행해지는 일이었죠.
하지만 윈도우에서는 운영체제 차원에서 바이너리 파일 안에 리소스라는 데이터 영역을 별도로 구분하여 관리해 주며, 이를 불러오는 API도 운영체제 차원에서 제공됩니다.

16비트 중에서도 윈도우 1.x(무려 1985년에 나온 바로 그것!), 2.x, 3.x의 포맷이 모두 서로 살짝 다르다고 하는데, 2.x 이상 바이너리는 오늘날 윈도우 운영체제의 NTVDM 하에서도 바로 실행 가능하다고 들었습니다. (9x 계열에서는 말할 나위도 없음.) 하지만 1.x도 리소스 데이터를 좀 변환하고 버전 플래그 같은 몇몇 값만 수정하면 곧장 실행할 수 있다고 하더군요. 윈도우가 하위 호환성을 상당히 잘 지키면서 버전업되어 왔다는 얘기가 되겠습니다.

0x3C 오프셋에 도스 바이너리가 아닌 실제 바이너리가 시작되는 위치가 기록되어 있다는 것은 NE(16비트 윈도우 바이너리), PE(오늘날의 32/64비트 바이너리) 모두 공통입니다. NE에도 PE와 마찬가지로 최소 운영체제 버전과 자신을 빌드한 링커의 버전이 헤더에 명시되어 있습니다.

윈도우 3.x 바이너리들은 대개 링커 버전이 5~6 정도로 잡혀 있던데 이건 비주얼 C++ 버전이 아니라 그 전신인 MS C 버전 기준이라고 보는 게 타당하겠습니다. 비주얼 C++ 1.5x는 MSC_VER의 값이 600밖에 안 되거든요. 그 반면 요즘 비주얼 C++ 200x는 이미 무려 1300~1500대까지 올라갔죠.

최소 운영체제 버전은 물론 3.10으로 잡으면 윈도우 3.1에서 실행 가능합니다. 하지만 이 수치를 더 높게 4.0으로 잡으면 “윈도우 3.1에서 실행되지 않는 16비트 윈도우 바이너리”를 만들 수 있습니다.

그런 예가 무엇이 있냐 하면 바로 윈도우 9x 이후에 제공되는 SysEdit.exe입니다. 이 프로그램은 16비트 EXE이지만 정작 윈도우 3.1에서는 실행이 안 됩니다. 하지만 윈도우 9x에서 실행하면 비록 16비트 EXE이지만 대화상자의 배경을 다른 32비트 프로그램들처럼 회색 입체 효과로 입혀 주며 16비트 프로그램과 호환되지 않는 일부 신규 메시지/API를 32비트 프로그램과 같은 스타일로 날려 줍니다. Win32s도 아니고 참 난감한 케이스이죠? 윈도우 9x가 나오던 당시, MS에서 내부적으로 기존 16비트 프로그램들의 외형 껍데기의 이질감을 줄이려고 넣은 꽁수에 가깝다고 보면 되겠습니다.

요즘은 파일 포맷을 설계할 때 최대한 확장성을 고려하여 chunk 테이블부터 넣는 게 일반적입니다. MIDI(음악), TTF(글꼴), PNG(그래픽)들이 다 그렇죠. PE도 마찬가지여서 text, data, reloc, rsrc 같은 청크 식별자가 존재합니다. 하지만 NE는 나중에 등장한 PE와는 달리 그런 구분은 존재하지 않으며 헤더, 세그먼트 테이블, 리소스 테이블, 이름 테이블 등 미리 정해진 정보가 순차적으로 쭉~ 등장하는 형태입니다.

kernel, user, gdi처럼 내가 참조하여 import하는 다른 모듈의 API에 대한 정보는 있지만 PE처럼 함수명이 그대로 기록되어 있지는 않고 그냥 서열 번호만 들어가는 것 같습니다. 또한 윈도우 1.x가 맨 처음에 파스칼로 개발되어서 그 영향을 받아서인지, export하는 심볼 이름들은 다 대문자로만 적혀 있고 대소문자 구분은 딱히 안 하는 걸로 보입니다. 물론 윈도우 내부 API가 SDK 형태로 최초로 정식 공개된 3.0 시절에는 이미 다 C언어 기반으로 바뀌었지만.

끝으로, NE에는 PE에 전혀 없는 개념인 name table이라는 게 있습니다. 프로젝트 빌드할 때 *.DEF 파일로부터 링커가 생성해 주는 테이블일 겁니다.
그것도 resident name, non-resident name이라 하여 언제나 메모리에 상주하는 것, 아니면 언제나 상주시키지 않기는 때문에(메모리 아끼기 위해) 불러오는 데 시간이 좀 걸릴 수도 있는 것으로 종류도 나뉘어 있습니다. 둘 다 자신이 export하는 함수들의 명칭 같은데 정확한 용도가 무엇인지, 그리고 또 왜 이런 식으로 분류를 했는지는 알 길이 없습니다. 인터넷으로도 이런 게 있다는 식으로 기계적으로 설명해 놓은 자료만 있지, NE 포맷을 왜 이런 식으로 만들 수밖에 없었는지 같은 친절한 설명은 정말 찾을 수 없더군요.

또한 이 테이블에는 꼭 export symbol만 들어가는 게 아니라, non-resident name의 1순위로는 이 프로그램의 full name도 같이 들어갑니다. 즉, 버전 리소스를 굳이 안 뒤져도 여기를 찾아봐도 “Microsoft Visual C++”, “FontMania” 같은 정보를 얻을 수 있다는 것이죠. 오늘날 쓰이는 PE에는 이런 정보가 없습니다.

윈도우 3.1의 기본 프로그램인 문서 작성기, 페인트 등의 EXE를 들여다보면 마치 DLL처럼 프로그램의 내부 함수로 추정되는 명칭(특히 윈도우/대화상자 프로시저)을 상당수 non-resident name table에다가 export해 놓은 것을 알 수 있습니다. PageInfoWndProc, DialogGoto, BroadcastChildEnum 등. 왜 이렇게 해 놓은지는 저는 16비트 윈도우 개발 경험이 없으니 알 길이 없습니다. 메모리가 캐 부족하던 시절에 아마 한 번 만들어 놓은 코드는 EXE, DLL을 불문하고 최대한 많이 재사용하려고 이렇게 한 게 아닌가 싶습니다.

그나마 resident name table은 거의 쓰지도 않는 거 같던데 왜 만들었는지도 모르겠고.. 수수께끼이군요. 참고로 32비트 시대로 와서는 리소스 같은 건 free 한다는 개념 자체가 없어졌습니다. 당연히 resident, non-resident 같은 구분도 전혀 필요 없죠.

대략 이렇게 NE 포맷에 대해서 살펴봤는데, PE까지 그대로 이어지고 있는 개념도 있는 반면 어떤 것은 완전히 다른 것도 존재한다는 걸 알 수 있었습니다. 역시 16비트와 32비트 사이에는 넘사벽 같은 gap이 있는 듯이 보입니다.

Posted by 사무엘

2010/01/11 00:18 2010/01/11 00:18
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/48

설치 프로그램 같은 부류를 작성하다 보면, 지금은 다른 프로세스에 의해 사용 중이기 때문에 건드릴 수 없는 파일을 다음 재부팅 때 교체(업그레이드 설치) 또는 삭제(제거)되도록 설정해야 할 필요가 있다.
(사용 중이어서 잠긴 파일도 놀랍게도 rename과 같은 드라이브 안의 이동은 가능하다. 단지 드라이브 바깥으로의 이동이나 삭제, 교체, 덮어쓰기가 안 될 뿐이다.)

이 분야에 관한 한 윈도우 API는 무척 자비심이 없었다. 윈도우 NT 계열과 9x 계열이 설정하는 방법이 서로 완전히 달랐기 때문이다.
전자의 경우 MoveFileEx라는 걸출한 API가 있어서 MOVEFILE_DELAY_UNTIL_REBOOT 플래그만 지정해 주면 됐다. 다음 재부팅 때 교체/삭제될 예정인 파일 목록은 레지스트리에 별도로 관리되는데, 이 위치 역시 MSDN에 문서화돼 있다.

이게 제일 깔끔한 방법인 반면 윈도우 9x에서는 이 방법을 쓸 수 없다.
윈도우 9x는 부팅 직전에 업데이트 또는 삭제해야 하는 파일의 리스트를 윈도우 디렉토리에 있는 wininit.ini 파일로부터 읽는다. 거기에 뭔가 의미 있는 값이 있다면, 바로 그 때 “Windows가 일부 구성요소를 업데이트하고 있습니다. 몇 분 정도 시간이 소요될 수 있습니다”란 친근한 메시지를 부팅 중에 텍스트 모드에서 영어로 잠시 보여준다. 아마 9x 유저들은 이 메시지를 기억할 것이다.

이 처리를 다 마치고 나면 wininit.ini 파일은 wininit.bak로 개명된다.
이 일을 하는 프로그램은 wininit.exe라는 프로그램이다. 그런데 얘는 본격적인 32비트 운영체제가 로딩되기 전에 잠깐 실행되는 완벽한 16비트 도스용 프로그램일 뿐이다. 그래서 wininit.ini 리스트에는 긴 파일 이름(LFN)을 쓸 수 없다는 무지하게 불편한 제약이 걸린다. 파일 이름이야 그렇다 치지만 폴더 이름까지 공백 하나 표현 못하니 얼마나 불편하리요.

지울 때야 LONGFI~1.TXT 이렇게 지정하면 되겠지만 옛 파일을 LFN 방식의 새 파일로 깔끔하게 교체하는 건 불가능하다는 뜻이기도 하다. 정 하고 싶으면 rename을 부팅 때 강제로 해 주는 다른 프로그램을 RunOnce 이런 레지스트리에 넣기라도 해야 한다.

또 하나 고려할 만한 점으로..
인스톨러가 프로그램을 설치/제거하다가 일부 파일을 건드리지 못했다면 “다음 프로그램이 이 파일을 사용 중입니다” 이러면서 파일을 사용 중인 프로그램 나열까지 띄워 주면 무척 좋을 것이다. (MSI는 실제로 그렇게 해 주고 있다.)

이 기능을 직접 구현하려면 현재 로딩되어 있는 모듈(EXE/DLL)의 리스트를 얻어 오는 API를 써야 하는데 이 역시 윈도우 9x와 NT가 사용하는 API 계보가 완전히 달랐다. 전자는 ToolHelp라고 불렸고 후자는 Process Status API였는데 다행히 윈도우 2000에는 9x의 ToolHelp API도 추가되어서 API가 일종의 통합을 이뤘다.

한편, 저런 이유 때문에 일부 파일을 결국 교체나 삭제를 못 한 경우 인스톨러는 “작업을 완료하려면 재부팅이 필요합니다. 지금 하시겠습니까?”라고 묻는 게 일반적이다. 하지만 대다수의 사용자는 귀차니즘에 입각하여 ‘아니요’를 고르고 만다.

그런데, 그렇게 재부팅을 미루고 언인스톨러를 종료했는데 그 상태에서 사용자가 그 프로그램을 다시 설치한 경우 꽤 문제가 된다. (변덕 한번 심하기도 하다. -_-)
잘 만들어진 인스톨러라면, 이 경우 교체하거나 삭제하기로 요청해 놓은 파일의 예약을 도로 취소해야 한다. 안 그러면 그렇게 재설치 한 후 다음에 운영체제를 재시작하는 순간 재앙이 벌어질 수도 있다.

따라서 원칙대로라면 건드리지 못한 파일이 없더라도 인스톨러는 wininit.ini 같은 목록을 뒤져 봐야 한다. 그것도 운영체제 계열 별로 완전히 다른 접근 방식으로 말이다. 요즘이야 9x 계열은 사실상 신경 쓸 필요도 없어지긴 했지만. 참고로 MSI가 저것까지 똑똑하게 알아서 처리해 주는지는 테스트 안 해 봤다.

결론은, 인스톨러는 완성도 높게 잘 만들려면 현 프로그램의 설치/제거 상태부터 시작해서, 저런 지저분한 API 차이까지 감안해 가며 귀찮게 따져야 하는 게 무지하게 많다는 것. IME 하나 만들려면 지저분한 잡일이 너무 많으니 MS에서 TSF를 개발한 것처럼, 저런 작업을 표준화하고 자동화하기 위해서 MSI라는 것을 안 만들 수가 없었을 것이다.

MSI는 파일을 복사하고 쓰는 제 본연의 기능 면에서 완성도는 무척 높은 것을 인정하지만, 같은 MS에서 개발한 AppLocale 같은 프로그램하고도 UI가 충돌을 일으킨다는 점은 심히 유감이다. -_-

Posted by 사무엘

2010/01/10 23:46 2010/01/10 23:46
Response
No Trackback , 2 Comments
RSS :
http://moogi.new21.org/tc/rss/response/40

훅킹 프로그래밍

윈도우 훅킹은 운영체제의 내부 메커니즘(주로 메시지)을 가로채어 시스템 전체의 동작 방식을 바꾸는 매우 강력한 기법입니다.

이런 훅은 우리 프로세스 안의 특정 스레드 안에다가만 설치할 수도 있고 시스템의 모든 스레드에다가 설치할 수도 있는데, 32비트 운영체제로 오면서 후자 같은 global 훅(혹은 시스템 훅)을 설치하기 위해서는 훅 프로시저는 반드시 DLL에 따로 존재해야 하는 약간의 번거로움이 생겼습니다.

global 훅은 동작 방식의 특성상 모든 프로세스들에 나의 코드를 주입하는 가장 간편한 방법입니다. 굳이 윈도우 메시지 훅킹이 아니더라도 다른 종류의 훅킹을 위해서라도 윈도우 훅이 사용됩니다. 한컴사전의 노클릭 단어 인식이라든가 과거의 한스타, 그리고 Dependency Walker의 EXE 프로파일 기능처럼 API 훅킹이 동원되는 프로그램도 살펴보시면 별도의 DLL 파일이 존재하는 것을 알 수 있는데, 이는 API 호출을 변조하는 코드 자체를 삽입하기 위해서 윈도우 훅을 사용한 것입니다.

Global 훅은 완전히 특이한 프로그래밍 패러다임을 제공합니다. 다른 프로세스에서 완전 제각기 따로 실행되는 함수를 만들 수 있습니다.
가령, A라는 프로세스에서 B라는 DLL에 들어있는 훅 프로시저로 global 훅을 설치했습니다. 그러면 A와 B 사이의 통신 방법이 문제가 됩니다. 통상 A는 B의 동작 방식을 결정하는 입력 데이터를 주고, B는 훅킹을 통해 얻은 결과를 A에다 전달해야 할 것입니다.

이를 위해 보통 메시지를 쓰면 무난하죠. B에서 A로 통신할 때야 우리끼리만 쓰는 WM_USER+n이라든가, 심지어 WM_COPYDATA를 바로 보내도 무난하지만, 다른 프로세스 안에 존재하기 때문에 메시지가 겹칠 우려가 있는 B를 “향해서” 메시지를 보낼 때는 RegisterWindowMessage로 값이 안 겹치는 게 보장되는 별도의 메시지를 등록해서 쓰는 게 안전합니다.

또한 프로세스 A가 자신의 주소 공간에 로드되어 있는 DLL B의 변수값을 바꾼다고 해서 다른 프로세스들의 주소 공간에 로딩되어 있는 DLL B의 인스턴스의 변수값이 바뀌지는 않는다는 것도 조심해야 합니다. global 훅 프로시저는 메시지를 받는 그 프로세스의 주소 공간을 기준으로 호출된다는 것!
이걸 헷갈려서는 안 됩니다. 변수 초기화 같은 걸 잘못하면 훅을 설치한 프로세스 A 안의 B만 제대로 동작하게 되고, 다른 프로세스에 침투한 B는 그렇지 못하게 됩니다.

이것 때문에 과거에 굉장히 주의가 필요했던 점이 뭐냐 하면 훅 핸들 값을 공유하는 것이었습니다.
훅 프로시저는 자신에게 걸린 메시지를 처리한 뒤, CallNextHookEx 함수로 그 메시지를 다음 훅에다가 전달도 해 줘야 했습니다. 운영체제에 갈고리질을 하는 놈이 나만 있는 것 아니기 때문에... 그런데 윈도우 9x는 유독 이때 자신이 받은 훅 핸들값도 알아서 전달해 줘야 했습니다.

프로세스 A가 자신의 주소 공간에 있는 B DLL에다가 훅 핸들을 넘겨준다고 해도, 다른 주소 공간에 복제된 다른 B DLL의 인스턴스는 그 값을 알 수 없었습니다.
그래서 이 값은 숫제 메모리 맵드 파일 같은 공유 메모리를 만들어서 넘겨주거나, #pragma data_seg 같은 전처리기로 별도의 공유 섹션을 만들어서 그 전역변수에다 핸들을 공유해야 했지요.

그런데 윈도우 2000 이상, 아니 NT 계열은 그럴 필요가 없습니다. CallNextHookEx 함수를 호출하는 것 자체만으로 이 문맥에서의 훅 핸들은 알아서 감지가 됩니다. 당연히 그렇게 되는 게 이치에 맞죠. 그럼, 윈도우 95보다 NT가 3.1 시절부터 먼저였는데 왜 애시당초 아무 쓸모없던 HHOOK 인자를 받는 게 있었을까? 그건 아마 16비트 시절의 훅킹 API의 프로토타입을 그대로 베끼다 보니 그렇게 된 게 아니었나 싶군요. 32비트 윈도우로 와서 WinMain의 hPrevInstance 인자가 완전 무의미해졌지만 여전히 그대로 남아있는 것처럼.

그럼에도 불구하고 훅킹 API에 관한 한 윈도우 9x는 NT를 100% 닮지 못하고, 그렇다고 해서 아예 3.1 시절처럼 단일 주소 공간도 아니면서(핸들 값 공유도 어려운 환경에서) 꽤 불편한 프로그래밍 관행을 개발자에게 강요하게 되었던 것 같습니다.

윈도우 9x 시절에만 해도 global 훅 프로그래밍은 굉장히 조심스럽게 해야 했습니다. 훅 프로시저에서 뭔가 뻑이 났다간 그건 90% 이상 운영체제 다운으로 연결됐습니다. 디버깅은 더욱 힘들었음. 보호 모드 운영체제란 말이 무색할 정도였어요. 그만큼 운영체제가 안전보다는 열악한 PC 환경에서 효율 내지 도스와의 호환성 위주였으며, 불안정하고 응용 프로그램의 위험한 동작에 대해 취약했습니다.

하지만 XP 정도 되니, 특히 비스타는 이 정도로 안정성이 강화될 줄은 몰랐습니다. 훅 프로시저에 굉장히 어이없는 실수가 들어있었는데 그냥 그 훅만 싹 없어지고 프로그램은 잘 돌아가더군요. 깜짝 놀랐음. 윈도우 9x였으면 당장 blue dead screen이었을 겁니다.
윈도우 2000 때만 해도 IME/TSF 모듈이 해당 응용 프로그램을 뻗게 만들 수 있었는데, XP 이후부터는 안 그렇더군요. 자체적으로 예외 핸들링을 합니다.

global 훅 프로그래밍을 할 때 괴로운 점.
훅을 거둬들이고 모든 프로그램을 종료한 뒤에도 훅 프로시저가 들어있는 DLL의 lock이 즉시 풀리지 않는 경우가 많다는 것입니다. 훅을 해제한 뒤에도 이 DLL이 여전히 몇몇 프로세스의 주소 공간에서 사라지지 않고 상주해 있기 때문입니다. 그런데 3~5분 정도 기다리고 나면 없어져 있어요. 그러니 DLL을 이것저것 고치면서 자주 리빌드를 하기가 힘듭니다. -_-;;

비슷한 예로 글꼴도 있답니다. 프로젝트 파일로부터 최종 TTF 파일을 만들어서 여타 프로그램에서 테스트를 했습니다. 그런데 한번 그러고 나면, 그 프로그램을 종료한 후에도 내가 새로 설치한 TTF 파일이 여전히 in use 상태여서 지워지거나 교체가 안 되는 겁니다. 그러니 글꼴을 빈번히 수정하고 테스트하기가 힘들죠.

이럴 때 VMware가 해결책이 될 수 있습니다. 가상 머신을 만들어서 훅 프로그램을 실행하거나 글꼴을 설치하기 직전 순간의 스냅샷을 만든 후, 테스트를 하고 나서 스냅샷 시점으로 revert 하기. -_-;; 그게 저절로 파일 lock이 풀리길 기다리거나 재부팅을 하는 것보다 더 빠르더군요. ㄱㅅ!

뭐, global 훅 얘기로 길어졌습니다만, 내 응용 프로그램 안에서의 작은 규모의 훅도 충분히 쓰일 일이 있으며 특히 MFC에서는 내부적으로 이런 훅을 사용합니다. 일반적인 방법으로 잡기 힘든 메시지들도 MFC의 단일 프레임워크 하에서 일관성 있게 처리시키기 위한 목적이기도 하고요,
또 모든 대화상자들을 부모 윈도우 기준으로 중앙에다 재배치시키기 위해서도 훅을 사용해 대화상자가 생성되는 시점을 가로챕니다.

그냥 DialogBox 같은 함수만 호출해 보면 잘 알다시피 대화상자가 중앙에 뜨지 않습니다. MFC는 modal 대화상자만 중앙으로 옮겨 주기 때문에, 그냥 Create 함수로 modeless 대화상자를 만들어 보면 당장 그 위치 차이를 감지할 수 있습니다.

Posted by 사무엘

2010/01/10 23:29 2010/01/10 23:29
, ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/37

윈도우 운영체제가 NT 초창기 시절 이래로 지금까지 사용해 오고 있는 실행 파일 포맷은 잘 알다시피 portable executable 형식입니다. 헤더도 이니셜인 PE로 시작합니다. 물론 네이티브 EXE이기 때문에 코드 부분은 기계마다 다르겠지만, 헤더 구조체라든가 리소스 같은 공통된 부분은 최대한 일치시켜서 이식성을 고려해서 설계했다는 뜻이지요.

늘 인텔 CPU에서만 돌아가는 EXE만 보다가 MIPS 같은 RISC CPU에서 돌아가는 PE 실행 파일을 헥사 에디터로 들여다보니 진짜로 기계어 코드가 한눈에 보기에도 일정 바이트 간격으로 아주 균일하게 나열돼 있더군요. 그걸 보고 놀랐던 기억이 납니다.

64비트 PE도 일부 구조체만 64비트로 확장되었을 뿐 기본적인 골격은 초창기 32비트 PE와 같습니다. 더구나 윈도우 운영체제가 인식하는 리소스(스트링 테이블, 대화상자, 메뉴 등)의 포맷은 매우 다행스럽게도 32비트 PE와 완전히 일치합니다.

EXE와 DLL은 자신만의 프로세스 공간을 만들어서 단독 실행이 가능하냐의 차이가 존재하는데, 기술적으로는 헤더의 비트 몇 군데만 다르지 똑 같은 PE 바이너리입니다. 이런 바이너리를 ‘모듈’이라고 부릅니다.

c, cpp 같은 소스 코드를 컴파일하면 기계어 코드인 obj 파일이 생깁니다. 이런 obj 파일과 lib를 링크하면 그런 모듈 파일이 결과물로 생성됩니다. lib는 또다른 obj의 묶음일 뿐 obj와 완전히 다른 파일이 아닙니다. 또한 모듈 역시 그런 obj, lib에 들어있는 코드를 PE 규격에 맞게 재배치하고 묶은 파일일 뿐이지 원시 파일과 그렇게 큰 차이가 없습니다.

윈도우 운영체제에서 개발 환경을 만든 사람들의 생각은, 링커가 특별히 할 일이 없게 하는 것이었던 것 같습니다. 물론 요즘은 전역 최적화처럼 링크 타임에도 코드를 생성하는 기술도 도입되어 사정이 그렇게 간단하지만은 않게 됐지요.

PE는 text(실행되는 기계어 코드), rdata(스트링 리터럴처럼 읽기전용 상수나 초기화 값), rsrc(윈도우 리소스 데이터), DLL 심볼 import/export 테이블, reloc(재배치 정보) 등 여러 섹션으로 나뉩니다. 특히 재배치 정보는 Win32s 시절에는 exe에도 필요했지만 지금은 dll에만 넣어 주면 됩니다.

PE의 헤더에는 자신의 기본 어드레스, 자신이 돌아가는 데 필요한 최소한의 운영체제 버전 같은 여러 정보가 들어가고 심지어 자신을 빌드한 링커의 버전을 기입하는 공간도 있습니다. 가령 비주얼 C++로 빌드하면 6.0, 7.1 (닷넷 2003), 8.0 (2005) 같은 번호를 쉽게 식별할 수 있지요.

원래 MS 자체에서 만든 프로그램 바이너리들의 링커 버전은 비주얼 C++의 버전과 거의 일치하지 않았습니다.
가령 윈도우 95는 까마득한 2.5, 그리고 98/ME는 3.1, 윈도우 2000은 5.12, 오피스 XP는 6.2였습니다. 비주얼 C++과는 별도로 자신들만 쓰는 컴파일러/링커가 있었던 것 같습니다.

하지만 이것이 언제부턴가, 한 02~03년부터 버전이 일치하기 시작했습니다. MS에서도 내부적으로 비주얼 스튜디오를 쓰기라도 했는지?
윈도우 XP는 7.0으로 당대의 최신 비주얼 C++이던 닷넷 2002와 일치합니다.
그리고 XP sp2 (sp1은 모르겠음)와 오피스 2003은 비주얼 C++ 닷넷 2003의 버전과 같은 7.1입니다.

그 후 윈도우 비스타와 오피스 2007의 모든 바이너리들은 비주얼 C++ 2005의 버전인 8.0으로 물갈이되어 있습니다. 하지만 CRT 라이브러리는 살짝 다릅니다. 오피스는 msvcr80을 쓰지만 운영체제는 자신만의 msvcrt를 고수하고 있습니다. 하지만 이제는 msvcrt에도 비주얼 C++ 2005에서 새로 추가된 strcpy_s 같은 보안 강화 함수들이 추가되어 있습니다.

msvcrt는 이제 운영체제가 혼자 마음대로 바꿔 쓰는 CRT DLL로 격리시키고 응용 프로그램들은 이제 msvcr??을 알아서 배포해서 쓰든가, 싫으면 스테틱 링크하라는 구도가 된 셈입니다.

Posted by 사무엘

2010/01/10 23:17 2010/01/10 23:17
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/31

C 런타임 라이브러리에는(이하 CRT) 단순히 내가 호출하는 printf, strcpy, malloc 같은 함수에 대한 코드만 있는 게 아니다.
사용자가 작성한 C/C++ 프로그램보다 아래 계층에서 먼저 실행되면서
사용자가 짠 main 함수를 호출해 주고, 표준 C 스펙에 정의돼 있는 각종 전역변수의 값을 설정해 주고
전역변수 C++ 오브젝트들을 미리 초기화해 주는 것도 CRT의 몫이다.

이 오버헤드가 작지는 않다.
그렇기 때문에 hello, world! 한 줄만 찍는 프로그램을 C 언어로 짜 봐도 어지간해서는 크기가 최소한 1만 바이트는 넘어서며, 특히 윈도우 프로그램의 경우 내가 전혀 호출하지 않은 GetStartupInfo, GetVersion 같은 커널 쪽 Win32 API를 호출해 있는 걸 볼 수 있다. (CRT를 스테틱 링크한 경우)

그런 함수는 당연히 CRT가 호출한 것이다.
가령 main 함수에 인자로 전달되는 명령줄 인자는 CRT가 준비해서 넘겨 준 것인데,
CRT 역시 명령줄 인자는 더 아래 계층의 GetCommandLine 같은 API 함수를 통해 얻어 온 후, 파싱해야 하기 때문이다.

이 CRT 초기화 코드를 무시하고 진짜 순수하게 내가 짠 코드만 집어넣게 하는 링크 옵션이 컴파일러에 따라서 물론 존재한다.
이렇게 하면 어셈블리 프로그래밍 하듯이 아주 작은 EXE를 만들 수 있다.
하지만 이 경우, 정상적인 C언어 사용은 포기해야 한다.

CRT 초기화 코드가 실행되지 않으면 printf, malloc 등 I/O라든가 뭔가 초기화 context가 필요한 함수들도 죄다 사용할 수 없게 되기 때문이다.
윈도우 환경의 경우 그런 것들도 Win32 API만으로 내가 직접 다시 짜야 할 것이다.
fopen은 CreateFile로,
malloc/free는 HeapCreate 등으로 힙 관리 직접 다 하고,
sprintf는 wsprintf 등으로. (그나마 윈도우는 운영체제 차원에서 % 문자를 C랑 똑같이 해석해 주는 함수가 있어서 다행이다. 운영체제 자체가 C언어로 개발됐다는 증거가 아닐까 한다)

과거 16비트 도스용 컴파일러 시절에는 이 CRT 라이브러리가
메모리 모델별로 따로 존재해야만 했다. tiny, small, medium, compact, large, huge 기억하시는가? 아주 골치아팠다.

그러다가 32비트 윈도우 환경에 와서는 메모리 모델 구분은 없어지고 CRT에 새로운 속성이 존재하게 됐다.

- 멀티스레드: 옛날에 컨텍스트를 저장하는 데 배째라 전역변수 썼던 것들을 이제 스레드 TLS로 옮겨야 한다. 대표적인 예가 strtok. 이제 비주얼 C++ 2008부터는 싱글스레드 라이브러리는 없어지고 무조건 멀티스레드만 지원한다.
- 디버그: 도스용 컴파일러 중에 디버그 용 CRT가 따로 있었던 건 별로 본 기억이 없다.
- DLL이냐 스테틱이냐: CRT를 DLL로 따로 떼어낼 수도 있게 됐다. 한 프로그램이 같은 CRT를 사용하는 수많은 DLL들을 로딩하는 경우, 그 DLL 모듈들도 CRT DLL 하나만 로딩하도록 개발하면 메모리를 많이 절약할 수 있다.

Posted by 사무엘

2010/01/10 23:01 2010/01/10 23:01
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/25

side by side assembly는 윈도우 운영체제의 구조적인 문제 중 하나였던 DLL hell 문제를 해결하기 위해 윈도우 XP에서부터 처음으로 도입한 기술입니다. (그 후 2003, 비스타 등등..)

전통적으로 Win32 EXE가 symbol import table을 통해 읽어들이도록 지정되어 로드 타임 때(런타임이 아닌) 실제로 읽어들이는 외부 dll은
해당 EXE/모듈이 있는 디렉토리, 커런트 디렉토리, 윈도우 및 윈도우 시스템 디렉토리, path 환경변수가 걸린 디렉토리 등등의 순서대로 탐색합니다. 물론 kernel32, gdi32, user32 같은 시스템 dll도 다 여기에 속하고요.

런타임이 아니라 로드(load) 타임이기 때문에
이런 dll 파일이 존재하지 않거나 한 심볼이라도 읽어들이지 못했다면
그 프로그램은 그냥 속수무책으로 전혀 실행되지 못합니다.
그런 상황에서의 처리를 프로그래머가 능동적으로 전혀 할 수 없다는 게 큰 약점입니다.

* * * * * * *

자, 이런 전통적인 로딩 방식은 잠재적인 문제를 품고 있습니다.
먼저, 한 회사에서 만들어서 여러 디렉토리에 상주하는 다수의 프로그램들이 자기네끼리 한데 공유하는 dll의 경로를 지정할 수가 없습니다. 기껏 잘 해 봐야 path 환경변수 지정이 고작인데, 이는 정량적인 방법은 아닙니다. 그 경로 지정 자체가 이미 '런타임'에만 가능한데, dll 로딩 경로는 '로드 타임'에 결정되어야만 하기 때문입니다.

이런 딜레마로 인해 조금이라도 한데 공유하는 dll은 반드시 탐색한다는 게 보장되는 윈도우 시스템 디렉토리에다 집어넣는 게 무조건 장땡이었고, 이 때문에 거기는 운영체제가 기본 제공하는 dll과, 응용 프로그램들이 집어넣은 dll로 인해 몇천 개의 수백 MB에 달하는 dll들로 난장판이 되어 버렸습니다.

둘째, dll을 식별하는 수단이 이름이 유일한지라, 이름이 같지만 다른 위치에 있거나 버전이 다른 엉뚱한 dll이 로딩되었을 때 대처할 방법이 전혀 없습니다. 이는 보안상으로도 매우 위험합니다. 이게 바로 말 그대로 DLL hell의 근본 원인입니다.

msvcrt.dll, mfc42.dll, comctl32.dll 이런 것들은 윈도우 98 이래로 이름은 같지만 그동안 버전별로 프로토콜, 인터페이스가 내부적으로 상당히 바뀐 게 많습니다. 내부적으로 문서화되지 않은 동작 방식이야 두말할 것도 없겠죠. 그런데 본이 아니게 그런 문서화되지 않은 동작 방식에 의존하던 프로그램이 다른 버전의 dll을 읽어들이면 그때부터 프로그램의 안정성은 뻑이 나기 시작한다는 것입니다.

* * * * * * *

side by side assembly 기술은 위의 두 문제를 모두 원천적으로 해결하는 과정에서 나온 해결책입니다.

첫째, 이제 MS에서는 지금까지 내놓았던 시스템 dll 이후로 제 3자 응용 프로그램의 고유 dll이 윈도우 시스템 디렉토리에다 들어가는 것을 비추(discourage) 행위로 규정했습니다.
호환성 때문에 저걸 완전 금지할 수는 없구요. <날개셋>의 경우 윈도우 IME가 반드시 시스템 디렉토리에만 있어야 하기 때문에 시스템 디렉토리를 건드립니다.

그런 특수한 경우가 아니면, 응용 프로그램이 사용하는 모든 dll은 가능하면 응용 프로그램 디렉토리에다가만 두어라! 시스템 디렉토리는 이제 운영체제만 자체적으로 쓰겠다. 이곳의 포화는 이제 그만!
메모리가 부족한 상황에서 자원을 하나라도 더 공유하려고 시스템 디렉토리를 두었지만 이제 그런 정책까지 과감히 포기한 것입니다.

이게 파격이 큰 이유는, 심지어 준시스템 dll이나 다름없는 개발툴의 런타임 dll들까지 이제 시스템 디렉토리에다 넣지 않기로 했기 때문입니다. 비주얼 C++ 기준으로 6.0이 끝입니다. 6.0으로 만든 EXE만이 mfc42.dll이나 msvcrt.dll 정도는 윈도우 98 이래로 어디에나 편재해(ubiquotous) 있다고 간주할 수 있습니다. 즉 'known dll'인 것입니다.

그 이후로, 닷넷으로 만든 EXE는 자신이 사용하는 msvcr71, msvcr80, mfc71 등등을 배포 패키지에다 자체 내장해야 합니다. 윈도우 XP, 비스타의 시스템 디렉토리에 기본으로 들어있지 않습니다. 응용 프로그램이 자기가 쓰는 런타임 dll은 무조건 복사본을 자기 디렉토리에다 심어야 합니다.

둘째. 첫째가 상당히 자비심 없는 정책이 되었는데 그럼 대책이 뭐냐 하면,
내가 정확하게 읽어들이고자 하는 DLL의 고유 식별자, CPU 아키텍쳐, 언어, 정확한 버전 번호를 exe 내부에다 리소스의 형태로 xml 문서로 명시해 놓는 것입니다. 한 컴퓨터의 디렉토리 구조와는 완전히 무관하게 말입니다. 메니페스트 xml이라고들 하죠.

그 메니페스트용으로 등록된 핵심 dll들은 윈도우 시스템 디렉토리에 있는 게 아니라 WinSxS 밑에 각 버전과 아키텍쳐별로 별개의 디렉토리에 저장됩니다.
윈도우 XP라면 gdiplus, comctl32 이런 dll이 존재할 텐데요. 이름이 같은데 윈도우 XP 원본이 갖고 있던 comctl32, SP2가 갖고 있던 comctl32 이런 버전별 '스냅샷'들이 전부 따로 저장되어 있는 걸 알 수 있습니다. 비스타에는 이보다 훨씬 더 많이 있습니다. 내가 만들어서 공유하기를 원하는 핵심 dll도 이런 식으로 등록, 배포할 수 있습니다.

* * * * * * *

윈도우 XP에서 이런 새로운 방식으로 곧바로 사용되기 시작한 대표적인 운영체제 컴포넌트가 바로 comctl32.dll 입니다. advapi, shell32 같은 거 말고 comctl32만 이런 형태가 됐습니다.
얘는 윈도우 9x 시절부터도 인터넷 익스플로러와 함께 곧잘 업데이트되었던 파일이고 특히 윈도우 9x의 안정성을 떨어뜨리는 주범이기도 했습니다.

이 파일이 특히 윈도우 XP에서는 비주얼 스타일 테마의 등장으로 인해 구조가 완전히 뒤바뀌었는데, 정작 새로운 파일은 WinSxS에 있고, system 디렉토리에 있는 파일은 버전이 여전히 5.82이고 옛날 IE 5.x 시절과 별 차이 없습니다. 한 마디로, system 디렉토리에 있는 파일은 옛날 프로그램과의 호환성을 위해 시간이 그 상태 그대로 정지해 버린 것입니다.

그 반면, 메니페스트에다가 새로운 comctl32를 사용하도록 지정을 해 놓은 exe만이 대화상자에서 윈도우 XP 이후의 비주얼 스타일 적용을 받을 수 있는 구조가 됐습니다. comctl32를 이 방법을 통해 최신 버전으로 지정하지 않으면, 옛날 프로그램에서는 알록달록한 테마만 나오지 않는 게 아니라 하이퍼링크 컨트롤 같은 XP에서 새로 추가된 컨트롤을 쓸 수도 없고, 비스타에서 그 유명한 TaskDialog 함수조차도 쓸 수 없습니다.

* * * * * * *

유행 따라서 윈도우 XP 비주얼 스타일을 쓰려고 으레 '리소스' 형태로 따로 작성해 온 메니페스트가 비주얼 스튜디오 2005에서는 개발툴 차원에서 통합적으로 지원되는 요소가 되었습니다. 2005는 이제 배포 대상 플랫폼으로서 윈도우 9x의 지원을 완전히 제껴 버리고 CRT와 MFC DLL 역시 side by side assembly 형식으로"만" 로딩하게끔 정책이 바뀌었습니다.

이게 언뜻 보기에 골때리는 이유가 뭐냐 하면, 이제 2005로 만든 exe는 msvcr80.dll이 시스템 디렉토리나 심지어 그 exe 디렉토리에 같이 있다고 하더라도 winsxs 설정이 안 되어 있으면, '응용 프로그램 구성이 잘못됐다'며 실행 안 되기 때문입니다. (msvcr80이 자체적으로 퇴짜를 때리는 듯.)
그냥 msvcr80, mfc80 같이 슬쩍 복사해 놓는 방식으로는 실행 못합니다. 이들을 winsxs 정식 등록을 해 주든가, 아니면 앗싸리 MS에서 배포하는 VS 2005 redistributable 프로그램을 같이 배포해야 합니다.

그런데 도대체가 VS 2005 redist.도 따로 있고, VS 2005 sp1 resit.도 따로 있습니다.
윈도우 비스타에 기본 내장되어 있는 msvcr80의 버전이 다르고, 오피스 2007이 사용하는 msvcr80의 버전이 다릅니다. 세부 버전 숫자가 하나라도 다르면 로딩 안 되는 게 일단 보안과 안정성 면에서는 나아진 점입니다만, 도대체 같은 기능을 하는 CRT DLL을 왜 자꾸 이렇게 바꿔 대는지, 도대체 앞으로는 또 어느 장단에 맞춰 춤을 추라는 소리인지 헷갈립니다.

MFC도 아니고 CRT가 뭐가 그렇게 자주 바뀌어야 하는지.. 그냥 아무 걱정 없이 윈도우 시스템 디렉토리의 msvcrt만 쓰던 시절이 살짝 그립기도 합니다. strcpy, malloc 이런 고전적인 함수가 도대체 뭐 변할 일이 있나요? (물론 보안 쪽으로 계속 더욱 강력해져 오긴 했죠. strtok_s, strcpy_s 이런 것도 마음에 듦.)

비주얼 스튜디오 2005로 처음으로 만들어 본 <날개셋> 4.8 64비트 바이너리들은 이런 잡음을 원천 봉쇄하기 위해, 메모리 낭비를 감수하고 일단 모든 바이너리에 CRT 라이브러리를 static 링크를 해 버렸습니다. 64비트 에디션을 앞으로 계속 이렇게 만들지는 미지수입니다.

다만, 이도 저도 못한 신세가 되어 버린 게 비주얼 스튜디오 2002/2003의 CRT DLL인 msvcr71입니다. side by side assembly 형식으로 완전히 넘어간 것도 아니면서, 운영체제의 기본 지원도 못 받아서 이 파일의 배포와 로딩은 재래식으로 응응 프로그램이 그냥 알아서 해야 합니다. 이제 VS 2005 쓰라고 64비트 지원도 중단됐고 그냥 과도기 DLL로 역사 속으로 사라지게 되었습니다.
VS 2003도 서비스 팩 1이 나온지라 버전이 살짝 다른 msvcr71.dll 두 버전이 존재하게 되었지만 겉보기 상으로 차이는 없는 듯합니다.

Posted by 사무엘

2010/01/10 22:17 2010/01/10 22:17
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/7

« Previous : 1 : ... 9 : 10 : 11 : 12 : 13 : Next »

블로그 이미지

그런즉 이제 애호박, 단호박, 늙은호박 이 셋은 항상 있으나, 그 중에 제일은 늙은호박이니라.

- 사무엘

Archives

Authors

  1. 사무엘

Calendar

«   2024/12   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30 31        

Site Stats

Total hits:
3043922
Today:
1114
Yesterday:
2435