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

멀티태스킹 운영체제에는 한 주소 공간에 여러 프로그램들이 동시에 실행될 수 있다. 그렇기 때문에 그런 환경에서 동작하는 프로그램이라면 자기가 메모리의 어느 위치에 적재되는지를 신경써야 하며, 임의의 위치에 올라가더라도 잘 실행될 준비가 되어 있어야 한다.

과거 도스 시절에는 EXE말고 COM이라는 실행 파일이 있었는데, 이것은 최소한의 헤더 같은 것도 없고 코드와 데이터가 다 16비트 공간 안에 막혀 있으며(과거 메모리 모델로 치면 제일 작은 tiny와 동일), 메모리 주소도 고정 붙박이식이어서 오늘날의 컴퓨터 환경과는 도저히 어울릴 수 없는 과거 유물이 되어 있다.

윈도우 운영체제의 실행 파일이 사용하는 PE 포맷은 position dependent code 방식이다. 즉, preferred base라는 개념이 존재하여 자기는 32비트 주소 공간에서 어디에 적재되는 게 이상적인 경우라고 이미 가정하고 만들어져 있다는 뜻이다. 어떤 EXE나 DLL이 거기에 바로 적재가 가능하다면 가장 빠르고 좋지만, 만약 이 선호하는 주소에다 적재가 못 된다면 별도의 재배치 작업이 필요하다. 즉, 마치 퀵 정렬처럼 잘 될 때와 못 될 때의 성능 편차가 크며 일종의 모험이 동반된다는 뜻이다.

이는 유닉스의 shared library와는 다른 디자인이다. 그쪽은 이렇다할 preferred base 주소가 없으며, 코드 자체가 어느 메모리 주소에 적재되든 동일한 성능으로.. 하지만 딱히 최적의 성능을 발휘하지는 않는 구도로 동작한다. 일종의 힙 정렬이나 병합 정렬처럼 동작한다는 뜻.

윈도우 PE 포맷에는 실제로 실행되는 기계어 코드인 code 섹션도 있고 data라든가 리소스(rsrc)와 더불어 재배치 정보를 나타내는 섹션(reloc)도 있다. preferred base에서 동작하지 못할 때, 코드의 어느 부분을 쫙 수정해 주면 preferred base가 아닌 다른 지점을 기준으로 이 프로그램이 잘 돌아갈 수 있는지를 따로 기록해 놓은 것이다. 사실 이런 개념의 재배치 정보는 도스 EXE나 16비트 윈도우 EXE에도 있긴 했다.

그런데 32비트 윈도우 EXE만은(물론 64비트도 포함) 원칙상 이런 재배치 정보가 필요하지 않게 됐다. 32비트 환경부터는 모든 EXE들이 나만의 독립된 주소 공간을 가지기 때문에, preferred base가 무엇이든지에 무관하게 EXE는 무조건 내가 원하는 위치에 가장 먼저 적재되는 게 보장되기 때문이다.
그래서 통상 EXE들은 재배치 정보를 넣지 않는다. 필요가 없기 때문에, 넣지 않는 게 파일 크기를 줄이는 데도 도움이 되기 때문이다.

그럼에도 불구하고 재배치 정보가 필요한 경우는 다음과 같다.

1. EXE가 아닌 DLL은 반드시 필요하다. DLL은 다른 EXE의 밑에 붙어서 동작한다는 특성상 자신만의 주소 공간을 갖고 있지 않다. 나의 preferred base에 EXE라든가 다른 DLL이 이미 선점되어 있다면 응당 재배치가 필요하다. DLL은 그런 상황을 언제든지 대비해야 한다.

2. Win32s에서 돌아가는 32비트 프로그램이라면 EXE라도 재배치 정보가 반드시 있어야 한다. Win32s는 과거 윈도우 3.1에서 일부 32비트 프로그램을 구동하기 위해 제공되었던 일종의 운영체제 익스텐더로, 32비트 프로그램을 실행만 해 줄 뿐, 16비트 윈도우 3.1이 지니고 있던 시스템적인 한계는 그대로 답습하고 있다.
멀티스레딩을 지원하지 않으며 32비트까지 포함해 모든 EXE가 독립이 아니라 단일 주소 공간을 공유한다!
프로그램을 실행할 때마다 EXE의 핸들이 제각각인 값이 들어오며, 로딩도 preferred base에 정확하게 절대로 되지 않는다. 여기에 대해서는 추후 실험해 볼 예정.
실제로 테스트를 해 보면 핸들이 0x1xxx 이렇게 아주 작은 값으로 들어온다는 게 매우 흥미롭다. 윈도우 NT에서는 그렇게 낮은 주소는 아예 무조건 에러로 간주하는 반면, Win32s에는 그게 여전히 쓰인다는 소리이다. 포인터가 아니라 진짜로 다른 번호에 가깝다.

3. 비주얼 C++ 2008에서 추가된 '시작 주소 랜덤화' 기능을 사용하려면 재배치 정보가 필요하다.
보안을 위해 성능을 희생하듯, 윈도우 비스타부터는 PE 실행 파일도 일종의 position independent (과거의 dependent가 아닌) code처럼 써먹으려는 것 같다.
이 방식으로 빌드된 EXE는 운영체제가 일부러 EXE의 preferred base와는 다른 임의의 위치에다가 로딩을 해 준다. 즉, 이 EXE의 특정 지점의 코드나 데이터를 딱 맞춰 수정하는 프로그램이 제대로 동작하지 않게 위해서이다. (스타크로 치면 맵핵 같은 프로그램)

Posted by 사무엘

2010/05/04 08:36 2010/05/04 08:36
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/258

윈도우 운영체제가 인식하는 실행 파일 포맷인 PE(portable executable)의 헤더를 보면,
이 EXE/DLL이 실행되는 플랫폼(x86, x64, IA64 등등)이라든가, 이 실행 파일의 특성을 나타내는 플래그 등 여러 정보가 존재한다.
그런데 그 플래그 중에는 'Large address aware' 여부를 나타내는 플래그가 있다.
이건 무엇을 뜻하며, 왜 만들어진 것일까?

윈도우 NT는 도스의 잔재 없이 처음부터 순수 32비트로 개발된 운영체제이다.
32비트 공간에서는 최대 2^32 = 4GB 크기의 가상 메모리를 사용할 수 있는데, MS는 전통적으로 하위 2GB는 응용 프로그램이, 상위 2GB는 커널이 사용하는 구도로 운영체제를 설계했다.

그때는 램은커녕 하드디스크 용량도 4GB보다 훨씬 적던 시절. 그러니 그때 32비트는 가히 무한대에 가까운 공간이었으며, 메모리 분배를 어떻게 한다고 해도 이상할 게 없었다.
응용 프로그램은 언제나 하위 2GB만을 사용하다는 게 무슨 뜻일까?
포인터에서 32비트 크기가 다 쓰이는 게 아니라, 최상위 1비트는 절대로 1이 될 일이 없다는 말이다.

그래서 일부 잔머리 잘 굴리는 프로그래머들은 포인터에다가도 자신만의 1비트짜리 boolean 정보를 최상위 비트에다 얹고, 포인터를 쓸 일이 있으면 그 값을 잠시 제거한 후 사용했다고 한다. 흠좀무.

그런데 세상이 변해서 이제 램이 기가바이트급 스케일이 되었고, 32비트 공간만으로는 부족한 시대가 왔다. 본격적으로 64비트 시대가 도래하기 전부터 데이터베이스처럼 아주 memory-intensive한 프로그램을 돌리던 업계에서는, 유저와 커널을 2:2로 가르지 말고 3:1로 갈라서 응용 프로그램에다가 메모리를 좀더 많이 얹어 달라고 MS에다 끊임없이 요구했다. 그래서 MS는 '물리 주소 확장' 모드라는 걸 만들어 줬다.

사실, 커널도 메모리, 좀더 정확히 말하면 주소 공간이 의외로 많이 필요하다. 2:2도 오히려 부족한 감이 있다. 커널 코드를 얹고 각종 커널 오브젝트를 관리하는 메모리만 필요한 게 아니기 때문이다. 가상 메모리라는 시스템은 그 개념상 메모리를 관리하기 위한 메모리도 요구하는 법. 그와 관련되어 방대한 공간이 필요하며, 디바이스 드라이버를 얹고 돌리기 위한 메모리 등등도 따지면 결코 만만한 수준이 아니다.

3:1로 가르면 응용 프로그램이야 사용 가능한 메모리가 좀더 늘며, 종전에는 응용 프로그램이 한번에 약 1GB 남짓밖에 매핑을 못 하는 memory mapped file도 훨씬 더 큰 크기까지 확장할 수 있다. 하지만 만들 수 있는 프로세스/스레드 수가 감소하며 네트웍이라든가 운영체제의 전반적인 기능상의 한계가 매우 커지고, 운영체제가 이론적으로 관리 가능한 총 물리 메모리의 양도 줄어든다! 이 tradeoff를 반드시 잊지 말아야 한다.

그런데 문제는...
그렇게 3:1로 응용 프로그램의 메모리 주소를 확장하면...
드디어 최상위 비트가 1인 포인터 값이 응용 프로그램으로 오는 게 가능해진다는 것.
그렇다면, 예전에 놀고 있던 최상위 비트를 다른 용도로 활용하던 프로그램을 이런 확장 환경에서 돌리면.....;;; 더 이상의 자세한 설명은 생략한다.

그래서 호환성을 목숨처럼 1순위로 강조하는 MS는, 아무 프로그램이나 일방적으로 넓어진 포인터를 주는 게 아니라, 넓어진 포인터를 줘도 안전하다고 플래그가 따로 지정되어 있는 프로그램에 대해서만 제 기능을 다하도록 하는 정책을 선택했다. 그것이 바로 large address awareness이다. 이 플래그가 없이 빌드된 프로그램은 여전히 메모리를 2GB씩밖에 못 쓴다. 마치 윈도우 XP 이후에도, 별도의 매니페스트를 내장하고 있지 않은 옛날 프로그램들은 비주얼 스타일 테마가 적용되지 않는 것과 같은 맥락으로 말이다.

단, 이건 EXE에 한해서이다. DLL은 그런 선택의 권리가 없다. 확장 주소가 지원되는 EXE에 붙을 수도 있고 지원 안 되는 EXE에 붙을 수도 있으며, 어느 때건 동작을 잘 해야 한다. 따라서 DLL은 반드시 확장 주소를 지원하도록 작성되어야 한다.

본격적으로 64비트 환경이 되면서 확장 주소의 진정한 의미가 드러났다. 이제는 상위 1비트 정도가 아니라 아예 테라바이트급 메모리 주소에도 접근 가능해야 하며, 64비트 프로그램은 '확장 주소 지원' 플래그가 반드시 있어야 한다. 이 플래그가 없으면, 비록 x64 내지 IA64 아키텍처용으로 만들어진 64비트 프로그램이라 할지라도 포인터의 주소로는 여전히 무려 2GB 이내의 값만 들어온다. -_-
포인터 크기를 4바이트 int 크기로 하드코딩하고 제작된 무개념 프로그램을 최대한 쉽게 64비트로 포팅할 수 있게 배려한 것이다. 물론 이 역시 EXE에 한해서이지만 말이다.

large address aware 옵션은 비주얼 C++의 x86 플랫폼에서는 호환성 차원에서 디폴트로 꺼져 있다. 즉, 사용자가 별도로 옵션을 켜지 않으면, 2GB까지만 인식하는 프로그램을 만든다.
하지만 x64/IA64 플랫폼에서는 사용자가 별도로 이 옵션을 끄지 않으면 디폴트로 켜져 있으며, 코드가 2GB 정도가 아니라 4GB 이상의 공간도 안전하게 인식하는 것으로 간주한다. 둘이 묘한 차이가 있다는 것을 기억하자.

물론 굳이 램이 4GB가 아니더라도 64비트는 CPU가 한번에 정보를 처리하는 단위 자체가 더 크다는 점 하나만으로 32비트보다 대용량 데이터를 처리하는 성능이 더 뛰어나다. double 실수형을 하나 스택에 얹을 때만 해도 32비트에서는 CPU 명령을 최소 둘 이상 써야 하는데 64비트에서는 한 번만에 끝난다는 소리이지 않은가. 그렇기 때문에 램 용량이 32비트 크기를 초과하기 전부터도 64비트 프로세서가 개발되어 일부 제한된 영역에서 쓰이기도 했던 것이다.

잘 알다시피 64비트 윈도우는 과거 16-32비트가 그랬던 것처럼 그 정도로 지저분한 호환 계층은 제공하지 않으며, 한 프로세스 공간에 64-32비트 코드가 공존하는 것을 허용하지 않는다. 그래도 윈도우 핸들값은 여전히 32비트 범위 안에만 존재하며 32와 64비트가 값을 그대로 공유 가능하다는 게 신기하다. 하긴, 윈도우 9x에서는 윈도우 핸들값이 아예 16비트 범위에 있었지만 말이다. ^^ 썽킹이라는 말도 참 오랜만에 다시 듣는다.

Posted by 사무엘

2010/04/12 09:12 2010/04/12 09:12
, ,
Response
No Trackback , 9 Comments
RSS :
http://moogi.new21.org/tc/rss/response/242

내 프로그램의 중복 실행 여부를 판단하려면? (물론 윈도우 프로그래밍 기준)

실행 직후에 자신만 식별할 수 있는 이름으로 커널 오브젝트를 만들어서, 이놈의 생성 여부로 판단하는 게 제일 무난하고 안전하다. 커널 오브젝트라 함은 메모리 맵드 파일, 뮤텍스, 이벤트 등 이름의 scope가 전역적인 어느 것이라도 될 수 있겠다.

다른 방법으로 중복 실행을 판단하는 방법은 크게 윈도우 아니면 파일로 식별하는 것으로 나뉘는데, 커널 오브젝트만치 완전하지는 못하다. 그 이유를 지금부터 설명하겠다.

※ 응용 프로그램이 생성한 윈도우로 판단하는 법

FindWindow 함수로 나만이 지정하는 윈도우 클래스 이름이나 윈도우 캡션 이름을 검색하여 그게 존재하면 그 윈도우로 포커스를 옮겨 버리고 나는 실행을 종료한다. 대개, 이미 존재하는 인스턴스로 포커스를 옮겨 주는 작업이 필요할 것이므로 윈도우로 검색하는 방법은 어지간해서는 상당히 간편하고 직관적이고 좋은 방법이긴 하다. 다만,

만약 MFC 같은 프레임워크로 프로그램을 개발하고 있었다면, 메인 윈도우의 클래스 이름을 나만의 명칭으로 변경하기 위해 PreCreateWindow 같은 함수를 번거롭게 오버라이드해야 한다.

또한 클래스 이름이 아니라 캡션 이름으로 검색하는 것은 어지간해서는 피해야 한다. 캡션 이름 검색은 모든 top-level 윈도우들에 WM_GETTEXT 메시지를 보내는 방법으로 행해지기 때문에 오버헤드가 클 뿐만 아니라, 이미 실행된 내 프로그램 윈도우가 작업 중이어서 응답을 안 하고 있다면 프로그램 실행이 의도대로 되지 않을 우려가 크다.

윈도우로 검색하는 방법은 근본적으로 큰 약점이 있다. 일반적으로 프로그램이 실행된 직후 로딩, 각종 초기화를 끝내어 메인 윈도우를 생성하기까지는 적지 않은 시간이 소요된다는 것이다. 커널 오브젝트를 생성하는 것처럼 즉시 생성되는 게 아니다. 그렇기 때문에 첫 인스턴스가 아직 메인 윈도우를 만들기 전에 사용자가 실수나 고의로 또 엔터를 눌러서 둘째 인스턴스까지 실행한 경우 여전히 프로그램이 두 개가 실행되어 버릴 수가 있다. 프로그램이 어떤 경우에도 절대로 두 인스턴스 이상이 실행돼서는 안 되는 중요한 프로그램인 경우 윈도우 검색의 결과에만 의존해서는 안 된다.

※ 파일 차원에서 판단하는 법

윈도우 3.1 시절에는 WinMain 함수의 둘째 인자인 hPrevInstance를 살펴보는 것만으로도 내 프로그램의 중복 인스턴스를 판단할 수 있었다.
32비트 이후의 운영체제에서는 인스턴스 핸들의 의미가 한 주소공간 안의 포인터로 완전히 바뀌어 버렸기 때문에, 주소공간 자체가 독립적인 프로세스를 식별할 수는 없게 되었다. 오로지 그 주소공간 안에 로드되어 있는 여러 DLL 같은 모듈들만 식별할 수 있다.

그 반면, 지금도 EXE 내지 DLL 내부에 공유 가능한 섹션을 따로 생성하여 여기에 중복 인스턴스와 관련된 정보를 간단하게 집어넣을 수도 있다. 즉,
#pragma data_seg()
#pragma comment(linker, "/Section:SHARED,RWS")
이런 지시문 안에다가 전역변수를 선언하면 그 변수는 운영체제의 가상 메모리 상으로 나의 모든 인스턴스들이 공유하게 된다는 뜻이다. 자세한 것은 MSDN 참고. 번거롭게 메모리 맵드 파일 API를 호출할 필요 없이 간단한 데이터 공유에는 이 방법이 굉장히 편리하다.

이렇게 파일 차원에서 식별하는 방법은 윈도우 차원에서 식별하는 방법이 잠재적으로 갖고 있는 부작용들이 전혀 없어서 좋으나, 말 그대로 파일에 전적으로 종속적이라는 큰 한계가 있다.
같은 EXE를 이름만 바꿔 복사해서 실행한 것은 중복 인스턴스로 전혀 판단하지 못한다는 것이다. 이 점이 매우 중요하며, 이는 대부분의 경우 원치 않는 결과일 것이다. 결국 실행 파일 그 자체가 아니라 그 실행 파일이 만들어 놓은 결과를 추적해서 중복 실행을 판단하는 접근 방식이 필요하게 된다.

Posted by 사무엘

2010/02/08 22:38 2010/02/08 22:38
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/176

레거시 코드

꽤 희귀한 기회 덕분에,
한때 시대를 풍미했던 상업용 소프트웨어의 소스를 구경하게 됐습니다.

윈도우 3.1에서 돌아가는 녀석이었거든요.
저야 도스박스에다 윈도우 3.1 + 비주얼 C++ 1.5도 갖추고 있어서 이걸로 돌려도 되지만
32비트로 포팅을 해 보고 싶어서 최신 개발툴로 프로젝트를 만들고 빌드를 해 봤습니다.
당연히 바로 빌드는 안 되고 수백여 군데에서 에러가 나는데...
16비트 윈도우 코드의 특징을 한눈에 알 수 있었습니다.

1. precompiled header가 없었는지 매 소스 파일마다 <windows.h> 인클루드.

2. 말로만 듣던 far, huge 포인터의 압박. 메모리 모델별로 malloc이라든가 포인터를 다루는 함수도 따로 존재. _fmalloc 같은.

3. POINT 구조체. 옛날엔 POINT 구조체의 x,y 멤버가 16비트 크기였기 때문에 32비트 long 하나로 POINT를 나타내는 게 관행이었으며, 심지어 lParam *을 POINT *로 바로 캐스트하는 MAKEPOINT 같은 매크로까지 존재했습니다. 하지만 지금은 좌표계가 32비트로 커지고 이것 때문에 MoveTo, SetWindowOrg, SetViewportExt 같은 함수들은 모두 Ex가 붙은 형태로 프로토타입이 바뀌게 됐죠.

4. 옛날에는 핸들(HANDLE, HINSTANCE, HGDIOBJ, HWND 등등)은 전부 void *의 typedef였고, 아무 형변환 없이 마음대로 섞어 썼습니다. 이 폐해를 막으려고 지금은 STRICT(엄격한 타입 구분)라는 개념이 추가됐지만 어마어마한 분량의 레거시 코드를 컴파일시키려면 그 옵션을 꺼야 하죠.

5. WM_CTLCOLOR 메시지. 옛날에는 이 메시지 하나만 왔지만 지금은 WM_CTLCOLORBTN, DLG, EDIT, LISTBOX 등으로 세분화됐습니다. 다만, 16비트 관행을 물려받은 MFC는 여전히 OnCtlColor 라는 한 함수로 이 메시지들을 모두 처리합니다.

6. 멀건 윈도우 3.1 대화상자에다가 은빛 3차원 효과를 입혀 주는 ctl3d.dll과 교신을 하는 흔적. 그러고 보니 그때는 이런 효과가 지금의 공용 컨트롤 6.0 매니페스트 같은 그런 매개체였던 것 같습니다. MFC에도 CWinApp 클래스에 Enable3dControls 같은 멤버 함수가 있을 정도였는데, 이제는 완전히 deprecated로 처리되죠.

7. DLL도 아니고 EXE에 웬 export 속성이 지정된 함수? 그것도 __declspec(dllexport) 같은 지금 통용되는 문법으로 작성된 것도 아닙니다.
16비트 윈도우 EXE는 헥사 에디터로 들여다보면, 윈도우/대화상자 프로시저처럼 운영체제로부터 호출을 받는 콜백 함수들의 이름을 따로 name table에다 등재를 해 놓는 경우가 많더군요. 꼭 그렇게 할 필요는 없는데 왜 그러는지 궁금.. 디버깅 정보와 관련이 있는지, 아니면 속도를 높이려고 그러는지 이유는 모르겠습니다.

8. C++ 문법이 바뀌어서 옛날에는 허용되었으나 지금은 허용되지 않는 몇몇 문법들

맥이나 리눅스 같은 다른 플랫폼들은 도스 같은 극악의 호환성 발목은 없었겠지만, 16비트를 겪은 시절조차 전혀 없었는지가 문득 궁금해지네요.

참고로 저는 C/C++은 32비트 도스 익스텐더 환경에서(왓콤, DJGPP) 처음 시작했고 16비트는 거의 접한 적이 없는 세대입니다. ^^

Posted by 사무엘

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

thread local storage

컴퓨터 프로그램에서 뭔가 데이터를 저장할 기억 장소를 확보하는 방법은 크게 다음과 같은 세 가지이다.

먼저, 함수 안에서 선언하는 지역 변수이다. 지역 변수는 스택에다 할당되며, 그 함수와 사실상 일심 동체이기 때문에 거듭 실행되면 계속 스택을 점유하여 기억 장소도 계속 할당된다. 따라서 스레드 충돌 걱정을 할 필요도 없으며, scope을 벗어나면 소멸도 100% 자동으로 되기 때문에 memory leak가 생길 여지도 전혀 없다. 끼칠 수 있는 영향의 범위 자체가 가장 좁다.

그리고 전역/static 변수가 있다. 이들은 scope이 말 그대로 굉장히 정적이며, 프로그램 실행 기간 내내 살아 있고 프로세스 내부의 모든 스레드들이 공유한다. 잘 알다시피 static은 메모리에 저장되는 형태는 전역 변수와 완전히 같고, 접근성은 지역 변수와 같은 중간 존재이다.
이런 변수가 C++ 개체라면, 생성자나 소멸자 함수가 호출되어야 하는데 이는 main 함수의 실행을 전후하여 CRT 라이브러리가 알아서 해 준다.

마지막으로 heap에다 할당되는 동적 메모리가 있다. 이것은 가장 동적이고 자유로운 야생마 같은 형태의 메모리로, 프로그램 언어 차원에서 보장해 주는 scope이란 개념이 없다. 전적으로 프로그램이 양도 얼마든지 할당하고 아무 스레드에서나 해제 가능하다. 그 대신 C/C++에서 온갖 포인터 관련 버그, memory leak의 온상이 되는 존재이기도 하다.

그런데 멀티스레드 환경이 보편화하면서 첫째와 둘째의 또다른 형태의 중간에 속하는 scope이 필요해진 게 있으니, 바로 한 스레드 안에서만 global인 공간이다.
사실 표준 C 라이브러리조차도 멀티스레드 환경을 고려하지 않고 디자인되어 지금 문제되고 있는 요인들이 적지 않다.

함수의 실행 결과를 나타내는 에러 코드를 예전에는 그저 전역 변수 하나에만 집어넣으면 됐지만 멀티스레드 환경에서는 최소한 스레드마다 독립된 공간에 저장해야 한다.
strtok 함수는 예전에는 토큰 해석 위치를 그저 static 변수에다가 집어넣으면 됐지만, 이랬다가는 여러 스레드가 동시에 이 함수를 호출했을 때는 재앙을 각오하야 한다.
qsort 함수는 콜백 함수만 달랑 받지 콜백 함수에 따로 사용자가 지정한 데이터 따위를 넘겨 주지는 않는다. 결국 콜백 함수가 외부로부터 정렬 옵션 같은 걸 얻고자 한다면 전역 변수로부터 참고를 해야 하는데, 서로 다른 정렬 옵션으로 여러 스레드가 동시에 qsort를 호출하면 어떤 상황이 벌어지게 될까?

이런 기초적인 문제를 해결하기 위해 멀티스레드 운영체제는 thread local storage를 관리하는 기능을 제공한다. 윈도우 API에서는 TlsAlloc()으로 TLS 슬롯 번호를 하나 받아서 그 값을 전역 변수로 저장한다. (해제는 나중에 TlsFree()로) 그래서 TlsGetValue()와 TlsSetValue()에다가 아까 받은 TLS 슬롯을 넘겨줌으로써 machine word와 같은 크기의 정수/포인터 값을 읽고 쓰면 된다.

한 프로세스 안에서 동일한 슬롯 번호이지만, 이 함수를 호출하는 스레드가 무엇이냐에 따라 서로 제각기 독립된 값을 읽고 쓸 수 있음이 보장된다는 것이다. 스레드별로 공유해야 하는 값이 그냥 정수 하나라면 그 값을 바로 집어넣으면 되지만, 더 큰 영역의 메모리라면 따로 heap에다 할당한 메모리의 포인터를 집어넣으면 된다.

한 프로세스가 가질 수 있는 TLS 슬롯은 윈도우 95/NT4 시절에는 64개였다. 그러나 윈도우 2000/XP/비스타급에서는 수백, 1천 개가 넘어가도 괜찮게 바뀌었다. 나뿐만이 아니라 한 프로세스에 같이 로드되어 있는 다른 수십 개의 DLL들이 다 TLS 슬롯을 요청하기도 하기 때문에 TLS 슬롯 요청은 최소화하는 습관을 들여야 한다. TLS는 EXE보다도, 내가 생성하지 않은 스레드에 느닷없이 붙어서 돌아가야 하기도 하는 DLL을 만들 때 더욱 필요한 존재이다.

CRT 라이브러리의 DLL 에디션도 strtok 같은 함수를 구현하기 위해 TLS를 사용한다. 직전의 토큰 위치를 TLS 슬롯이 가리키고 있는 별도의 메모리에다 저장해 놓고 그때 그때 참고한다는 것이다.
또한 qsort처럼 콜백 함수가 사용할 데이터를 별도로 얻는 방법이 없는 함수를 사용하면서도 스레드 안전은 보장되게 코드를 짜야 할 때, 그 데이터를 TLS에다가 저장해 놓은 뒤 내가 짠 콜백 함수는 그 TLS를 사용하도록 만들 수도 있다.

TlsGet/SetValue()는 일종의 없는 scope을 새로 구현하는 함수이다. 그렇기 때문에 함수 실행 결과값을 기존 scope에 속하는 전역/지역 변수에다가 저장해 놓는다는 게 완전 무의미하다. 그러므로 콜백 함수가 TLS를 사용한다면 이 함수도 매번 무지막지한 빈도로 호출해야 한다. 따라서 이 함수는 다른 어떤 함수들보다도 성능에 초점을 맞춰 구현되어 있으며, 인자값의 검증을 거의 하지 않는다고 MSDN에 명시되어 있기도 하다.

물론 근본적으로 비주얼 스튜디오 2005에서 도입된 strtok_s는 TLS를 꺼낼 필요가 없이 토큰 위치를 아예 별도의 인자로 넘겨준 포인터에다 저장하도록 프로토타입이 바뀌어, 문자열을 중첩적으로 토큰화할 수도 있게 됐다.
그리고 qsort_s는 콜백 함수에다 넘겨줄 데이터를 별도로 같이 지정도 할 수 있다. 이렇게 하는 게 전역 변수급(TLS 포함) 메모리의 사용을 줄이고, 가능한 한 함수 인자와 지역 변수만 사용함으로써 모듈 사이의 독립성도 높이는 좋은 방법일 것이다.

DLL의 함수를 매번 LoadLibrary, GetProcAddress, FreeLibrary로 퍼 와서 쓰는 게 불편하기 때문에 import library가 존재하듯이, TLS도 비주얼 C++의 컴파일러 확장을 이용하여 좀더 쉽게 사용하는 방법이 있긴 하다. __declspec(thread)로 선언된 변수는 컴파일되는 바이너리의 내부에 .data, .text 처럼 섹션이 .tls라고 하나 더 생기고 거기에 보관된다. 하지만 이건 운영체제 관점에서 오버헤드가 굉장히 크고 옛날 운영체제에서는 동작하지 않기도 해서 잘 쓰이지 않는다.
#pragma data_seg로 섹션을 하나 더 만들어서 share 속성을 주어, 메모리 맵을 쓰지 않고 모든 프로세스에서 공유되는 메모리 영역을 만드는 것과 비슷한 차원인데, 물론 사용되는 기술 계층은 서로 차이가 있다.

우리가 다른 프로세스에서 멀티스레드로 동작하는 DLL을 만들고 있는데 우리 프로그램이 스레드마다 꽤 복잡한 C++ 오브젝트라든가(별도의 메모리 내지 리소스의 할당/해제가 필요한) 대용량 오브젝트를 운영해야 한다면 어떻게 해야 할까? TLS 슬롯에는 그 포인터밖에 집어넣을 수 없으며 메모리는 별도로 할당해야 한다. 그러나 디버거 쪽 훅을 설치하지 않는 이상 스레드의 생성, 소멸을 일일이 다 감시할 수는 없다. 그 대신 스레드마다 새로 생성된 TLS 슬롯의 초기값은 언제나 0임이 보장되기 때문에 오브젝트의 생성은 TlsGetValue의 값을 매번 검사하여 NULL일 때 하면 된다.

소멸은 약간 주의해야 한다. 프로세스의 실행 중에 스레드가 하나 실행이 끝났다면 DllMain에 DLL_THREAD_DETACH 통지가 오기 때문에 그때 우리 스레드 TLS 슬롯이 가리키는 포인터를 해제해 주면 되나, 문제는 모든 스레드의 소멸에 대해 일일이 이런 통지가 오지는 않는다는 것이다. primary thread의 실행이 종료됨으로써 DLL_PROCESS_DETACH 한 방으로 끝인 경우도 빈번하기 때문이다.

한 스레드가 여타 스레드의 모든 TLS 슬롯 값들을 enumerate하는 방법은 윈도우 API에 존재하지 않는다. 그렇기 때문에 각 스레드가 갖고 있는 포인터들은 별도의 전역 변수 링크드 리스트 같은 데에 별도로 관리하고 있다가 프로세스가 단번에 종료되면 이들을 한꺼번에 해제도 해 줘야 한다. 어차피 프로세스가 종료되는 마당에 memory leak 정도야 문제될 게 없지만, inter-process한 커널 오브젝트처럼 "cleanup"이 반드시 제때에 이루어져야 하는 자원도 있을 수 있기 때문이다.

물론 가장 이상적인 경우는 DLL을 만드는 우리가 매 스레드별로 Init 내지 Uninit 함수를 만들어서 우리 DLL을 사용하는 응용 프로그램이 스레드 실행의 시작과 종료를 알려 주는 것이다. 이렇게만 하면 모든 논란이 일축된다.

Posted by 사무엘

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

스레드 동기화


컴퓨터가 충분히 발전하여 오늘날의 운영체제 시스템 개념을 실현하게 된 것은 중앙 처리 장치가 최소한 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

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

블로그 이미지

철도를 명절 때에나 떠오르는 4대 교통수단 중 하나로만 아는 것은, 예수님을 사대성인· 성인군자 중 하나로만 아는 것과 같다.

- 사무엘

Archives

Authors

  1. 사무엘

Calendar

«   2021/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:
1696857
Today:
619
Yesterday:
481