지금으로부터 30년쯤 전에 도스용으로 만들어졌던 프로그래밍 툴 중에는 자기 언어로 만들어진 예제 프로그램으로 그럴싸한 게임을 제공하는 경우가 있었다.
QBasic의 경우, 포트리스 내지 Scorched Earth와 비슷한 형태의 턴 기반 슈팅인 '고릴라'가 유명했으며.. 길다란 뱀을 사방으로 적절히 조종하면서 아이템(?)을 먹는 퍼즐인 nibbles도 있었다.

사용자 삽입 이미지사용자 삽입 이미지

아이템을 먹을수록 뱀은 길이가 더 길어지며, 머리가 벽은 물론이고 자기 몸통과도 부딪치지 않도록 조종을 잘 해야 한다. 그리고 레벨이 올라갈수록 뱀의 이동 속도가 더 올라가고 장애물도 더 많아져서 게임 진행이 더 어려워진다.
영문판 원판은 80*25 텍스트 화면에서도 아스키 그래픽 문자를 적절히 이용해서 글자 한 칸을 상하로 쪼개어 세로 공간을 두 배로 늘리는 편법을 구현했다. 하지만 한글판에서 제공된 nibbles는 문자 코드의 한계로 인해 그런 게 다 삭제되었다.

그런데 가만히 생각해 보니 마소 말고 볼랜드 개발툴에서 제공한 예제 프로그램 중에는 가히 이 분야의 끝판왕이 있었다. 번듯한 체스 게임이 컴퓨터 AI까지 포함해서 소스가 통째로 제공되었던 것이다.

사용자 삽입 이미지

이거 기억하는 분 계신가..?
그런데 이게 bgidemo보다 훨씬 덜 유명하고, 본인도 지난 수십 년 동안 얘의 존재를 까맣게 잊고 있었던 이유는.. 아무래도 아무 버전에서나 쉽게 볼 수 있는 예제는 아니었기 때문이지 싶다.
즉, 보급형인 Turbo가 아니라 기함급인 Borland라는 브랜드가 붙은 C++ 내지 Pascal을 설치하고, Windows 개발 환경에다 자체 프레임워크 라이브러리까지 다 선택해야 얘를 구경하고 돌려볼 수 있다.

이 예제 프로그램의 이름은 볼랜드에서 개발한 C++용 Windows API 프레임워크의 이름을 딴 OWL Chess였다.
하지만 내 기억이 맞다면 Turbo Vision 기반의 도스용 체스 예제도 있었다. 체스판과 말을 그래픽 모드가 아니라 텍스트 모드에서 꽤 기괴한 색과 특수문자를 동원해서 표현했던 걸로 기억하는데.. 정확한 내역은 너무 오래돼서 잘 모르겠다.

Windows용 OWL Chess는 이런 식으로 동작했던 걸로 본인은 기억한다.

  • 16비트 전용이다. 32비트 에디션에도 포함됐다거나, Delphi 및 C++ Builder 같은 후대의 컴포넌트 기반 RAD툴로 리메이크 됐다는 소식은 내가 아는 한 없다. 그러니 얘는 Windows XP에서 실행됐을 때도, 저 스크린샷에서 보다시피 프로그램의 제목 표시줄에 테마가 적용돼 있지 않다.
  • 역시 저 스크린샷에서 묘사된 바와 같이, 창 크기는 고정 불변이다. 요즘처럼 모니터가 크고 화면 해상도가 높은 시대엔 크기 조절이 안 되는 프로그램은 사용자에게 좋은 인상을 주기 어려울 것이다.
  • 키보드 포커스가 딴데로 넘어가서 프로그램이 비활성화 되면 즉시 게임판이 가려지고 pause 모드로 바뀐다.
  • 컴퓨터 AI는 1990년대의 바둑 같은 보드 게임 AI들이 그랬던 것처럼 규칙 기반으로 move를 평가하고, 재귀적으로 수읽기를 하면서 알파-베타 가지치기로 복잡도를 제어하는 식으로 구현됐다. 생각하는 데 시간이 많이 걸리긴 하지만, 멀티스레드라는 것도 없던 시절에 이 동작이 찔끔찔끔 idle time processing만으로 잘 만들어져 있다. 컴퓨터의 생각이 현재 어느 정도까지 진행됐는지가 수시로 현란하게 시각적으로 표시되기 때문에 지겹지 않다.

하긴, 1990년대 초중반에는 프로그래밍깨나 공부 좀 한 사람들이 도스의 그래픽 모드에서 아기자기한 오목· 장기 게임을 구현해서 PC 통신 자료실에 무료로 공개한 게 많았다. 아, 심지어 화투 치는 고도리...도 그 시절부터 있었다.
또한 그 시절에 유명한 프로그래밍 기술 간행물이던 '비트 프로젝트' 시리즈에도 초창기엔 Borland C++로 개발한 Windows용 장기 게임이 있었다.

지금이야 국내에서 유료 판매까지 되고 있는 장기 게임 프로그램으로는 '장기도사'가 있다. 하지만 그 전에는 '바다장기'라는 프로그램도 있었는데, 얘가 내 기억이 맞다면 저 원조 OWL Chess의 소스를 기반으로 만들어진 듯했다.
프로그램의 외형과 동작이 굉장히 비슷하게 느껴졌었기 때문이다. 또한 바다장기도 검색을 해 보면 16비트스러운 스크린샷밖에 안 나오는 게 더욱 비슷하다.

사용자 삽입 이미지

그래도 서양의 체스와 동양의 장기가 완전히 동일한 게임은 아닐 텐데, 체스 AI를 장기 AI로 룰을 개조하는 건 건 아무나 할 수 있는 일이 아니었을 것이다. 그리고 그 원판 AI 코드도 move를 기술하고 평가하는 룰 계층만 바꿔 주면 어지간한 보드 게임의 AI에 모두 대응 가능하도록 상당히 추상적이고 깔끔하게 잘 만들어져 있었던 모양이다. 바다장기는 AI를 '추론 엔진'이라는 용어를 써서 표현했다.

일개 예제 프로그램의 체스 AI가 전문 상업용 AI에 비할 바는 아니겠지만.. 이 정도만 해도 어디인가? 지금 저 프로그램의 소스를 다시 볼 수 있으면 보드 게임 AI의 구현과 관련해서 많은 통찰을 얻을 수 있을 텐데 아쉽다. 얘의 소스만 어디 github에 따로 올라와도 될 텐데 말이다.
본인은 체스는 룰조차도 모르지만.. 그래도 학창 시절에 오목과 스크래블이라는 보드 게임 AI를 연구했던 이력이 있는 사람이어서 이런 쪽에 더욱 흥미를 느낀다.

Posted by 사무엘

2021/03/17 19:35 2021/03/17 19:35
, , , ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1866

C/C++은 성능을 위해 컴파일러나 언어 런타임이 프로그래머의 편의를 위해 뭔가를 몰래 해 주는 것을 극도로 최소화한 언어이다. 꾸밈 없고 정제되지 않은 '날것 상태 raw state'에다가 고급 프로그래밍 언어의 패러다임과 문법만을 얹은 걸 추구하다 보니, '초기화되지 않은 쓰레기값'이라는 게 존재하는 거의 유일한 언어이기도 하다.

같은 프로그램이라도 이제 막 선언된 변수나 할당된 메모리 안에 무슨 값이 들어있을지는 컴퓨터 운영체제의 상태에 따라서 그때 그때 달라진다. 반드시 0일 거라는 보장은 전혀 없다. 오히려 0 초기화는 별도의 CPU 부담이 필요한 인위적인 작업이다. global/static에 속하는 메모리만이 무조건적인 0 초기화가 보장된다.

그렇다고 쓰레기값이라는 게 완벽하게 예측 불가이면서 통계적인 질서를 갖춘 난수인 것도 물론 아니다.
그리고 Visual C++에서 프로그램을 debug 세팅으로 빌드하면 쓰레기값이라는 게 크게 달라진다. C++ 개발자라면 이 사실을 이미 경험적으로 충분히 알고 있을 것이다.

1. 갓 할당된 메모리의 기본 쓰레기값: 0xCC(스택)와 0xCD(힙)

가장 먼저, 스택에 저장되는 지역변수들은 자신을 구성하는 바이트들이 0xCC로 초기화된다. 다시 말해 디버그 빌드에서는 int x,y라고만 쓴 변수는 int x=0xCCCCCCCC, y=0xCCCCCCCC와 얼추 같게 처리된다는 것이다. 디스어셈블리를 보면 의도적으로 0xCC를 대입하는 인스트럭션이 삽입돼 들어간다.

0은 너무 인위적이고 유의미하고 깔끔한(?) 값 그 자체이고, 그 근처에 있는 1, 2나 0xFF도 자주 쓰이는 편이다. 그에 비해 0xCC는 형태가 단순하면서도 현실에서 일부러 쓰일 확률이 매우 낮은 값이다. 그렇기 때문에 여기는 초기화되지 않은 쓰레기 영역이라는 것을 시각적으로 곧장 드러내 준다.

int a[10], x; a[x]=100; 같은 문장도 x가 0으로 깔끔하게 자동 초기화됐다면 그냥 넘어가지만, 기괴한 쓰레기값이라면 곧장 에러를 발생시킬 것이다.
또한, 복잡한 클래스의 생성자에서 값이 대입되어 초기화된 멤버와 그렇지 않은 멤버가 뒤섞여 있을 때, 0xCC 같은 magic number는 탁월한 변별력을 발휘한다.

타겟이 align을 꼼꼼하게 따지는 아키텍처라면, 쓰레기값을 0x99나 0xDD 같은 홀수로 지정하는 것만으로도 초기화되지 않은 포인터 실수를 잡아낼 수 있다. 32비트에서 포인터값의 최상위 비트가 커널/사용자 영역을 분간한다면, 최하위 비트는 align 단위를 분간할 테니 말이다. 뭐 0xCC는 짝수이다만..

0xCC라는 바이트는 x86 플랫폼에서는 int 3, 즉 breakpoint를 걸어서 디버기의 실행을 중단시키는 명령을 나타내기도 한다. 그래서 이 값은 실행 파일의 기계어 코드에서 align을 맞추기 위해 공간만 차지하는 잉여 padding 용도로 위해 듬성듬성 들어가기도 한다.
Visual C++ 6 시절엔 거기에 말 그대로 아무 일도 안 하는 nop를 나타내는 0x90이 쓰였지만 2000년대부터는 디버깅의 관점에서 좀 더 유의미한 작용을 하는 0xCC로 바뀐 듯하다. 정상적인 상황이라면 컴퓨터가 이 구역의 명령을 실행할 일이 없어야 할 테니까..

다만, 힙도 아니고 스택 지역변수의 내용이 데이터가 아닌 코드로 인식되어 실행될 일이란 현실에서 전혀에 가깝게 없을 테니, 0xCC는 딱히 x86 기계어 코드를 의식해서 정해진 값은 아닐 것으로 보인다.

Visual C++의 경우, 스택 말고 malloc이나 new로 할당하는 힙(동적) 메모리는 디버그 버전에서는 내용 전체를 0xCD로 채워서 준다. 0xCC보다 딱 1 더 크다. 아하~! 이 값도 평소에 디버그 하면서 많이 보신 적이 있을 것이다.
힙의 관리는 컴파일러 내장이 아니라 CRT 라이브러리의 관할로 넘어기도 하니, 0xCD라는 값은 라이브러리의 소스인 dbgheap.c에서도 _bCleanLandFill이라는 상수명으로 확인할 수 있다.

2. 초기화되지 않은 스택 메모리의 사용 감지

C/C++ 언어는 지역변수의 값 초기화를 필수가 아닌 선택으로 남겨 놓았다. 그러니 해당 언어의 컴파일러 및 개발툴에서는 프로그래머가 초기화되지 않은 변수값을 사용하는 것을 방지하기 위해, 디버그용 빌드 한정으로라도 여러 안전장치들을 마련해 놓았다.
그걸 방지하지 않고 '방치'하면 같은 프로그램의 실행 결과가 debug/release별로 달라지는 것을 포함해 온갖 골치아픈 문제들이 발생해서 프로그래머를 괴롭히게 되기 때문이다.

int x; printf("%d", x);

이런 무식한 짓을 대놓고 하면 30년 전의 도스용 Turbo C에서도 컴파일러가 경고를 띄워 줬다. Visual C++의 에러 코드는 C4700. 그랬는데 한 Visual C++ 2010쯤부터는 이게 경고가 아니라 에러로 바뀌었다.
그리고 그 뿐만이 아니다.

int x;
if( some_condition_met(...)) x=0;
printf("%d", x);

이렇게 문장을 약간만 꼬아 놓으면 초기화를 전혀 안 하는 건 아니기 때문에 컴파일 과정에서의 C4700을 회피할 수 있다. 하지만 보다시피 if문의 조건이 충족되지 않으면 x가 여전히 초기화되지 않은 채 쓰일 수 있다. 이건 정적 분석 정도는 돌려야 감지 가능하다.
(그런데, 글쎄.. 함수의 리턴이 저런 식으로 조건부로 불완전하게 돼 있으면 컴파일만으로도 C4715 not all control paths return value 경고가 뜰 텐데.. 비초기화 변수 접근 체크는 그 정도로 꼼꼼하지 않은가 보다.)

Visual C++은 /RTC라고 디버그용 빌드에다가 run-time check라는 간단한 검사 기능을 추가하는 옵션을 제공한다. 함수 실행이 끝날 때 스택 프레임 주변을 점검해서 버퍼 오버런을 감지하는 /RTCs, 그리고 지역변수를 초기화하지 않고 사용한 것을 감지하는 /RTCu.

사용자 삽입 이미지

저 코드를 Visual C++에서 디버그 모드로 빌드해서 실행해서 if문이 충족되지 않으면 run-time check failure가 발생해서 프로그램이 정지한다. 다만, 이 메모리는 초기화만 되지 않았을 뿐 접근에 법적으로 아무 문제가 없는 스택 메모리이다. 할당되지 않은 메모리에 접근해서 access violation이 난 게 아니다. 심각한 시스템/물리적인 오류가 아니라 그저 의미· 논리적인 오류이며, 쓰기를 먼저 하지 않은 메모리에다가 읽기를 시도한 게 문제일 뿐이다.

그러니 이 버그는 해당 메모리 자체에다가 시스템 차원의 특수한 표식을 해서 잡아낸 게 아니며, 논리적으로 매우 허술하다. (0xCC이기만 하면 무조건 스톱.. 이럴 수도 없는 노릇이고!)
문제의 코드에 대한 디스어셈블리를 보면 if문이 만족되지 않으면 printf으로 가지 않고 그냥 곧장 RTC failure 핸들러를 실행하게 돼 있다.

void do_nothing(int& x) {}

int x; do_nothing(x); printf("%d", x);

그렇기 때문에 요렇게만 해 줘도 RTC를 회피하고 x의 쓰레기값을 얻는 게 가능하다. 글쎄, 정교한 정적 분석은 이것도 지적해 줄 수 있겠지만, 포인터가 등장하는 순간부터 메모리 난이도와 복잡도는 그냥 하늘로 치솟는다고 봐야 할 것이다.

하물며 처음부터 포인터로만 접근하는 힙 메모리는 RTC고 뭐고 아무 안전 장치가 없다. int *p에다가 new건 malloc이건 값이 하나 들어간 것만으로도 초기화가 된 것이거늘, 그 주소가 가리키는 p[0], p[1] 따위에 쓰레기값(0xCD)이 있건 0이 있건 알 게 무엇이겠는가????

나도 지금까지 혼동하고 있었는데, 이런 run-time check failure는 run-time error와는 다른 개념이다. 순수 가상 함수 호출 같은 건 C/C++에 존재하는 얼마 안 되는 run-time error의 일종이고 release 빌드에도 포함돼 들어간다. 하지만 RTC는 debug 빌드 전용 검사이다.

그러니 버퍼 오버런을 감지하는 보안 옵션이 /RTC만으로는 충분하지 않고 /GS가 따로 있는 것이지 싶다. /GS는 release 빌드에도 포함돼 있으며, 마소에서는 보안을 위해 모든 프로그램들이 이 옵션을 사용하여 빌드할 것을 권하고 있다.

3. 해제된 힙 메모리: 0xDD(CRT)와 0xFEEE(???)

일반적인 프로그래머라면 동적으로 할당받은 힙 메모리를 free로 해제했을 때, 거기를 가리키는 메모리 영역이 실제로 어떻게 바뀌는지에 대해 생각을 별로 하지 않는다. 사실, 할 필요가 없는 게 정상이기도 하다.
우리 프로그램은 free를 해 준 주소는 신속하게 영원히 잊어버리고, 그 주소를 보관하던 포인터는 NULL로 바꿔 버리기만 하면 된다. free 해 버린 주소를 또 엿보다가는 곧바로 메모리 에러라는 천벌을 받게 될 것이다.

그런데 실제로는, 특히 디버그 모드로 빌드 후 프로그램을 디버깅 중일 때는 free를 한 뒤에도 해당 메모리 주소가 가리키는 값을 여전히 들여다볼 수 있다. 들여다볼 수 있다는 말은 *ptr을 했을 때 access violation이 발생하지 않고 값이 나온다는 것을 의미한다.
이 공간은 나중에 새로운 메모리 할당을 위해 재사용될 수야 있다. 하지만 사용자가 디버깅의 편의를 위해 원한다면 옵션을 바꿔서 재사용되지 않게 할 수도 있다. (_CrtSetDbgFlag(_CRTDBG_DELAY_FREE_MEM_DF) 호출)

뭐, 메모리를 당장 해제하지 않는다고 해서 free 하기 전의 메모리의 원래 값까지 그대로 남아 있지는 않는다. Visual C++의 디버그용 free/delete 함수는 그 메모리 블록의 값을 일부러 0xDD (_bDeadLandFill)로 몽땅 채워 넣는다. 여기는 할당되었다가 해제된 영역임을 이런 식으로 알린다는 것이다.

실제로, free된 메모리가 곧장 흔적도 없이 사라져서 애초에 존재하지도 않았던 것처럼 접근 불가 ?? 로 표시되는 것보다는 0xDD라고 디버거의 메모리 창에 뜨는 게 dangling pointer 디버깅에 약간이나마 더 도움이 될 것이다. 이 포인터가 처음부터 그냥 쓰레기값을 가리키고 있었는지, 아니면 원래는 valid하다가 지칭 대상이 해제되어 버린 것인지를 분간할 수 있으니 말이다.

그런데 본인은 여기서 개인적으로 의문이 들었다.
본인은 지난 20여 년에 달하는 Visual C++ 프로그래밍과 메모리 문제 디버깅 경험을 떠올려 봐도.. 갓 할당된 쓰레기값인 0xCC와 0xCD에 비해, 0xDD를 본 적은 전혀 없는 건 아니지만 매우 드물었다.

dangling pointer가 가리키는 메모리의 값은 0xD?보다는 0xF?였던 적이 훨씬 더 많았다. 더 구체적으로는 2바이트 간격으로 0xFEEE (0xEE, 0xFE)이다.

사용자 삽입 이미지

인터넷 검색을 해 보니.. 이건 놀랍게도 CRT 라이브러리가 채워 넣는 값이 아니었다. free/delete가 궁극적으로 호출하는 Windows API 함수인 HeapFree가 메모리를 정리하면서 영역을 저렇게 바꿔 놓았었다. 더구나 CRT에서 0xDD로 먼저 채워 넣었던 영역을 또 덮어쓴 것이다.
이 동작에 대해서 놀라운 점은 저게 전부가 아니다.

(1) 0xFEEE 채우기는 프로그램을 Visual C++ 디버거를 붙여서(F5) 실행했을 때만 발생한다. debug 빌드라도 디버거를 붙이지 않고 그냥 Ctrl+F5로 실행하면 0xFEEE가 생기지 않는다. 그리고 release 빌드라도 디버거를 붙여서 실행하면 0xFEEE를 볼 수 있다.

(2) 더 놀라운 점은.. 내가 집과 직장 컴퓨터를 통틀어서 확인한 바로는 저 현상을 볼 수 있는 건 Visual C++ 2013 정도까지이다. 2015부터는 debug 빌드를 디버거로 붙여서 돌리더라도 0xFEEE 채움이 발생하지 않고 곧이곧대로 0xDD만 나타난다~!

운영체제가 정확하게 어떤 조건 하에서 0xFEEE를 채워 주는지 모르겠다. 인터넷 검색을 해 봐도 정확한 정보가 나오는 게 의외로 없다.
하필 Visual C++ 2015부터 저런다는 것은 CRT 라이브러리가 Universal CRT니 VCRuntime이니 하면서 구조가 크게 개편된 것과 관계가 있지 않으려나 막연히 추측만 해 볼 뿐이다.

여담이지만 HeapAlloc, GlobalAlloc, LocalAlloc은 연달아 호출했을 때 돌아오는 주소의 영역이 그리 큰 차이가 나지 않으며, 내부 동작 방식이 모두 비슷해진 것 같다. 물론 뒤의 global/local은 fixed 메모리 할당 기준으로 말이다.

4. 힙 메모리 영역 경계 표시용: 0xFD와 0xBD

0xCD, 0xDD, (0xFEEE) 말고 heap 메모리 주변에서 볼 수 있는 디버그 빌드용 magic number 바이트로는 0xFD _bNoMansLandFill와 0xBD _bAlignLandFill가 더 있다.

얘들은 사용자가 요청한 메모리.. 즉, 0xCD로 채워지는 그 메모리의 앞과 뒤에 추가로 고정된 크기만큼 채워진다. Visual C++ CRT 소스를 보면 크기가 NoMansLandSize인데, 값은 4바이트이다. 사용자가 요청한 메모리 크기에 비례해서 채워지는 0xCD와 0xDD에 비하면 노출 빈도가 아주 작은 셈이다. 특히 0xBD는 0xFD보다도 더욱 듣보잡인 듯..

애초에 얘는 사용자가 건드릴 수 있거나 건드렸던 공간이 아니며 그 반대이다. 사용자는 0xCD로 채워진 공간에다가만 값을 집어넣어야지, 앞뒤  경계를 나타내는 0xFD를 건드려서는 안 된다.
CRT 라이브러리의 디버그용 free/delete 함수는.. 힙을 해제할 때 이 0xFD로 표시해 놨던 영역이 값이 바뀌어 있으면 곧장 에러를 출력하게 돼 있다.

그리고 예전에 메모리를 해제해서 몽땅 0xDD로 채워 놨던 영역도 변조된 게 감지되면 _CrtCheckMemory 같은 디버깅 함수에서 곧장 에러를 찍어 준다. 그러니 0xDD, 0xFD, 0xBD는 모두 오류 검출이라는 용도가 있는 셈이다. 0xCC와 0xCD 같은 쓰레기값 영역은 쓰지도 않고 곧장 읽어들이는 게 문제이지만, 나머지 magic number들은 건드리는 것 자체가 문제이다.

그리고 얘들은 heap 메모리를 대상으로 행해지는 점검 작업이다. 이런 것 말고 스택 프레임에다가 특정 magic number를 둬서 지역변수 배열의 overflow나 복귀 주소 변조를 감지하는 것은 별도의 컴파일러 옵션을 통해 지원되는 기능이다. 요것들은 힙 디버그 기능과는 별개이며, 보안 강화를 위해 release 빌드에도 포함되는 게 요즘 추세이다.

이상이다.
파일 포맷 식별자 말고 메모리에도 디버깅을 수월하게 하기 위해 쓰레기값을 가장한 이런 특수한 magic number들이 쓰인다는 게 흥미롭다. Windows의 Visual C++ 외의 다른 개발 환경에서는 디버깅을 위해 어떤 convention이 존재하는지 궁금해진다.

사실, 16진수 표기용인 A~F에도 모음이 2개나 포함돼 있고 생각보다 다양한 영단어를 표현할 수 있다. 거기에다 0을 편의상 O로 전용하면 모음이 3개나 되며, DEAD, FOOD, BAD, FADE, C0DE 정도는 거뜬히 만들어 낸다. 거기에다 FEE, FACE, FEED, BEEF 같은 단어도.. 유의미한 magic number나 signature를 고안하는 창의력을 발산하는 데 쓰일 수 있다.
그러고 보니 아까 0xFEEE도 원래 free를 의도했는데 16진수 digit에 R은 없다 보니 불가피하게 0xFEEE로 대충 때운 건지 모르겠다.

Posted by 사무엘

2021/02/11 08:36 2021/02/11 08:36
, , , ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1853

1. DLL 주소 재배치와 ASLR의 관계

Windows XP 내지 Vista 이후로 (1) 커널 API와 C 런타임 라이브러리 함수 심벌들이 한 DLL 몰빵이 아니라 분야별로 재분류되어 배치되기 시작한 것, (2) 시스템 DLL들이 이제 전혀 rebase되지 않고 고정된 단일 preferred base 주소를 갖기 시작한 것을 보면 참 격세지감이 느껴진다.

위의 둘은 (1) 자잘한 DLL 여러 개보다 큰 DLL 하나가 더 효율적이다(선박처럼??). (2) DLL들은 로딩되는 주소가 겹치지 않게 빌드 후에 반드시 rebasing을 해 줘라
이런 전통적인 고정관념을 역행하는 변화이기 때문이다.

보안 강화를 위해 10여 년 전 Windows Vista 때부터 ASLR (시작 주소 랜덤화)이 도입되면서 DLL은 물론이고 EXE조차도 반드시 자기 preferred base에 고정적으로 로딩이 되지 않게 되었다. 이 때문에 요즘은 EXE도 과거 Win32s 프로그램들처럼 끝에 재배치 정보가 다시 포함돼 들어가고 있다.

하지만 이런 ASLR을 위한 재배치 때는 말 그대로 메모리 오프셋 수정만 행해질 뿐, 재배치의 치명적인 페널티라고 여겨지는 가상 메모리 페이지 파일 재기록이라든가 재사용 불가(여러 프로세스에서 동일 DLL 로딩 시에도 shallow가 아닌 deep copy 발생) 까지 발생하지는 않는다. 운영체제의 보안 기능이 그 정도로 바보는 아니다.

그러므로 오늘날은 DLL을 미리 rebase 하건 안 하건 실행 성능이 달라지는 것은 없다. rebase를 해도 이익을 얻는 것은 없지만 반대로 손해를 보는 것 역시 없다. rebase라는 게 빌드 타임이 아닌 런타임의 영역으로 바뀐 셈이다.

정말 재수가 없어서 엄청 많은 자잘한 DLL들이 로딩되다 보니 한 DLL이 프로세스 A에서는 ASLR 배당 주소로 로딩됐지만 프로세스 B에서는 그 주소로 로딩이 못 되게 됐다면.. 그때는 통상적인 페널티가 부과되는 재배치가 발생할 것이다. 하지만 광대한 주소 공간을 자랑하는 64비트 환경에서는 그럴 가능성이 더욱 희박해졌다.

2. EXE를 LoadLibrary 하기

LoadLibrary 함수는 실행 가능한 코드가 담긴 DLL을 불러오거나 혹은 EXE/DLL로부터 리소스를 얻고자 할 때 즐겨 쓰인다.
그런데 여기서 의문이 든다. LoadLibrary를 호출해서 exe의 단순 리소스가 아니라 코드를 내 프로세스 공간에 가져와 실행하는 게 가능할까?

사실, 기술적으로 볼 때 EXE와 DLL의 차이는 그리 크지 않다. 심지어 EXE도 DLL처럼 심벌 export를 할 수 있다.
그리고 EXE를 LoadLibrary로 그냥 쌩으로 불러와도, 의외로 일단 성공은 한다. GetProcAddress를 해서 심벌을 요청하면 주소값이 돌아오기까지 한다.
하지만 그 함수를 호출해 보면 십중팔구 access violation 에러가 난다. 여기서 대부분의 사람들은 '안 되나 보다'라고 생각하고 단념하게 된다. 왜 이런 현상이 발생하는 것이며, 문제를 해결할 방법은 없는 걸까?

DLL이 아닌 EXE를 LoadLibrary 하면 운영체제는 얘를 반쯤 데이터로 취급하는가 보다. GetProcAddress를 호출했을 때 심벌 검색 결과를 되돌려 주지만 그 포인터가 가리키는 코드를 실행 가능한 상태로 만들어 놓지는 않는다.
특히 (1) 주소 재배치와 관련된 그 어떤 조치도 취하지 않는다. 구체적으로는.. EXE가 사용하는 import table의 주소를 패치하지 않기 때문에 그 EXE의 코드가 실행되면서 Windows API 같은 걸 호출하면 그대로 뻑이 나게 된다.

그리고 (2) EXE의 진입점 함수를 전혀 실행하지 않는다.
EXE건 DLL이건 무조건 맨 먼저 실행할 부분을 가리키는 진입점이란 게 있는데.. 그게 EXE는 int func() 형태이고, DLL은 BOOL func(HMODULE, UINT, PVOID) 형태이다.

즉, EXE는 처음엔 아무 인자 없이 실행됐고 C 라이브러리가 GetStartupInfo 같은 API 함수를 호출해서 실행 인자를 준비한 뒤에 main이나 WinMain을 또 호출하는 형태이다. 그러나 DLL은 진입점 함수의 형태가 DllMain과 완전히 동일하다. 즉, DLL_PROCESS_ATTACH 같은 이벤트 명칭은 이 함수의 호출 인자가 아니면 딴 데서 알아낼 곳이 없다.
LoadLibrary는 원래 DllMain을 호출하게 돼 있는데 EXE는 받아들이는 함수 prototype이 다르므로 아예 호출을 안 하는 것이다.

그러므로 LoadLibrary된 exe의 코드를 강제로 실행한다면 IAT 테이블의 주소가 패치되지 않고 C 라이브러리가 전혀 초기화되지 않은 상태에서 덥석 실행된다. 그 함수에서 내부적으로 전역변수 C++ 객체 같은 걸 사용한다면.. 역시나 제대로 실행되지 못하고 높은 확률로 뻑나게 된다.

IAT 주소를 패치하는 방법까지는 어느 용자가 찾아낸 게 인터넷에 이미 굴러다닌다. (☞ 링크) 이거 패치가 제대로 되려면 EXE는 애초부터 재배치 정보가 들어간 상태로 빌드돼야 한다.
하지만 각종 부작용 없이 C 라이브러리만 감쪽같이 초기화하고 EXE의 export 함수를 실행하는 건.. 굉장히 삽질스럽고 가성비가 낮다. 그냥 EXE와 DLL의 차이가 이러하며 LoadLibrary(EXE)가 기술적으로 왜 권장되지 않는지 이론으로만 알고 넘어가면 될 듯하다.

3. 재빠르게 대체된 파일에 대한 creation date 보정

응용 프로그램 중에는 안전을 위해 문서 저장 기능을 임시 파일을 생성하는 형태로 구현한 것이 있다.
기존 파일을 곧장 덮어써서 저장하는 게 아니라.. 임시 파일에다가 저장을 한 뒤, 기존 파일을 지우고 임시 파일을 기존 파일의 이름으로 바꾼다. 이렇게 하면 저장하는 중에 컴퓨터에 전기가 나가는 등의 이상 현상이 발생하더라도 최소한 기존 자료가 송두리째 날아가는 일은 막을 수 있다.

그런데 이렇게 기존 파일을 덮어쓰는 게 아니라 파일 자체를 딴 것으로 대체하는 식으로 저장을 하면 기존 파일이 갖고 있는 creation time이 보존되지 않게 된다. 그렇기 때문에 기존 파일의 creation time을 따로 얻어 놓은 뒤, 저장을 마친 새 파일에 대해서 creation time을 SetFileTime 함수로 따로 지정해 줘야 한다.

단, Windows NT 계열의 경우, 놀랍게도 보정 동작을 진작부터 지원하고 있었다. 어떤 프로그램이 A라는 파일을 삭제한 뒤에 다른 파일의 이름을 A로 신속하게 거의 곧장 변경한 경우, 그 파일에다가 삭제된 A의 creation time을 자동으로 지정해 줬던 것이다~!

이런 보정을 위해서는 파일 삭제와 개명 알고리즘에다가 삭제된 파일의 생성 시각을 백업해 놓고, 시간차를 감지해서 이 renaming이 기존 파일을 승계하는 동작인지 판단하는 등 여러 귀찮은 작업이 필요할 것이다. 하지만 마소에서는 임시 파일 방식으로 저장하면서 creation time을 관리하지 않는 프로그램이 많은 것을 감안하여 운영체제 차원에서 이런 보정 기능을 구현했다고 한다.

이 보정은 NT 계열에서만 지원되어 왔으며, 9x 계열에서는 존재하지 않는다.

4. 스레드 동기화 deadlock 자동 감지

복잡한 메모리 문제를 잡아내기 위해 C 라이브러리 차원에서 저런 다양한 안전 장치와 디버깅 편의 기능이 제공되듯, 멀티스레드 동기화 오브젝트에도 디버그 버전용은 데드락 정도는 assertion failure 에러를 내면서 곧장 감지하는 기능이 있으면 좋겠다는 생각이 든다.

“당신이 지금 취득을 위해 대기하려는 뮤텍스는 현재 다른 스레드가 잡고 있는데, 문제는 그 스레드도 지금 당신이 요 스레드에서 잡고 있는 뮤텍스를 얻으려고 대기 중이다. 그러니 이 상태로는 상호 무한 대기 교착 상태가 됨.”

이건 레퍼런스 카운트 기반인 오브젝트에서 순환 참조 오류를 감지하는 기능을 구현하는 것과 기술적으로 완전히 동급이다.
Hash 같은 컨테이너를 둬서 스레드 ID별로 각각 현재 진입해 있는 뮤텍스에 대한 기록을 관리하고, 뮤텍스 오브젝트를 감싸는 클래스에다가 현재 자신을 잡고 있는 스레드 정보도 같이 보관하는 정도의 수고만 하면 큰 어려움 없이 구현 가능하다.

하지만 PC용 프로그램에서 돌아가는 스레드의 개수가 무슨 할당된 동적 메모리 블록 개수처럼 많을 리는 없을 것이고, 프로그램의 응답이 멎었을 때 데드락 부위를 찾는 것은 도구의 도움 없이 도저히 못 할 일은 아닐 것이다. 유용성에 비해 저런 기능을 갖추는 건 속도와 메모리 오버헤드가 너무 커서 가성비가 맞지 않으니 데드락 자동 감지 기능은 운영체제나 프로그래밍 언어 런타임이 제공해 주지 않는 듯하다.

개인적으로 직장에서는 심지어 자기 스레드 자신의 실행이 끝나기를 기다리는.. C++ 오브젝트로 치면 delete this.. 무슨 자살이나 다름없는 deadlock도 경험한 적이 있었다.
프로그램의 실행이 종료되어 UnInit() 함수가 호출될 때는 백그라운드 작업 스레드에 대해서도 작업을 중단시키고 작업 스레드의 실행이 끝날 때까지 기다리게 했는데, 뭔가 로직이 꼬여서 작업 스레드에서 UnInit()를 호출하는 상황이 발생한 것이다.

Uninit이 무슨 loop 안에서 1초에 수십, 수백 번씩 실행되어서 성능이 중요한 함수인 건 아니다. 그러니 자기 자신이 무슨 스레드 문맥에서 실행되었는지 검사해서 deadlock을 피할 수도 있다.
하지만 그것보다는 Uninit이 스레드 함수가 아니라 의도했던 대로 main thread에서만 실행되도록 프로그램 구조를 고치는 것이 훨씬 더 나은 해결책이었다.

Posted by 사무엘

2021/02/03 19:36 2021/02/03 19:36
,
Response
No Trackback , 2 Comments
RSS :
http://moogi.new21.org/tc/rss/response/1850

1. 프로그래밍 용어· 명칭과 타 분야 비교

  • 2의 제곱근 vs 제곱근(루트) 2: 어순의 차이로 의미가 달라지는 new operator/operator new, 함수 템플릿 vs 템플릿 함수 같은 C++ 용어와 상황이 비슷하다. 특히 다들 전자가 후자를 포함하는 편이기도 하다.
  • 전철역에서 논현, 신길, 양평: namespace가 필요한 좋은 예이다..;;; (1) 얘들은 고유명사 지명이 지역간에 충돌하는 사례이고 (2) 월드컵경기장이나 시청은 보통명사 시설명이 충돌하는 예이다.
    (3) 수색, 광명, 신촌 같은 건 지역이 아니라 일반열차 역과 지하철역이 충돌하는 경우이다. 그리고 (4) '회송'은 역의 이름으로는 쓰이지 않는(쓰일 수 없는) reserved word 정도에 대응할 것이다.
  • 배의 명칭 A급 B함/호(포항급 초계함 천안함, 올림픽급 타이타닉 호, 베헤모스급 배틀크루저 히페리온): 프로그래밍으로 치면 A는 타입명이고 B는 변수명이다.

2. 마침표와 세미콜론

우리는 년월일 날짜를 간략하게 표기할 때 2020. 10. 5. 같은 식으로 숫자 뒤에 마침표를 찍곤 한다. 이때 일을 나타내는 마지막 마침표도 생략하지 말고 반드시 다 찍는 것이 한글 맞춤법에 규정된 원칙이다. 각각의 점이 년, 월, 일을 나타내기 때문이다.

이는 마치 파스칼 언어에서는 세미콜론이 문장의 구분자(separator)인 반면, C/C++에서는 문장의 종결자(terminator)인 것과 비슷하다.
파스칼에서는 end나 else 직전에 등장하는 구문의 끝에 ; 이 생략되지만 C/C++은 그렇지 않다. 날짜 숫자의 뒤에다 찍는 .는 파스칼이 아닌 C/C++의 세미콜론과 같은 성격이라고 생각해야 한다.

3. 병렬화

같은 용량의 데이터가 있을 때 압축을 하는 것은 압축을 푸는 것보다 계산량이 훨씬 더 많은 어려운 작업임이 주지의 사실이다.
그 대신, 압축 "하기"는 CPU 멀티코어를 활용해서 속도를 쭉쭉 끌어올릴 수도 있는 반면, "풀기"는 그런 병렬화는 안 되고 그냥 단일 코어에서 linear한 작업에 의존할 수밖에 없다. 기껏해야 풀어야 하는 압축 파일 자체가 여러 개일 때에나 여러 CPU에다가 던져줄 수 있을 것이다.

C/C++ 파일을 빌드하는 절차도 이와 비슷해 보인다. '컴파일'은 아무래도 분산 처리와 병렬화가 가능하지만, 모든 결과물이 하나로 집약되는 '링크'는 그게 불가능한 최종 병목이 될 수밖에 없다.

4. 버퍼의 크기

일상적으로 무슨 모임 같은 데서(본인의 경우는 교회에서 청년부 모임 같은..) 인원수대로 프린트물이나 간식 같은 것을 준비해야 할 때가 있다. 평소에 모임 참석자가 얼마 정도 되는지에 대한 대략의 데이터는 있지만 딱 정확하게 몇 명인지는 알 수 없을 때는 준비물을 몇 개 정도 챙겨야 너무 남거나 모자라는 일이 없이 최대한 딱 맞을 수 있을까?

이건 나름 통계적인 노하우가 필요한 일이다. 가끔 모자라는 일이 발생해도 괜찮은지, 아니면 모자라는 일은 절대 없어야 하는지에 따라서도 전략이 달라진다. 프로그래밍으로 치면 static한 배열의 크기를 잡는 것과 매우 비슷해 보인다는 생각을 본인은 오래 전부터 했다. ㅎㅎ

문자열 클래스의 경우, 사소한 문자열까지 늘 동적 메모리를 할당하는 건 번거로우니 자체적으로 자그마한 배열도 갖고 있고, 그 배열 크기를 초월하는 긴 문자열을 배당할 때만 동적 메모리를 사용하게 하는 구현체도 존재한다. C++의 표준 string 클래스도 반드시 저렇게 동작해야 한다는 조건은 없지만 대체로 이런 식으로 구현된 걸로 본인은 알고 있다.

이런 것 말고도

  • 건물을 지을 때 이 정도 건물에서는 화장실에 변개를 몇 개 설치하는 게 좋을까?
  • 엘리베이터는 어느 정도 크기로 몇 개 설치하는 게 좋을까?
  • 이 정도 도로의 교차로 내지 횡단보도에서는 신호 주기를 어느 정도로 주는 게 좋을까?

같은 문제도 치밀한 공학 및 통계 계산의 산물이지 싶다. 동시에 사용하는 사람의 수가 최대인 시간대에 대기 시간이 너무 길어지지 않게 하는 한편으로, 나머지 한산한 시간대에 시설들이 사용자 없이 놀면서 발생하는 비효율도 최소화해야 하기 때문이다.

5. 훅킹

훅(hook) 내지 훅킹이란 컴퓨터 소프트웨어가 돌아가는 과정을 몰래 들여다보고 필요하면 변조도 하는 메커니즘을 말한다. 훅킹은 대체로 시스템 프로그래밍 분야에 속하며 꽤 강력한 고급 테크닉으로 간주된다.

(1) 메시지 훅
Windows에는 SetWindowsHookEx라는 엄청난 함수가 있어서 시스템과 응용 프로그램 사이에서 오가는 메시지들을 매우 수월하게 들여다볼 수 있다. 그러니 Spy++ 같은 프로그램을 만들 수 있다.
권한 문제만 없다면 심지어 다른 프로그램의 메시지를 들여다볼 수도 있다. 이 경우, 훅 프로시저가 내 프로세스가 아니라 그 메시지를 받은 프로세스의 문맥에서 실행된다는 점을 주의할 것. 32비트와 64비트별로 DLL을 따로 만들고, 프로세스 간의 통신 같은 잡다한 수고만 좀 해 주면 된다.

(2) API 훅
다른 프로그램이 그냥 기계어 수준에서 운영체제의 특정 함수를 호출하는 것을 감지하고, 그 함수 대신 내가 심은 함수가 호출되게 할 수 있다. C 언어 형태의 클래식 API가 제일 쉽고, COM도 결국은 CoCreateInstance 같은 함수를 훅킹하면 이론적으로 가능하다. 실행되는 기계어 코드를 변조하는 게 아니라 import 섹션 주소를 변조하는 고전적인 테크닉이 있다.

16비트 시절에는 API 훅을 시스템 전체에다 걸어서 운영체제 외형을 통째로 마개조 할 수도 있었지만 32비트 이후부터는 그 정도까지는 어렵다. 다만, 시스템 전체에다가 설치한 메시지 훅과 CreateRemoteThread 등 다른 어려운 테크닉들과 연계하면 API 훅도 어느 정도 global하게 설치하는 게 가능은 하다.
과거에 한컴사전이 GDI 그래픽 API에다가 훅을 걸어서 단어 자동 인식 기능을 제공했던 적이 있다. 마우스 포인터 주변의 화면 캡처 + 필기 인식이 아니다~!

(3) 패킷 훅
심지어 시스템 전체에서 오고 가는 네트워크 패킷을 모니터링 할 수도 있다. 이게 기술적으로 가능하니까 packet sniffer이라고 불리는 유틸리티들도 존재 가능할 것이다. 이에 대해서는 본인도 더 아는 게 없다.
macOS는 Windows와 달리 메시지 훅이고 API 훅 같은 건 존재하지 않는 것으로 본인은 알고 있다. 하지만 macOS라도 패킷 모니터링은 아마 가능할 것이다.

packet sniffer이라든가 심지어 VPN 툴 같은 건 어떤 API를 써서 어떻게 만드나 모르겠다. 신기한 물건이다.

Posted by 사무엘

2021/02/01 08:34 2021/02/01 08:34
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1849

메모리 leak 사냥 후기

본인은 얼마 전엔 생계를 위해 덩치 좀 있고 스레드도 여럿 사용하는 C++ 프로젝트에서 골치 아픈 메모리 leak 버그만 잡으면서 꼬박 두 주를 보낸 적이 있었다.
요즘 세상에 raw 포인터를 직접 다루면서 동적 메모리를 몽땅 수동으로 직접 관리해야 한다니, C/C++은 자동차 운전으로 치면 수동 변속기와 잘 대응하는 언어 같다.

의외로... Visual C++이 2012 무렵부터 제공하기 시작한 정적 분석 도구는 memory leak을 잡아 주지는 않았다. 제일 최신인 2019 버전도 마찬가지이다.
얘가 잡아 주는 건 잠재적으로 NULL 포인터나 초기화되지 않은 변수값을 사용할 수 있는 것, 무한 루프에 빠질 수 있는 것 따위이다. 개중에는 너무 자질구레하거나 심지어 false alarm으로 여겨지는 것도 있다. 하지만..

char *p;
p = new char[256];
p = (char *)malloc(100);
p = NULL;
*p = 32;

이렇게 코드를 짜면 지적해 주는 건 맨 아래에 대놓고 NULL 포인터를 역참조해서 대입하는 부분뿐이다.
앞에서 new와 malloc 메모리 블록이 줄줄 새는 것은 의외로 out of 안중이더라. 개인적으로 놀랐다.

리눅스 진영에는 Valgrind라는 툴이 있긴 한데, 얘도 프로그램을 직접 실행해 주는 동적 분석이지 정적은 아니다.
다른 상업용 3rd party 정적 분석 툴 중에는 메모리 leak도 잡아내는 물건이 있을지 모른다. 하지만 그런 것 없이 Visual C++ 순정만 쓴다면 메모리 leak 디버깅은 전통적인 인간의 동적 분석에 의존해야 할 듯했다. 그래서 고고씽..

처음에는 무식하게 여기저기 들쑤시면서 삽질하면서 시간을 많이 보냈지만, 나중엔 차츰 요령이 생겼다.
먼저, 안정성이 검증돼 있는 맨 아랫단의 각종 오픈소스 라이브러리들을 의심하고 무식하게 들쑤실 필요는 없다. 물론 겉으로 드러난 결과는 거기서 할당한 메모리들이 줄줄 새는 것이다. 하지만 근본 원인은 거기보다 더 위에 있다.

그렇다고 맨 위의 애플리케이션이 오브젝트 해제를 안 했다거나 한 것도 아니었다. 그 정도로 초보적인 실수였다면 금세 감지되고 잡혔을 것이다. 더구나 App들은 아랫단과 달리 C++을 기반으로 스마트 포인터 같은 것도 그럭저럭 활용해서 작성되어 있었다. 그러니 거기도 딱히 문제는 없었다.

대부분의 문제는 오픈소스를 우리 쪽에서 살짝 수정한 부분, 오픈소스로부터 호출되는 우리 쪽 콜백 함수, 그리고 우리가 작성한 중간 계층의 공유 라이브러리에서 발견되었다.
이 코드를 처음으로 작성한 전임자가 누구인지는 모르겠지만.. C++ 코딩을 너무 Java 코딩하는 기분으로 했다는 생각이 강하게 들었다.

std::string s = _strdup("ABCD");

이런 식으로만 해 놓고 그냥 넘어간다거나.. (저기요, R-value는 어떡하고..??)
함수 뒷부분에서 나름 메모리를 해제한답시고 p = NULL을 쓴 것을 보니.. 전임자는 정말 Java의 정신으로 충만했다는 게 느껴졌다. (p는 물론 스마트가 아닌 일반 포인터)

메모리 leak 디버깅을 위해 C 컴파일러들은 디버깅용 메모리 관리 함수들을 제공하며, 다른 라이브러리들은 보통 자신들이 사용하는 메모리 할당 함수를 자신만의 명칭으로 바꿔서 쓴다. 그 명칭만으로 자신의 메모리 사용 내역을 추적할 수 있게 하기 위해서이다. (매크로 치환 및 해당 함수의 구현 부분 수정)

Visual C++ 기준으로, 프로그램이 처음 실행됐을 때 _CrtSetDbgFlag( _CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF )를 호출하고 나면, 종료 시에 아직 해제되지 않은 heap 메모리들 목록이 쭈욱 나열된다. 메모리 할당 번호와 할당 크기, 그리고 메모리의 첫 부분 내용도 일부 같이 덤프된다.

여기서 ‘할당 번호’라는 걸 주목하시길..
만약 프로그램을 여러 번 실행하고 종료하더라도 (1) 메모리 할당 번호가 동일한 leak을 일관되게 재연 가능하다면, 그건 운이 아주 좋은 상황이다.
_CrtSetBreakAlloc을 호출해서 나중에 그 번호에 해당하는 메모리 할당 요청이 왔을 때 프로그램 실행을 중단시키면 되기 때문이다. 그러면 게임 끝이다.

하지만 복잡한 멀티스레드 프로그램에서 이렇게 매번 동일한 번호로 발생하는 착한 leak은 그리 많지 않다. 이것만으로 이 메모리의 출처를 추적하고 문제를 해결하는 건 아직 모래사장에서 바늘 찾는 짓이나 마찬가지이다. 단서가 좀 더 필요하다.

그래서 메모리를 할당할 때 이 요청은 (2) 소스 코드의 어느 지점에서 한 것이라는 정보를 같이 주게 한다.
어떻게? Visual C++ 기준 _***_dbg라는 함수를 만들어서 뒤에 소스 코드와 줄 번호 인자를 따로 받게 한다. ***에는 malloc뿐만 아니라 변종인 realloc과 calloc, 내부적으로 이런 함수를 호출하는 strdup 같은 함수도 모두 포함된다. 심지어 C++용으로는 operator new 함수도 말이다.

C의 __FILE__과 __LINE__은 그야말로 디버깅용으로 만들어진 가변 매크로 상수인 셈이다. 이렇게 말이다.

#ifdef _DEBUG
#define malloc(n)   _malloc_dbg(n, __FILE__, __LINE__)

#define new    __debug_new
#define __debug_new   new(__FILE__, __LINE__)
void *operator new(size_t n, const char *src, int lin);
void *operator new[](size_t n, const char *src, int lin);
#endif

new operator가 오버로딩 되는 건 placement new를 구현할 때와 디버깅용 메모리 할당을 할 때 정도인 것 같다.
이렇게 메모리 할당 방식을 바꿔 주면.. 나중에 leak report가 뜰 때 그 메모리 블록에 대해서 할당되었던 지점이 같이 뜬다. 무슨무슨 c/cpp의 몇째 줄이라고..

물론 그 함수가 호출된 배경을 알 수 없으니 저것도 불완전하게 느껴질 수 있다. 또한 이미 자체적으로 malloc을 다른 명칭으로 감싸고 있는 코드에 대해서는 이런 매크로 치환이 곧장 통하지 않는다는 한계도 있다.

그래도 그 정보마저 없던 것보다는 상황이 월등히 더 나아진다.
참고로, 프로그램이 실행 중일 때에도 동적 할당된 임의의 메모리에 대해서 _CrtIsMemoryBlock을 호출하면 이 메모리의 할당 번호와 출처 정보를 얻을 수 있다. 이를 토대로 leak은 얘보다 전인지 후인지, 언제 할당되었는지를 유추 가능하다(할당 번호의 대소 비교).

이것만으로도 아직 막막할 때 본인이 사용한 최후의 방법은 (3) _CrtSetAllocHook을 사용해서 메모리 할당이 발생할 때마다 콜백 함수가 호출되게 하는 것이었다.
내가 작성하지도 않은 방대한 코드에서 malloc/calloc을 전부 내 함수로 치환하는 것은 위험 부담이 매우 큰데.. 그럴 필요 없이 Visual C++ CRT의 malloc이 디버깅을 위해 사용자의 콜백 함수를 직접 호출해 준다니 고마운 일이 아닐 수 없다.

이를 위해서는 한 프로세스 내의 모든 static library 및 DLL 모듈들이 동일한 Visual C++ CRT 라이브러리를 DLL로 링크하게만 맞춰 놓으면 된다. 어느 것 하나라도 CRT의 static 링크가 있으면 일이 많이 골치 아파진다. DLL로 해야 모든 모듈들이 사용하는 메모리가 한 CRT에서 통합적으로 관리된다.

콜백 함수는 메모리 할당 번호뿐만 아니라 할당 크기, 그리고 이 메모리를 요청한 스레드가 어느 것인지도 확인 가능하다.
개인적으로는 leak 중에서 크고(수백~수천 바이트 이상) 유니크한 바이트 수를 동일하게 요청하는 것을 콜백 함수를 통해서 잡아내고, 이걸 토대로 다른 leak들도 잡아냈다.
겨우 4바이트, 8바이트 같은 너무 평범하고(?) 자주 호출되는 할당 요청은 leak만 추려내기가 곤란할 것이다.

이 콜백 함수에서 또 메모리를 동적 할당하지는 않도록 주의해야 한다. 그러면 콜백 함수에서 호출된 메모리 할당 함수가 또 콜백을 호출하고.. stack overflow 에러가 발생할 수 있다.
로그를 찍기 위해 흔히 사용하는 sprintf 부류의 함수조차도 내부적으로 메모리를 동적 할당한다.

이 문제를 회피하기 위해 우리 콜백 함수 내부에서 중복 호출 방지 guard를 둘 수도 있지만.. 간단하게 C 라이브러리 대신 Windows API가 제공하는 wsprintfA/W 함수를 사용하는 것도 괜찮은 방법이다. Windows API 중에는 C 라이브러리를 사용할 수 없는 환경에서도 C 라이브러리의 기능을 일부 사용하라면서 저런 부류의 함수를 제공하는 경우가 있다.

이상이다.
memory leak은 여느 메모리나 스레드 버그처럼 프로그램을 당장 뻗게 만들지는 않는다.
오히려 메모리 관리를 잘못해서 원래는 dangling pointer가 됐어야 할 포인터로도 메모리 접근을 가능하게 만들어 주기도 한다(해제되지 않았기 때문에).

하지만 leak은 결국 컴퓨터의 메모리 자원을 소진시키고, 한 프로그램이 반영구적으로 동일한 상태를 유지하면서 돌아가지 못하게 하는 심각한 문제이다. 더 넓게 보자면 굳이 heap 메모리 말고도, 각종 커널 핸들이나 GDI 객체처럼 나중에 반드시 닫아 줘야 하는 일체의 리소스들도 제때 해제해 주지 않을 경우 leak이 발생할 수 있는 물건들이다. 상업용 툴은 이런 것들까지 다 모니터링을 해 주지 싶다.

이 주제 관련 다른 여담들을 좀 늘어놓으며 글을 맺고자 한다.

(1) leak은 새어나가는 그 메모리의 할당이 벌어지는 상황을 추적하는 게 핵심이다. 그런데 새고 있는지의 여부는 한참 뒤에 프로그램이 종료될 때에나 알 수 있다는 것이 큰 모순이며, 관련 디버깅을 어렵게 하는 요인이다.
또한 시작과 끝이 있는 게 아니라 언제나 돌아가는 서버/서비스 같은 프로그램도 있다. 이런 건 leak을 어떻게 찾아내야 좋을까? 그렇게 오랫동안 상시 가동되는 프로그램이야말로 memory leak이 절대로 없어야 하는데, 역설적이게도 그런 유형의 프로그램이 leak을 잡기가 더욱 어렵다. 뭔가 새로운 방법론을 찾아서 적용해야 한다.

(2) 컴퓨터에서 메모리 영역이란 건 용도에 따라 코드와 데이터로 나뉘는데, 코드를 저장하는 메모리가 새는 일은.. 무슨 가상 머신 급의 고도의 시스템 소프트웨어를 개발하는 게 아닌 이상 없을 것이다.
다만, 데이터도 다 같은 데이터는 아니어서 진짜로 쌩 문자열 같은 POD인지, 아니면 내부에 포인터가 들어있는 실행 객체의 인스턴스인지에 따라 체감 난이도가 달라진다. 후자는 그 자체가 코드는 아니지만 코드에 준한다는 느낌이 든다.

(3) a( b(), c() ) 이런 구문의 실행을 디버거로 추적한다면, step into는 b()의 내부부터 먼저 들어간다. step over는 이들을 통째로 다 실행하고 다음 줄로 넘어간다.
그 둘의 중간으로.. b()와 c()처럼 인자 준비 과정에서 발생하는 함수 호출은 몽땅 생략하고 a()로만 step into 하는 명령도 좀 있으면 좋겠다.
특히 smart pointer는 함수로 넘겨줄 때마다 trivial한 생성자나 연산자 오버로딩 함수로 먼저 진입하는 것이 굉장히 번거롭다. 이런 것을 생략할 수 있으면 디버깅 능률과 생산성이 더 올라갈 수 있을 것이다.

Posted by 사무엘

2021/01/07 08:35 2021/01/07 08:35
, , ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1840

Windows에는 운영체제가 응용 프로그램으로 보내 주는 각종 통지 메시지, 또는 응용 프로그램으로 하여금 보내기로 정한 기본(시스템) 메시지들이 0부터 WM_USER 미만까지의 범위에 들어있다. 그런데 시스템 메시지 영역은 유니코드 BMP만큼이나 이제 빈 공간이 거의 없고 다 고갈됐을 것 같은데..
2048도 아니고 겨우 1024는 처음에 공간을 너무 좁게 잡은 것 같다. 앞으로 새로운 메시지가 추가 가능할지 궁금하다.

  • 그리고 요즘 어지간한 사용자들이 프로그램을 이것저것 띄워 놓고 나면 사용자 등록 메시지는 공간이 얼마나 사용되며 여유가 얼마나 남아 있을까?
  • 스레드를 생성하면 TLS 슬롯이 얼마나 사용되며 이 역시 여유 공간은 얼마나 될까?
  • Custom 클립보드 포맷을 등록하는 공간 역시 보통 얼마나 쓰이는 편일까?
  • 20년 전이나 지금이나 빌드되는 프로그램의 기본 스택 크기는 1MB인 걸까? 부족하지는 않은가?

이런 게 궁금해지곤 한다. Windows의 개발사인 마소에서는 이런 usage 데이터를 더욱 궁금해하고 수집하고 싶어할 것이다.
그리고 Windows가 16비트에서 32비트로 넘어가면서.. 특히 3.x에서 95로 넘어가면서 메시지 체계가 바뀐 게 좀 있다.

(1) EM_SETSEL (에디트), LB_ADDSTRING (리스트박스) 같은 기성 컨트롤을 조작하는 메시지들이 그때는 WM_USER 이후의 사용자 영역에 있었지만, 32비트 시절부터는 WM_USER 이내의 시스템 메시지 영역으로 이동했다.

이런 건 굳이 바꿀 필요가 없어 보이는데 왜 헷갈리게 단절적인 변화를 만든 걸까? 게다가 앞서 언급한 바와 같이 WM_USER 이내의 영역 자체도 그리 넉넉한 편이 아닌데 말이다.
이런 메시지들은 비트수(16/32/64..;; ) 내지 사용하는 문자열(2바이트 단위 유니코드 / 1바이트 ANSI) 형태가 다른 프로그램끼리 주고 받더라도 언제나 제대로 맞게 전달된다는 것을 운영체제 차원에서 보장하기 위해서이다.

즉, 문자열 포인터 같은 게 lParam 값으로 같이 전달됐다면 포인터가 가리키는 메모리의 값에 대한 복사와 보정까지 운영체제가 알아서 처리해 준다는 것이다. WM_SETTEXT처럼 말이다.
이들과 달리, 기성 컨트롤 말고 후대에 새로 추가된 공용 컨트롤들은 통신 메시지들이 WM_USER 이후에 있으며, 운영체제 차원에서의 메모리 보정 지원이 없다.

공용 컨트롤은 처음부터 Windows 95 내지 NT 3.5x라는 32비트 환경에서 개발됐기 때문에 16비트 호환성을 고려할 필요가 없다.
그리고 메시지들이 LV_ITEM, TV_ITEM 같은 복잡한 구조체와 비트 플래그를 주고받는 형태로 구현돼 있다. 그러니 한 프로세스가 다른 프로세스의 공용 컨트롤 내용을 들여다보거나 메시지를 보내서 조작하기란 매우 난감할 것이다.

그나마 과거의 Windows 9x는 프로세스 간 공유 메모리(memory-mapped file)의 주소가 모든 프로세스에서 동일하기라도 했지만, 현재의 NT 계열에서는 그런 보장마저 없다. system hook을 설치해서 공용 컨트롤을 그 프로세스의 문맥에서 조작하는 코드를 집어넣어야 할 듯하다.

(2) 기성 컨트롤의 글자색과 배경색을 변경하는 용도로 WM_CTLCOLOR 계열 메시지가 쓰이는데.. 얘는 16비트 시절에 그랬다. 32비트부터는 WM_CTLCOLORBTN, DLG, EDIT, STATIC 등으로 메시지의 형태가 세분화됐다. 왜 그리 됐을까?

메시지와 함께 전달되던 값이 HDC와 창 핸들(HWND), 그리고 창의 종류 정보 셋이었다. 16비트 시절에는 32비트짜리 lParam에 창 핸들과 종류 정보가 각각 16비트씩 합쳐져서 들어있었는데.. 32비트에 와서는 창 핸들만으로 32비트를 차지하기 때문에 기존 방식대로 메시지를 전달할 수가 없어졌던 것이다. 그래서 창의 종류 정보는 메시지 자체에 담겨 있도록 불가피하게 메시징 방식이 변경됐다.

다만, Windows 9x는 16비트 프로그램과의 호환을 위해 창 핸들의 값이 여전히 16비트 범위 내에서만 할당되었기 때문에 옛날 방식대로 메시징을 해도 “이론적으로는” 상위 word의 값이 짤려서 문제될 게 없다.
그리고 MFC는 32비트 이후에도 메시지 핸들러 함수가 16비트 시절과 동일하게 CWnd::OnCtlColor에서 처리하게 돼 있다. 즉, 하위 호환성이 유지된다. MFC의 소스를 보면 WM_CTLCOLOR???? 메시지들을 모두 CWnd::OnNTCtlColor라는 내부 함수에다 한데 모은 뒤, 특수 처리를 해서 OnCtlColor로 재전달을 하게 돼 있다.

(3) 세월이 흘러서 컴퓨터 환경이 바뀌고 Windows의 버전이 올라가면 새 메시지만 추가되는 게 아니라.. 기존 메시지가 용도나 존재감을 잃고 잉여로 전락하는 경우도 있다.
WM_QUERYDRAGICON은 최소화된 창이 아이콘 모양으로 떠 있던 Windows 3.x의 GUI 엔진의 잔재이다. 요즘으로 치면 리스트뷰 컨트롤에서의 큰 아이콘 모드와 비스무리한 동작인데.. 95/NT4부터는 저 메시지가 전혀 쓰이지 않는다.

WM_COMPACTING이라고 현재 시스템에 메모리가 부족한 상황임을 알리는 메시지도 있다. 이 메시지는 정확하게 메모리의 양이 부족해졌거나, 가상 메모리 스왑 파일 thrashing 시간이 너무 길어졌을 때 발생하는 것도 아니다.

16비트 시절에는 가상 메모리라는 게 없었기 때문에, 메모리의 단편화(leak이 아니라 fragmentation) 정도를 모니터링 하고 연속된 빈 메모리 공간을 많이 확보해 두는 걸 운영체제가 알아서 해야 했다. 응용 프로그램은 당장 사용하지 않는 메모리는 이동과 재배치가 가능하게 해 줘야 했는데.. 이렇게 메모리 재배치에 걸리는 시간이 일정 수준 이상 너무 길어진다 싶으면 이 메시지가 날아갔다. 32비트부터는 당연히 존재감이 전혀 없어졌다.

그리고.. 256색 팔레트 관련 메시지들도 21세기쯤부터는 완전히 퇴출 상태이다. Windows XP 무렵부터는 이제 안전 모드에서도 그래픽이 하이컬러 이상이지, 구닥다리 16색/256색 따위는 완전히 퇴출됐기 때문이다.

요즘 컴퓨터 기기에서 뭔가 자원이 부족하다는 메시지는 배터리 부족 정도밖에 없지 싶다.
WM_USER 이내에 시스템 메시지를 추가할 공간이 도저히 남아 있지 않다면, 이제 쓰이지 않게 된 구닥다리 메시지의 값을 재활용해야 하지 않을까?

Posted by 사무엘

2021/01/05 08:33 2021/01/05 08:33
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1839

인쇄 기능 -- 下

지난번 상편에서는 Windows에서 인쇄 기능을 코딩으로 구현하는 절차와 인쇄 관련 기본 개념과 동작들을 살펴보았다. 이번 하편에서는 상편에서 분량상 모두 다루지 못한 인쇄 관련 추가 정보들을 한데 늘어놓도록 하겠다.

1. 인쇄 관련 공용 대화상자들

우리가 Windows에서 가장 자주 접하는 공용 대화상자는 아마 파일 열기/저장 대화상자일 것이다. 이것 말고도 글꼴 선택 내지 색깔 선택 대화상자가 있으며, 인쇄 대화상자도 그 중 하나이다.

사용자 삽입 이미지

인쇄를 지원하는 대부분의 프로그램에서 Ctrl+P를 눌러서 쉽게 보는 인쇄 대화상자라는 건 위와 같이 생겼다. 기본적으로 ‘일반’이라는 탭 하나밖에 없지만, 추후 확장과 사용자의 customize 가능성을 염두에 두고 프로퍼티 시트 형태를 하고 있다. 20년 전, Windows 2000 시절부터 저런 형태가 도입되었다.

사용자 삽입 이미지

하지만 Windows 9x 내지 더 옛날 16비트 시절에는 인쇄 대화상자가 전통적으로 위와 같은 모양이었다. 외형상 가장 큰 차이는 프로퍼티 시트가 아닌 일반 대화상자 형태이고, 프린터 목록이 리스트 컨트롤이 아니라 콤보 상자라는 점이다. 지금도 좀 옛날 프로그램에서는 요런 모양의 대화상자를 여전히 볼 수 있다.

인쇄 대화상자로 할 수 있는 일은 크게 (1) 인쇄를 내릴 프린터를 선택하고, (2) 각 프린터별로 용지의 종류와 방향, 여러 부 인쇄 방식 등을 지정하고, (3) 인쇄할 페이지 영역을 지정하고, (4) 최종적으로 인쇄 명령을 내리는 것이다.
하는 일이 생각보다 많기 때문에 옛날에는 인쇄 대화상자를 약간 간소화시켜서 (2)만 지정할 수 있는 “프린터 설정” 대화상자라는 게 따로 있기도 했다. 아래처럼 말이다.

사용자 삽입 이미지

Windows 말고 아래아한글만 해도 먼 옛날 도스 시절에는 Alt+P는 인쇄 대화상자이지만 Ctrl+P는 프린터 설정이었다는 걸 생각해 보자.
하지만 Windows 2000 스타일의 최신 인쇄 대화상자에서는 인쇄와 프린터 설정이 한데 통합되었다. 어떻게..?? 기존의 '프린터 설정'은 자신의 상위 호환인(superset) 인쇄 대화상자를 '적용'만 누른 뒤 '취소'로 닫는 것으로 퉁친 것이다. 이 때문에 인쇄 대화상자는 딱히 modeless가 아닌 modal 형태--자신이 떠 있는 동안 부모 윈도우로 포커스를 옮길 수 없음--임에도 불구하고 '적용' 버튼이 따로 있는 것이다.

MFC에서는 기본 구현돼 있는 인쇄 기능만 사용하는 경우, 오늘날의 2019 최신 버전까지도 의외로 옛날 스타일의 인쇄 대화상자가 계속해서 나타난다. CPrintDialog는 PRINTDLG 구조체 기반이며, 최신 스타일 대화상자를 지원하려면 PRINTDLGEX 기반인 CPrintDialogEx를 사용해야 한다. 최신 스타일이 도입되면서 내부 동작이 많이 바뀌고 바이너리 호환성이 깨졌기 때문에 Ex 클래스는 오리지널 클래스의 파생형이 아니며, 서로 호환성이 없다.

MFC 없이 Windows API만 사용한다면 재래식 PRINTDLG 구조체만 사용하더라도 최신 스타일 대화상자가 나오게 하는 것 자체는 가능하다. 대화상자 템플릿을 customize하는 것 없이 기본 멤버만 사용한다면 말이다.

그러나 구형 구조체와 구형 API만 사용해서 나타난 최신 대화상자에는 ‘적용’ 버튼이 나오지 않는다. 구형 API에는 함수 리턴값 차원에서 그런 응답 자체가 존재하지 않았기 때문이다.
아울러, 인쇄할 영역을 단순히 x~y쪽이 아니라 x1~y1, x2~y2 이런 식으로 여러 개 지정하는 것도 구형 API로는 불가능하다. 해당 구조체 멤버가 존재하지 않기 때문이다. 이런 식의 제약이 있다.

다음은 관련 여담이다.

(1) PrintDlg 내지 PrintDlgEx의 실행이 성공하면 프린터 DC뿐만 아니라 DEVMODE 내지 DEVNAMES 구조체 내용을 담고 있는 global 동적 메모리 핸들도 돌아온다. 현재 선택된 프린터에 대해서 지정한 용지 등의 설정들이 여기에 저장되기 때문에 응용 프로그램이 이 핸들을 잘 관리하고 있어야 한다. 그래서 다음에 인쇄 기능을 호출할 때 요걸 넘겨줘야 기존 인쇄 설정이 보존된다.

(2) 인쇄 대화상자 말고 용지 설정 대화상자라는 것도 있지만 이건 잘 쓰이지 않는다. 용지 종류와 방향, 상하좌우 여백 정도는 공통이겠지만 그 뒤로 인쇄 내용을 배치하는 방식들은 응용 프로그램들마다 같은 구석이 별로 없기 때문이다. 당장 워드패드, 메모장, 그림판만 해도 용지 설정 대화상자의 모양은 전부 제각각이다.

(3) 운영체제에 설치된 글꼴들을 조회하는 것처럼, 현재 설치돼 있는 프린터들을 조회하는 API도 있다. 인쇄 대화상자를 꺼내지 않고 내 대화상자의 한구석에다가 프린터 목록 같은 걸 마련하고 싶다면 이런 API를 쓰면 된다. 하지만 현실에서 사용할 일은 거의 없을 것이다.

(4) “인쇄 중” 대화상자라든가 “인쇄 미리보기(화면 인쇄)” 같은 건 공용 대화상자 버전이 존재하지 않으며, MFC의 경우 자체 구현돼 있다. 오늘날은 인쇄 진행 상황 같은 건 위대하고 전능하신 task dialog로 너무나 깔끔하게 구현할 수 있을 것이다.
그리고 인쇄 미리보기는 여느 대화상자와 달리 꽤 큰 공간이 필요한 관계로, 전통적인 modal 대화상자보다는 프로그램 주 화면에다가 곧장 미리보기를 표시하는 식으로 디자인이 바뀌는 추세이다. 요즘 MS Office 프로그램들이 대표적인 예이다.

2. 플로터

먼 옛날 한 1980~90년대쯤에 컴퓨터 개론 교양 서적에서는 컴퓨터의 출력 장치로 모니터, 프린터와 더불어 플로터라는 물건도 소개되어 있었다. 플로터는 로봇 팔 같은 게 달려서 종이에다가 도면을 말 그대로 '그려 주는' 장치이다.
덕분에 얘는 직선이나 곡선 하나는 무한한 해상도로 원본 그대로 정확하게 그릴 수 있다. 잉크를 적절히 배합해서 컬러 표현도 가능하다.

하지만 장점은 그걸로 끝.. 인쇄 속도가 도트 프린터 이상으로 끔찍하게 느리며(비록 도트 프린터처럼 시끄럽지는 않지만), 속이 채워진 도형이나 비트맵 같은 그림을 표현할 수 없다.
실제로 플로터를 가리키는 DC에다가 GetDeviceCaps(hDC, RASTERCAPS)를 호출해 보면 RC_BITBLT가 가능하지 않다는 응답이 올 것이다. 비트맵 전송을 할 수 없고 천상 원과 직선과 곡선 그리기나 하라는 소리이다.

일반 가정집이나 사무실에서 A4 용지에다가 인쇄하는 거라면 그냥 닥치고 일반 프린터만 쓰면 된다. 하지만 플로터는 프린터가 범접할 수 없는 A1 같은 엄청나게 큰 종이에다가 도면을 그릴 수 있으며, 펜 대신 커터 같은 걸 꽂아서 선 궤적대로(가령 글자의 윤곽선) 종이를 오려낸다거나 하는 용도로도 활용 가능하다는 것에 존재 의의가 있다. 즉, 산업용으로 마르지 않는 수요가 있다.

하긴, Windows에도 트루타입도 비트맵도 아니고 Modern, Roman, Script처럼 내부가 채워지지 않은 선으로만 구성된 '벡터 폰트'가 있었다. 실용적인 쓸모가 전혀에 가깝게 없는 완전 잉여인지라, 요즘 어지간한 프로그램의 글꼴 목록에서는 조회조차 되지 않을 것이다. 이런 게 바로 플로터용 글꼴이다. 물론 프린터에서도 쓸 수 있지만..

사용자 삽입 이미지

옛날에 도스용 터보 C의 BGI에도 비트맵 말고 벡터 글꼴 컬렉션이 있었다. 큰 크기에서는 내부가 채워지지 않고 선만 그어졌으며.. 힌팅이 없어서 작은 크기에서는 영 보기 좋지 않았으니 반쪽짜리인 건 동일했다. 비트맵 같은 계단 현상만 없을 뿐 다른 방면으로 단점투성이였다.

사용자 삽입 이미지
OpenCV 같은 그래픽 라이브러리도 전문적인 폰트 엔진 없이 자체적으로 영문· 숫자를 뿌리는 기능은 제공되는 글꼴이 딱 저런 퀄리티이다.

3. 프린터 드라이버의 가상화

컴퓨터라는 기계는 개나 소나 다 “물리적으로는 없지만 그래도 일단 있다고 치자”라고 넘기는 가상화가 통용되는 동네이다. 메모리는 진작부터 가상화하고 지내고 컴퓨터 자체도 가상 머신, 그리고 iso 같은 광학 디스크는 뭐.. 이제 운영체제(드라이브 마운트)와 압축 유틸조차(생성) 정식으로 지원하는 세상이 됐다.

그러니 프린터도 당연히 가상화의 대상이다. 당장 내 자리에 프린터가 존재하지는 않지만 어떻게든 인쇄를 하는 방법은 크게 두 가지인데, 하나는 그냥 pdf나 xps 같은 파일로 인쇄하는 것이고 다른 하나는 원격 프린터에 네트워크로 연결해서 인쇄하는 것이다. 한때는 컴퓨터조차 한데 공유하는 자원이고 각 사용자는 단말기로 접속만 하던 시절이 있었지만 지금은 프린터 정도나 사무실 같은 데서 여러 컴퓨터들이 한데 공유한다는 점이 흥미롭다.

옛날에는 PC에 Windows 운영체제만 달랑 설치하고 나면 프린터가 잡힌 게 없었다. 프린터는 네트워크나 비디오/오디오 장치만치 필수는 아니니까..
기본 프린터가 없는 컴퓨터에서는 어지간한 프로그램에서 인쇄 미리보기 기능조차 동작하지 않았다. 프린터 공급 용지의 크기를 알 수 없기 때문이다. 하지만 Vista부터는 xps 문서 생성기라는 드라이버가 기본 연결되어 저런 광경을 볼 일은 없어졌다.

pdf/xps가 대중화되기 이전에도 "파일로 인쇄"라는 개념 자체는 마치 "램 드라이브"와 비슷한 위상으로 존재했으며, 지금도 인쇄 대화상자의 옵션으로 존재한다. 인쇄할 내용이 저장된 컴퓨터와 프린터가 연결된 컴퓨터가 서로 일치하지 않을 때 파일로 인쇄하는 기능이 꼭 필요하지 않겠는가?

하지만 지난번 글에서도 잠시 언급했듯이 이런 파일 인쇄 결과는 특정 프린터 기종에 종속적이기 때문에 범용성이 떨어진다. 본인도 저걸 활용한 적은 거의 없었던 것 같다. 그 대신 오늘날은 위지윅만 보장되고 물리적인 프린터의 구조와는 철저하게 독립적인 전자 문서 파일 포맷이 활발히 쓰이고 있다.

우리나라는 인터넷으로 은행 거래나 공문서 발급 같은 걸 받을 때 보안 ActiveX들을 잔뜩 설치해야 해서 원성이 자자하다. 그래서 이런 사이트 접속 전용으로 ActiveX 설치 총알받이 가상 머신을 만들려고 해도 안 되는 경우가 많다. 반드시 본머신(?)만 쓰라고 강요하면서 가상 머신에서는 설치되기를 거부하는 매우 악랄한 ActiveX도 있기 때문이다.

그것처럼.. 증명서 같은 것을 인쇄하는 전용 프로그램의 경우, 가상 프린터인 건 또 어떻게 감지하는지 pdf 같은 파일 생성은 거부하는 경우가 대부분이다.
가상 CD를 감지하는 프로그램은 못 봤는데 프린터와 PC는 어째 감지하는지? 신기한 노릇이다. 하드카피 종이 인쇄는 뭐 변조 못 할 줄 아나..;; 어차피 원본대조필 워터마크가 필요한 건 똑같을 텐데.

한편, 가상 프린터 드라이버라는 게 파일 아니면 네트워크만 있는 건 아니다. 예전에는 FinePrint라고 인쇄되는 데이터를 인위로 보정해서 1페이지에 여러 페이지 내용을 축소해서 인쇄한다거나, 잉크의 농도를 인위로 줄이는.. 뭐랄까 메타 프린터 드라이버 유틸이 있었다. 최종 목적지는 물리적인 프린터이지만 그 사이에 중재를 한다는 것이다.
하지만 요즘은 축소 인쇄라든가 잉크 절약 모드는 프린터 드라이버 차원에서 기본 제공되는 옵션이 돼서 그런 메타 드라이버의 필요성이 많이 줄어든 게 사실이다.

또한, 레이저 프린터는 기술 배경이 복사기와 비슷하다 보니, 같은 페이지를 여러 부 인쇄하는 것을 소프트웨어가 아니라 프린터에게 맡기는 게 매우 능률적이다.
양면 인쇄를 위해 페이지별 인쇄 순서를 교묘하게 바꾸는 것도 해당 워드 프로세서/전자출판 프로그램, 심지어 메타 드라이버가 담당할 법도 하지만.. 요즘은 프린터 드라이버 차원의 옵션으로 자체 제공하기도 한다. 물론 파일로 인쇄할 때는 이런 것들은 전혀 고려할 필요가 없을 것이다.

4. 창 내용을 인쇄(?)하는 API

끝으로, 이것만 마지막으로 언급하고 글을 맺도록 하겠다.
Windows에서 화면에 표시돼 보이는 각각의 창(윈도우!)들은 WM_PAINT라는 특수한 메시지가 왔을 때 invalid region과 BeginPaint - EndPaint 사이클에 맞춰 자기 내용을 그리는 일에 특화돼 있다. 이는 창 내용을 다시 그리라는 요청이 여러 번 반복해서 오더라도 그리는 건 한 번만 행해지고, 꼭 다시 그려야 하는 부분만 효율적으로 그리기 위한 조치이다.

그런데 가끔은 윈도우도 저런 사이클에 구애받지 않고, 특정 DC가 하나 주어졌을 때 묻지도 따지지도 말고 거기에다가 자기 모습 전체를 있는 그대로 싹 다시 그리게 하고 싶을 때가 있다.
이럴 때를 위해 운영체제는 WM_PRINT 및 WM_PRINTCLIENT라는 메시지를 정의하고 있다. 이건 어지간한 Windows 프로그래머라도 접하거나 구현해 본 적이 거의 없는  듣보잡일 것이다.

그런데 Windows XP에서는 저 메시지로도 모자라서 하는 일이 거의 차이가 없어 보이는 PrintWindow라는 함수까지 추가됐다. 이건 뭐 WM_GETTEXT와 GetWindowText의 관계와 비슷한 것일까?
MSDN을 찾아보면..

  • The PrintWindow function copies a visual window into the specified device context (DC), typically a printer DC.
  • The WM_PRINT message is sent to a window to request that it draw itself in the specified device context, most commonly in a printer device context.

이라고 이런 물건들이 주로 인쇄용으로 쓰일 거라고 대놓고 문서화돼 있다. 하지만 현실에서 창 스크린샷 한 장 달랑 프린트 할 일이 얼마나 될까? 실제로는 이것들 역시 화면 출력용으로 쓰인다.

가령, alt+tab을 눌렀을 때 나타나는 각 프로그램 창들의 썸네일 말이다. 물론 Vista 이후부터는 DWM이 창들 화면을 하드웨어빨로 몽땅 저장하는 세상이 되긴 했지만, 그런 것 없이 invalid region과 무관하게 자기 모습 캡처 화면을 떠야 할 때는 저런 함수/메시지가 필요하다.

그리고 메뉴나 콤보 상자 목록이 스르륵 미끄러지며 표시되는 것 말이다. 이런 걸 구현하기 위해서도 WM_PAINT와 별개 계통인 그리기 전담 메시지가 쓰인다.
스르륵 미끄러지는 걸 구현한답시고 매 프레임마다 WM_PAINT가 날아온다거나 하지는 않는다. WM_PRINTCLIENT를 날려서 전체 리스트 모양을 메모리 DC에다가 한번 그려 놓은 뒤, 그걸로 애니메이션은 운영체제가 알아서 구현해 준다.

이런 걸 생각하면 창 핸들과 DC 하나 던져주고 print를 요청하는 메시지와 함수가 왜 필요하고 어떨 때 쓰이는지 약간 수긍이 갈 것이다. 하지만 그게 왜 하필 print라는 단어가 붙었으며 함수 버전과 메시지 버전이 모두 필요한지는 나로서는 아직도 잘 모르겠다.

Posted by 사무엘

2020/10/26 08:37 2020/10/26 08:37
, , , ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1812

인쇄 기능 -- 上

1. 모니터와 프린터의 차이

소프트웨어를 개발하면서 프린터 인쇄 기능을 구현할 일은 그리 많지 않을 것이다. 넓게는 pdf를 생성하는 것까지 포함하더라도 말이다. 인쇄 기능이 존재하는 프로그램이라면 게임은 절대 아닐 것이고 아무래도 골수 업무 분야일 것이다.

뭐, Windows API의 경우, GDI API에서 사용되는 DC라는 게 처음부터 극도의 장치 독립과 추상화를 추구하면서 매우 범용적으로 설계되었다. 얼마나 추상적인가 하면, 아직 VGA도 없던 1980년대의 Windows 1.0부터 얘네들의 그래픽 API에는 색깔을 팔레트 인덱스 기반으로 선택하는 게 아예 없었으며 언제나 RGB 기반이었다.
그러니 글자와 그림을 찍는 기본적인 동작은 화면에다 그릴 때나 종이에다 그릴 때나 거의 같은 코드로 구현할 수 있다.

물론 두 장치는 성격이 근본적으로 완전히 다른 물건이다 보니, 코드가 100% 완전히 같을 수는 없다. 프린터는..

  • 3D 가속 렌더링이라든가 동영상 따위와는 전혀 접점이 없다.
  • 출력 속도가 화면보다 훨씬 더 느리다.
  • 색깔은 뭐.. 흑백 프린터도 여전히 현역인 것을 감안해야 한다.
  • 출력이 한없이 연속적인 게 아니며, 페이지의 구분이 존재한다.
  • 프린터가 꺼졌거나 아예 연결되지 않은 것, 용지가 없는 것, 종이가 걸린 것 등으로 인한 실패 확률이 높다.
  • 다만, 해상도는 프린터가 모니터를 아득히 초월할 정도로 높다. 오늘날 HDPI 화면의 해상도가 이제 30~40여 년 전 도트 프린터의 해상도(180dpi)를 따라잡은 것과 비슷한 수준이다.

화면용 3D/애니메이션 위주의 그래픽 API가 DirectX 기반으로 눈부시게 바뀐 동안, 프린터 쪽은 GDI+ 정도 말고는 수십 년 전이나 지금이나 별로 바뀐 게 없다. 글쎄, 인쇄 대화상자의 디자인이 살짝 바뀌었으며 Windows Vista/7 즈음에는 xps라고 pdf 같은 위지윅 전자 문서 생성 API도 추가되긴 했지만, 이게 통상적인 인쇄 절차를 대체할 만한 물건은 아닌 것 같다.

2. Windows에서 통상적인 인쇄 절차

정말 핵심 중의 핵심 기본은 다음과 같다.

  • 화면에다가 그릴 때는 GetDC, BeginPaint 따위로 DC를 얻었다. 하지만 인쇄용 DC를 얻을 때는 PrintDlg 함수의 실행 결과로 얻어진 DC를 쓰는 편이다. CreateDC 직통으로 DC를 생성하는 건, 특정 프린터만을 저격하는 아주 특수한 프로그램을 만드는 상황이 아닌 한 거의 없다.
  • 인쇄를 시작할 때는 저 DC에 대해서 특별히 StartDoc이라는 함수를 호출한다. 이렇게 프린터 DC에서만 사용 가능한 전용 함수들이 몇몇 좀 있다.
  • 그리고 매 페이지에다 인쇄할 때마다 시작과 끝을 StartPage와 EndPage로 해 준다. 인쇄하고자 하는 페이지 수만치 for문을 돌리면 된다. 그 동안 프린터 DC를 상대로 각종 GDI 함수를 호출해서 글자와 그림을 찍도록 한다.
  • 인쇄를 마치려면 EndDoc을 호출하고, 중간에 인쇄가 취소됐다면 AbortDoc을 호출한다. 이거 무슨 저그 스커지가 적기와 자폭하느냐, 아니면 피가 닳아서 죽느냐의 차이와 비슷하다.;;
  • 다 사용한 DC는 ReleaseDC가 아니라 DeleteDC로 해제한다. 화면 DC 말고 메모리 DC와 프린터 DC는 저 함수로 해제해야 한다.

아 참.. 똑같이 DC를 사용하더라도 화면에다 그릴 때와 프린터에다 그릴 때의 매우 큰 차이가 하나 있는데, 바로 좌표계이다.
화면에는 그냥 픽셀 단위를 가리키는 MM_TEXT가 간단하고 직관적이니 널리 쓰이지만 프린터에는 그런 게 없다. 프린터의 해상도와 무관한 밀리미터, 인치, 포인트 등의 실제 길이 단위 기반의 좌표계를 지정해 줘야 한다(SetMapMode 함수).

그리고 MM_TEXT를 제외한 나머지 추상 단위계들은 수학 좌표계처럼 y축의 값이 증가하는 방향이 위로 올라가는 방향이다. 이건 동일 코드로 화면 출력과 프린터 출력을 모두 구현하는 것을 어렵게 하는 주범이다. BMP 이미지가 화면 기준으로 파일이 상하가 뒤집힌 구조인 이유도 이런 좌표계를 염두에 두고 만들어졌기 때문이다.
인쇄 미리보기를 구현한다거나 더 나아가 편집 화면 차원에서 프린터 결과와 화면 결과가 동일한 위지윅 프로그램을 개발한다면, 어차피 화면에서도 저런 범용적인 좌표계를 사용해야 할 것이다.

3. 용지 정보 얻기 (크기와 방향)

그러고 보니 응용 프로그램이 인쇄를 하기 위해서는, 아니 그 전에 인쇄 분량 계산을 하기 위해서는 먼저 인쇄되는 종이의 크기를 알아야 한다. 이 정보는 인쇄를 하는 당사자인 프린터 드라이버가 갖고 있으며, 응용 프로그램은 운영체제 API인 GetDeviceCaps(hPrinterDC, HORZSIZE 또는 VERTSIZE)를 호출해서 얻을 수 있다.
이 함수의 리턴값은 언제나 밀리미터 단위이다. 그러므로 mm 계열이 아닌 좌표계를 사용하고 있다면 적절히 변환해서 글자를 찍으면 된다.

여기서 알 수 있듯, 인쇄 용지의 크기라는 것은 프로그램이 인쇄를 위해 프린터 DC를 생성하기 전까지는 알 수 없다. 하지만 MS Word나 아래아한글처럼 위지윅을 지원하는 워드 프로세서 부류의 프로그램은 자체적으로 가상의 용지를 설정하고 동작한다.
문서에 설정되어 있던 용지와 실제 인쇄 용지가 크기나 종횡비 같은 게 일치하지 않는다면.. 인쇄할 때 배율 같은 걸 적절히 보정해 줘야 가장자리 내용이 짤리지 않는다. 그건 해당 프로그램이 담당해야 하는 영역이다.

한편, 용지의 크기뿐만 아니라 인쇄 방향--세로 portrait 또는 가로 landscape--도 응용 프로그램의 페이지 옵션과 프린터 내부의 옵션이 서로 따로 노는 형태이다. 그럴 만도 한 게.. 기능이 매우 빈약한 메모장으로 인쇄를 하건, 아래아한글로 인쇄를 하건, 가로· 세로 인쇄는 응용 프로그램과 무관하게 언제나 가능한 게 정상이기 때문이다.

그렇기 때문에 인쇄 대화상자에서 각 설치된 프린터별 설정 대화상자를 또 연 뒤, 용지의 방향을 바꿔 줄 수 있다.
인쇄 방향이 프린터 차원에서 landscape(가로)로 설정되었다면 GetDeviceCaps(hPrinterDC, HORZSIZE)의 값이 VERTSIZE의 값보다 더 커진다.

그리고 PrintDlg 함수는 프린터 DC를 생성하면서 PRINTDLG 구조체에다가 DC뿐만 아니라 DEVMODE라는 구조체 내용을 담고 있는 메모리 핸들도 따로 되돌려 주는데, 여기에서 dmOrientation이라는 멤버를 참조하면 용지의 방향을 알 수 있다. (DMORIENT_PORTRAIT 또는 DMORIENT_LANDSCAPE)

물론, 프린터의 용지 방향이 세로이더라도 내 프로그램에서의 용지 방향 설정이 가로로 돼 있으면 이를 감지하여 내가 직접 그림을 90도로 눕히고 돌려서 그리면 된다. 그러면 어차피 가로 방향 인쇄와 동일한 효과를 낼 수 있다.
하지만 그 정도 수고는 워드 프로세서 급에서나 할 일이다. 일반적인 프로그램이라면 그냥 프린터 설정만 따라서 내용을 출력하면 된다.

이렇듯 가로 세로 방향 전환이라는 건 전통적으로 프린터에만 존재하는 개념으로 여겨졌으나, 요즘은 디스플레이 장비에서도 어렵지 않게 찾을 수 있다. 스마트폰은 말할 것도 없고(방향 전환), 모니터도 방향을 전환하는 피벗 기능이 있기 때문이다. 가로로 납작한 모드는 영화를 볼 때 유용할 것이며, 세로로 길쭉한 모드는 문서 편집 내지 코딩을 할 때 유용할 것이다.

4. 인쇄 전담 계층의 분리

요즘 프린터는 한 줄씩 데이터를 받는 족족 타자기처럼 출력물을 토해내는 게 아니라 복사기처럼 최소한 페이지 단위로 동작한다. 운영체제의 인쇄 관리자는 응용 프로그램이 StartDoc ~ EndDoc 사이에서 GDI 함수로 명령을 내린 것들을 마치 메타파일 생성하듯이 모았다가 프린터 드라이버로 보낸다. 그럼 프린터 드라이버는 그걸 프린터가 해석할 수 있는 인쇄 동작으로 바꿔서 기계에다 전송한다.

즉, 응용 프로그램의 입장에서는 인쇄할 데이터를 운영체제의 인쇄 관리자에다가 다 보내 놓기만 하면 명목상 인쇄를 다 마친 것이다. 그 뒤에 실제로 인쇄가 제대로 됐는지, 도중에 종이 부족 같은 문제가 발생하지는 않았는지를 프린터와 통신하며 챙기는 건 인쇄 관리자의 영역이다.
이 인쇄 관리자를 '프린터 스풀러'라고 부르는 편인데, SPOOL은 다른 단어들의 이니셜이다.

문서를 실제로 인쇄하는 것보다야 같은 소프트웨어인 스풀러에게 인쇄 데이터를 파일 형태로 생성하고 전달하는 게 훨씬 더 빨리 되며, 중간에 실패할 일도 거의 없다. 그러니 스풀러가 별도로 분리되어 있으면, 응용 프로그램의 입장에서는 인쇄를 굉장히 빨리 끝마치고 사용자가 프로그램을 사용할 수 있는 상태로 신속하게 복귀할 수 있다.

그 반면, 도스 시절에는 지금처럼 운영체제 차원에서 인쇄 관리자가 제공되는 게 없었다. 그래서 도스용 아래아한글 같은 프로그램은 스풀러 기능을 내부에서 직접 구현해야 했다.
그리고 스풀 옵션을 사용하지 않거나, MB급 단위인 스풀 데이터를 저장할 디스크 공간이 부족하거나, 아예 스풀 기능이 없던 아래아한글 1.x 시절에는...
수십 페이지의 인쇄 명령을 내려놓았다면 다 끝날 때까지 컴퓨터를 사용하지 못하고 기다려야 했다. 스풀러가 아니라 그 느린 프린터로 데이터를 전부 보내야 했기 때문이다. 지금으로서는 도저히 믿을 수 없는 삽질이다.

도스 시절에는 PC 통신 프로그램은 전화를 걸거나 업로드/다운로드를 하는 중에 멀티태스킹을 하는 게 핵심 기술이었다. 그것처럼 워드 프로세서는 인쇄 중의 멀티태스킹이 핵심 기술이었던 셈이다.
1994년에 출시되었던 도스용 아래아한글 2.5는 프린터에다가 인쇄 데이터를 전송하는 방식을 개선해서 인쇄 속도를 크게 향상시켰다고 광고했었는데.. 그 기술 디테일이 무엇이었는지는 개인적으로 지금도 알 길이 없다.

프린터 스풀링용 데이터는 파일의 형태로 인쇄를 한 것이니 '인쇄의 가상화' 결과물이라고 볼 수 있다.
하지만 아무래도 특정 프린터 하드웨어에 지극히 종속적인 형태일 것이므로 pdf나 xps 같은 장치 독립까지 만족하는 전자문서라고 볼 수는 없다.

글쎄, 도스 말고 Windows에서도 3.x + 옛날의 굉장한 구식 도트 프린터의 경우(KS 24핀 180dpi 이러던..-_-), 응용 프로그램에서 인쇄 명령을 내리면 요즘처럼 인쇄 관리자와 해당 프린터 드라이버의 자체 UI가 뜨는 게 아니라 직통으로 바로 인쇄가 시작되기도 했던 것 같다. 거의 30년 가까이 전의 추억이다.

5. 인쇄를 중간에 취소하기

제아무리 인쇄 과정이 가상화돼서 프린터가 아니라 인쇄 관리자에게만 인쇄 데이터를 넘겨주면 된다 하더라도.. 수십· 수백 페이지 분량의 문서를 인쇄하는 건 1초 안으로 호락호락 금방 끝나는 작업이 아니다.
더구나 속도와 별개로 사용자가 인쇄 작업을 중간에 취소할 수 있게도 해 줘야 한다. 현재 페이지만 인쇄하려 했는데 실수로 100페이지짜리 인쇄를 몽땅 시켜 버리는 건 흔히 저지르는 실수이다.

그렇다면 요즘이야 해결책이 아주 간단하다. "전체 x쪽 중 현재 y쪽 인쇄 중"이라는 진행률 게이지와 "취소" 버튼이 달린 대화상자를 modal로 표시한 뒤, 인쇄는 스레드로 진행하면 된다. 인쇄 스레드는 매 페이지의 인쇄가 끝났을 때마다 main UI로부터 취소 버튼의 클릭 여부를 검사하고, 만약 그게 눌렸다면 AbortDoc을 호출해서 인쇄를 취소하고 곧장 빠져나오면 된다.

그런데 문제는 멀티스레드라는 게 존재하지 않던 옛날 16비트 골동품 시절이다. 이때는 실시간 인쇄 상황 표시와 취소 처리를 어떻게 했을까?
그때는 main 스레드가 근성의 idle time processing만으로 UI와 인쇄를 같이 병행해야 했다. 그리고 이를 도와주는 취지의 API가 제공되었다. 그 정체는 SetAbortProc라는 함수이다.

인쇄를 시작하기 전에 프린터 DC에 대해 abort 콜백 함수를 지정해 주면.. 나중에 그 DC를 대상으로 각종 그리기· 조작 명령이 수행된 뒤에 운영체제가 그 콜백을 매번 호출해 준다. 마치 잠수하다가 수시로 수면 위로 잠깐씩 나와서 숨을 쉬는 것처럼 말이다.
이때 콜백 함수가 해야 할 일은 두 가지였다. (1) 큐에 쌓여 있는 메시지를 처리해서 프로그램의 GUI를 돌아가게 하기, 그리고 (2) 혹시 사용자가 인쇄 취소 명령을 내렸는지 검사해서 그 여부를 리턴값으로 되돌리기.

이를 위해서 콜백 함수에는 무려 message loop이 들어가 있어야 했다. 단, 종료 조건을 통상적인 GetMessage로 하지 말고 PeekMessage(... PM_REMOVE)로 지정해야 한다. 전자는 처리해야 할 메시지가 없으면 메시지가 또 생길 때까지 내부적으로 대기를 한다. 하지만 지금 이 콜백은 메시지 처리만 하고 나서 실행을 종료해야 하기 때문이다.

그리고 SetAbortProc을 호출 하기 전에 "인쇄 중..." 대화상자를 표시해 놔야 한다. 이 대화상자는 백그라운드 인쇄 기능과 연계해서 돌아가야 하는 관계로, 응용 프로그램의 자체 message loop을 타는 modeless 형태로 표시돼야 한다. DialogBox 말고 CreateWindow를 쓰라는 뜻이다.
그래도 이 대화상자의 용도는 명백하게 modal이니, 이게 표시된 동안은 parent 프레임 윈도우로 포커스가 옮겨질 수 없게 EnableWindow(hParent, FALSE) 처리도 사용자가 수동으로 해야 한다.

SetAbortProc 같은 메커니즘이 없었다면 인쇄 도중 UI 표시와 취소를 구현하기 위해서 우리가 수동으로 인쇄 루틴의 내부에다 PeekMessage 체크를 집어넣어야 했을 테니 인쇄용 코드와 인쇄 미리보기용 코드조차 동일하게 관리하기가 어렵고 프로그램이 많이 지저분해졌을 것이다. 하지만 abort 콜백 함수를 구현하는 건 과거의 클립보드 chain 관리만큼이나 여전히 몹시 삽질스럽고 번거로운 구석이 있었다.

사용자 삽입 이미지
(MFC 라이브러리가 자체 구현한 "인쇄 중" 대화상자)

옛날 프로그램으로 인쇄를 해 보면.. 잠깐 표시되는 '인쇄 중' 대화상자는 왠지 추레해 보이고 (1) 그 흔한 진행률 게이지 하나 없고, (2) 다른 대화상자들과 달리 중앙 정렬돼서 표시되지 않고(좌측 상단에 치우쳐서) [X] 버튼도 없으며, (3) 반응성이 안 좋아서 종종 응답이 멎기도 하던 이유들이 모두 설명된다.
(1)은 16비트 시절엔 진행률 게이지 공용 컨트롤이 없었기 때문이요, (2)는 modal이 아닌 modeless로 처리됐기 때문, (3)이야 뭐.. idle time processing으로 돌아가니 반응성이 좋지 않은 것이다.

이런 지저분함은 앞서 언급했듯이 멀티스레드가 등장한 뒤에야 과거 유물로 남게 되었다.
하지만 과거에 나왔던 Windows 프로그래밍 책들은 여전히 옛날 전통적인 방식으로 SetAbortProc 기반의 인쇄 절차를 소개하고 있으며, 16비트 시절부터 유구한 전통과 짬을 자랑하는 MFC도 그 구조를 고스란히 따르고 있다.
SetAbortProc가 함수 주소만 받고 함수에다 넘겨줄 데이터를 받지 않는 것도.. 데이터쯤은 그냥 전역변수로 넘겨주던 1980년대 C스러운 사고방식의 결과물이 아닐까 싶다.

참고로 MS Word의 경우, 신기하게도 스풀러에게 인쇄 데이터를 넘겨주는 작업조차도 철저하게 백그라운드로 수행된다. 즉, "인쇄 중" 대화상자 자체가 표시되지 않으며, "몇 페이지 인쇄 중"이라는 말은 아래의 상태 표시줄에 표시된다. 그 와중에도 문서 편집과 수정이 가능하고 프로그램을 온전하게 사용할 수 있다. 이런 프로그램도 얼마든지 만들 수 있다.;;

Posted by 사무엘

2020/10/23 08:35 2020/10/23 08:35
, , , ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1811

1990년대 후반, Windows 95에서 98에서 넘어갈 무렵에 PC에는 USB 포트가 등장하고 마우스에는 휠이 추가되는 등 여러 변화가 일어났다.
그리고 그래픽 카드가 성능이 향상되면서 컴퓨터 한 대에 모니터를 두 대 이상 연결할 수도 있게 되었다. 이렇게 화면 공간을 키우니 컴퓨터 작업 생산성과 능률이 획기적으로 향상될 수 있었다.

그런데 이렇게 멀티모니터를 쓰는 건 다 좋은데 약간 불편할 때가 있다.
한창 제2 보조 모니터에서 작업을 하다가(탐색기 따위) 새 프로그램을 실행했는데 그 프로그램의 창은 언제나 다른 모니터(십중팔구 제1 주 모니터)에서만 나타나서 고개를 돌려야 하는 것 말이다. 그 프로그램은 무엇이 문제인 걸까?

마지막으로 종료되던 당시의 창 위치와 크기, 상태(특히 최대화 여부)를 기억해 뒀다가 다음에 재구성하는 프로그램이라면 뭐 더 할 말이 없다.
그리고 그런 것 없이 CreateWindowEx 함수에다가 CW_USEDEFAULT(알아서 해라~~)만 지정하고 때우는 프로그램이라면... 이 역시 논란의 여지가 없다. CW_USEDEFAULT를 해 주면, 같은 프로그램을 여러 번 실행했을 때 운영체제가 다음 창은 이전 창보다 약간 우측 하단에 배치해서 서로 겹치지 않게도 해 준다.

문제는 대화상자 기반의 프로그램이다.
그냥 운영체제의 제일 저수준 DialogBox 같은 함수만 쓰면 대화상자가 모니터나 parent(owner) 윈도우의 좌측 상단에 표시된다. 이는 일반적으로 바람직한 결과가 아니기 때문에 응용 프로그램이 창을 인위로 중앙으로 옮기는 후처리를 한다. MFC에도 이런 보정을 하는 훅 프로시저가 있다.

그런데 owner 윈도우가 딱히 지정되지 않았다면 한 화면 전체를 중앙 좌표 계산의 기준으로 삼아야 할 텐데, 이 화면이란 건 선택의 여지 없이 주 모니터의 화면으로 지정되곤 한다. 주 모니터 말고 보조 모니터를 기준으로 실행되게 할 수는 없을까?

프로그램을 실행할 때는 여느 대화상자를 띄울 때와 달리 parent 윈도우를 지정하는 게 없다. 그러니 사용자가 어느 모니터에서 작업을 하고 있는지도 알 수 없다.
이런 상황에 대처하기 위해 Windows에서는 프로그램을 실행할 때 기준으로 삼을 모니터 핸들을 주고받을 수 있다. 마치 WinMain 함수에 전달되는 명령 인자 문자열이나 창을 띄울 방식(SW_SHOW 따위)처럼 말이다.

일단, 타 프로그램을 실행하는 프로그램에서 모니터 정보를 직접 공급해 줘야 한다. 글쎄, 키보드 포커스를 받고 있는 윈도우가 속해 있는 모니터로 자동화의 여지가 없지는 않아 보이지만.. 일단 이건 굉장히 UI 종속적이고 인위적인 정보이다. 그렇기 때문에 운영체제가 자동화를 해 주지 않는다.

모니터 정보를 지정하면서 프로그램을 실행하는 함수로 일단 ShellExecuteEx가 있다.
SHELLEXECUTEINFO 구조체에서 fMask에다가 SEE_MASK_HMONITOR 플래그를 지정한다. 그 뒤 hMonitor에다가 HMONITOR 값을 주면 된다. 이 값은 MonitorFromWindow 같은 함수를 통해 얻을 수 있다.

저 구조체에서 hMonitor 멤버가 있는 자리에는 원래 hIcon이라는 멤버가 있었다. 얘는 도대체 왜 추가됐고 무슨 용도로 쓰이는지 알 길이 없다. 프로그램을 실행하는 데 무슨 아이콘을 지정할 일이 있는지(입력)?? 실행된 프로그램의 아이콘을 얻어 오는(출력) 것도 아니다. 그래서 현재는 이 자리가 hMonitor로 완전히 대체된 듯하다.

다음으로 모니터 정보를 받는 쪽에서는.. GetStartupInfo 함수를 실행해서 결과를 확인하면 된다. 그런데 그 방법이 좀 므흣하다.
STARTUPINFO 구조체에서 dwFlags에 STARTF_USESTDHANDLES 플래그가 지정되지 않았는데도 hStdOutput에 NULL이 아닌 값이 있으면 그게 실은 파일 핸들이 아니라 모니터 핸들이다. 요 값을 토대로 화면 좌표를 얻으면 된다. 따로 모니터 핸들이 온 게 없으면 예전처럼 주 모니터를 사용하면 되고..

Windows 탐색기는 프로그램을 실행할 때 그 탐색기 창이 표시돼 있는 모니터의 핸들을 저렇게 꼬박꼬박 넘겨준다. 그러니 Visual C++ IDE를 통해 실행하지 말고..;; 탐색기로 실행하면 모니터가 제대로 식별되는지를 테스트할 수 있다.

여기까지가 일단 MSDN에 문서화돼 있는 내용이다.
참고로, 앞서 언급했던 overlapped 윈도우의 CW_USEDEFAULT는 본인이 확인해 보니 확실하게 multiple-monitor-aware이다. 윈도우 클래스 이름과 모니터별로 마지막으로 창을 생성했던 위치를 기억하고 있어서 서로 겹치지 않게, 그리고 이 프로세스에 전달된 기본 모니터에 맞게 창을 적절한 위치에 생성해 주는 것으로 보인다. 그러니 프로그래머가 무슨 정보를 얻어 오고 지정하지 않아도 된다.

다만, MFC는 대화상자를 표시할 때 화면 중앙 보정만 해 주지, owner가 없는 대화상자에 대해 모니터까지 감안한 처리를 하지는 않는다. (최신 2019의 MFC의 소스 기준) 언제나 주 모니터를 기준으로만 처리하니 일면 아쉽다.

끝으로.. 본인은 의문이 들었다.
ShellExecuteEx도 궁극적으로는 제일 저수준의 프로그램 실행 함수인 CreateProcess를 호출할 텐데, CreateProcess로 직통으로 모니터를 지정할 수 없을까?

조금 검색을 해 보니 의문은 의외로 쉽게 해결되었다. 저 함수에다가 STARTUPINFO 구조체를 지정해 줄 때 모니터 정보를 같이 전달을 할 수 있었다.
dwFlags 멤버에다가.. 문서화되지 않은 0x400이라는 값을 주고, hStdOutput에다가 HMONITOR 값을 주면 된다.

그럼에도 불구하고 이 용법은 지금까지 MSDN에 단 한 번도 언급된 적이 없었다. kernel32 팀과 user32 팀이 서로 연계가 되지 않기라도 했는지, 정확한 이유는 모르겠다.
STARTF_MONITOR 같은 플래그가 정식으로 추가되고, STARTUPINFO 구조체도 SHELLEXECUTEINFO 구조체와 마찬가지로 hMonitor라는 멤버가 hStdOutput 자리에 공용체의 형태로 추가돼야 할 텐데 그렇지 못하다.

Posted by 사무엘

2020/08/06 08:35 2020/08/06 08:35
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1781

1. 파이썬

요즘 프로그래밍 언어는 네이티브 코드+수동 메모리 관리(= 가상 머신이나 GC가 없는) 분야에서야 C++이 무섭게 발전하면서 약진하는 중이다. D나 Rust나 델파이 같은 나머지 네이티브 코드 언어 진영은 요즘 어찌 지내나 모르겠다.

거기서 양상이 살짝 바뀌어서 VM 기반의 언어로는 Java와 C#이 대표적이다. C#은 매우 뛰어난 언어이고 기반이 탄탄한 건 사실이지만 PC와 Windows의 밖에서는 과연 쓸 일이 얼마나 있나 모르겠다. 안드로이드와 iOS 모두 앱 개발용으로 권장하는 주력 언어가 코틀린, Swift 등 생소한 것으로 바뀌었는데, 지난 수 년 동안 기존 언어(Java, Objective C)의 점유율은 어찌 바뀌었는지 역시 궁금하다.

이보다 표현이 더 자유로운 동적 타입 언어 세계에서는 닥치고 JavaScript 아니면 파이썬이 지존 압권 깡패로 등극했다. 펄, 루비, 루아.. 등등 필요 없고 이걸로 다 물갈이돼 버렸다. 특히 파이썬은 교육용과 실무용이라는 두 영역에서 완벽하게 주류로 자리잡았다는 것이 대단하고 신기하다.

대학교들 CS101 프로그래밍 기초 코스에서 가르치는 언어도 C, Java를 거쳐 지금은 몽땅 파이썬이다. 교육용이 아니면 일부 특수한 분야 한정의 마이너 언어에 그쳤던 과거의 베이식이나 파스칼과는 매우 대조적인 점이다. 한편, JavaScript는 웹의 세계 공용어라는 독보적인 지위를 획득했고 말이다.

네이티브 코드인 C++, 가상 머신 기반인 Java, 동적 타입인 파이썬.. 이렇게 등급과 종류를 불문하고 언어들에 한때는 객체지향 패러다임이 들어가는 게 유행이었는데, 21세기에 들어서는 함수형 패러다임도 필수가 돼서 익명 함수(람다) 정도는 지원해 줘야 아쉽지 않은 지경이 돼 있다.

C/C++, Java 같은 언어에만 파묻혀 살다가 컴파일 에러와 런타임 에러의 구분이 없는 언어..
catch되지 않은 예외 같은 에러를 잡고 나니 다음으로 소스 코드의 스펠링 에러를 접할 수 있는 언어를 쓰는 느낌은 참 묘하다.
그래도 컴파일 없이 바로 실행한다는 게 심리적으로 참 부담없고 가벼운 느낌을 준다. 먼 옛날에 Basic 쓰던 시절 이래로 얼마 만에 다시 경험하는 느낌인지?

  • 나눗셈은 정수/정수라도 언제나 실수가 되는구나. 이건 C/C++ 계열이 아니라 베이식/파스칼에 더 가까운 이념이다. 그래도 '같지 않음'이 <>가 아니라 !=인 것은 C/C++ 영향이다.
  • 비트 연산자는 & |로 두고, 논리 연산자를 and or이라는 단어로 분리한 것은 나름 양 계열의 특성을 골고루 적절하게 수용한 디자인인 것 같다.
  • 삼항 연산자 A ? B:C를 B if A else C로 표현한 것은.. 우와;;;;
  • 함수에 인자를 전달할 때 값만 그냥 전하기도 하고 경우에 따라서 config=100 이렇게도 주는 건.. C/C+++ 스타일과 objective C 스타일을 모두 접하는 것 같다.
  • 문자열이나 리스트 같은 복합 자료형에다가 상수배 곱셈 연산을 해서 복제 뻥튀기를 시키는 것도 상당히 유용하다. 단, 이 경우 내부에 있는 복합 자료형들은 shallow copy만 된다. 제대로 deep copy를 하려면 list comprehension 같은 다른 기법으로 원소들을 하나하나 새로 생성해야 한다.
  • 여러 변수에다 한꺼번에 대입하기, 그리고 리스트 원소들을 연달아 함수 인자로 풀어넣기...;;;
  • 코딩을 하다 보면 특정 자료구조 내부의 원소들을 range-based for 문으로 순회함과 동시에, 각 원소별로 1씩 증가하는 인덱스 번호도 같이 돌리고 싶은 때가 많다. 이럴 때 파이썬은 for i, elem in enumerator(set)라고.. enumerator를 사용하면 저 기능을 곧장 구현할 수 있다.. 오, 이거 사이다 같은데?
  • []는 배열, {}는 dictionary. 의도한 건지는 모르겠지만 JSON 자료구조와 딱 정확하게 대응한다.
  • 문자열에 "" ''을 모두 사용 가능한 건 SQL 같다. 다만, 문자로 표현된 숫자 리터럴과의 구분이 없다 보니, 'a'와 97을 상호 변환하는 건 베이식이나 파스칼처럼 별도의 함수를 써야 한다.

2. 각 프로그래밍 언어별로 없어서 처음에 좀 놀랐던 것들

  • JSON: JavaScript라는 프로그래밍 언어의 문법을 채용했다면서 정작 자신은 코멘트를 넣는 부분이 없고 정수 리터럴에 16진수 표기용 접두사가 없다. 얘는 오로지 machine-generation만 생각했는가 보다.
  • Java: int 같은 primitive type을 함수에다 reference로 전달해서 swap 같은 걸 시킬 수 없다. 그리고 가상 머신 환경에서 큰 의미가 없긴 하지만 sizeof 연산자도 없다.
  • 파이썬 1: goto가 없는 건 Java도 마찬가지이지만.. switch-case도 없다. 파이썬은 들여쓰기 구문이 콜론으로 끝나는 언어인데, 정작 C/C++계열에서 라벨과 콜론을 사용하는 문법이 저 동네에서 존재하지 않는 셈이다. 넣어 달라는 제안이 과거에 있긴 했지만 문법적으로 난감해서 봉인됐다고 한다. 뭐, 그 대신 얘는 elif가 있다.
  • 파이썬 2: 그리고 파이썬은 명시적인 const 속성도 없는 것 같다. 튜플이 값의 불변을 보장하는 자료형이기 때문에 const 테이블 역할을 같이 담당한다.
  • 파스칼: 오리지널 문법에서는 임의의 크기의 동적 배열을 만들 수 없다. 참고로 베이식은 배열의 크기 조절은 자유이지만 포인터가 아예 존재하지 않다 보니 리스트 같은 재귀 구조의 복잡한 자료구조를 구현하는 것 자체가 원천 불가능이다.
  • 익명 함수: C++의 람다만 그런 건지는 모르겠지만, 자기 자신을 간단히 가리키는 키워드가 없고 재귀호출을 구현할 수 없다. 그나마 구현했다는 것들은 다 주변의 다른 functor 등 갖가지 편법을 동원해서 매우 힘들게 억지로 구현한 것들이다.

사실, C/C++의 for문은 while문과 거의 동치일 정도로 조건 검사 지향적이고 range-based for는 21세기가 돼서야 도입됐다. 그러나 파이썬의 for문은 훨씬 더 range 내지 iterator 지향적이다.

그리고 베이식 같은 언어는 switch/case가 거의 if문의 연장선일 정도로 범위 지정도 되고 쓰임이 유연하지만.. C/C++의 switch/case는 그보다 제약이 심하다. 그 대신 그 제약을 이용해서 컴파일러가 최적화를 할 여지가 더 있다. (가령, 조건 검사 대신 테이블 오프셋 참조로..)

3. 언어 문법 차원에서의 지원

20여 년 전 먼 옛날에 스타크래프트 경기 중계방송이란 게 처음으로 행해지던 극초창기엔 경기 운영 노하우가 부족해서 이런 일이 있었다고 한다.
경기를 하는 선수 말고 화면 중계를 위한 옵저버도 게임에 join을 해야 하는데, 자기 기지는 없이 남들 시야 눈팅만 하는 상태로 참여하는 방법을 몰랐던 것이다.

그러니 그때 옵저버는 테란을 골라서 들어갔다. 자기 커맨드센터는 띄워서 맵 구석 모서리에 안 보이게 처박아 놓고, SCV 4기는 서로 공격시켜서 없앴다. 이런 궁색한 삽질을 해서 자기 존재를 최대한 없애 버린 뒤 선수들의 화면을 중계했던 것이다.

물론, 옵저버의 이런 자폭 플레이는 경기 시작 직후, 카메라가 잠시 각 선수들의 개인 화면을 비추고 있는 동안 최대한 잽싸게 행해졌다. 한편으로 선수들 역시 옵저버에게 자기 시야를 공개하는 설정을 매번 수동으로 해 줘야 했다.
선수가 옵저버의 커맨드센터를 고의나 실수로 부숴서 옵저버를 엘리시켜 버리는 건.. 그건 경기 진행 방해이며 규정상 거의 반칙 몰수패 사유가 됐을 것이다.;;

그러다가 잘 알다시피 경기용 맵은 특수하게 트리거를 조작해서 옵저버를 위한 전용 자리가 있는 "유즈맵, 커스텀 맵" 형태로 만들어지고 쓰이게 되었다. 이제 옵저버의 일꾼을 제거하고 커맨드센터를 치우는 삽질을 할 필요가 없어진 것이다.
하지만 경기 자체는 다른 특이 사항이 전혀 없고 건물 짓고 유닛 뽑아서 적 진영을 부수는 것밖에 없는데 매번 유즈맵을 쓰는 건 번거로웠다. 스타 프로그램 차원에서 일반 맵에다가 옵저버 참관 기능을 지원하는 게 제일 이상적이고 바람직했다.

결국 옵저버 참관 기능은 먼 훗날 스타의 1.18 패치에서 정식으로 도입됐다. 지난 1.08 패치에서 리플레이 기능이 추가된 것만큼이나 참신한 기능이다.
특히 이 참관 기능은 각 선수들의 개인 화면과 동급으로 진영별 자원 수, 생산· 연구 건물들의 내부 진행 상태까지 모두 볼 수 있어서 매우 편리하다. 과거의 유즈맵 옵저버로는 그런 게 가능하지 않았기 때문에 선수 개인 화면의 모습을 직접 봐야 했다.

이렇게 과거에 꼼수로 구현하던 기능들이 훗날 정식으로 가능해진 것의 예로는 C++ 프로그래밍이 떠오른다.
일례로, 복사나 대입이 가능하지 않은 클래스를 만들기 위해서 복사 생성자나 대입 연산자를 private에다가 미구현 상태로 박아 넣는 꼼수가 동원됐지만.. C++14부터는 = delete라는 더 완전하고 깔끔한 문법이 언어 차원에서 추가됐다.

Posted by 사무엘

2020/08/03 19:31 2020/08/03 19:31
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1780

« Previous : 1 : 2 : 3 : 4 : 5 : 6 : ... 21 : Next »

블로그 이미지

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

- 사무엘

Archives

Authors

  1. 사무엘

Calendar

«   2021/10   »
          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:
1676164
Today:
80
Yesterday:
591