« Previous : 1 : 2 : 3 : 4 : 5 : ... 29 : Next »

1. Dependency Walker

Dependency Walker라고.. Windows용 실행 파일에서 export 심벌과 import 심벌들을 재귀적으로 분석해서 모듈 간의 전체 의존 관계를 그래프 형태로 출력해 주는 굉장히 유용한 유틸리티가 있다. macOS나 리눅스 같은 타 OS에도 모듈 간 의존이라는 개념이 응당 있을 텐데, 타 OS용 실행 파일을 분석하는 프로그램도 좀 있었으면 좋겠다.

얘는 15년쯤 전, Windows Vista의 출시와 비슷한 시기에 마지막 버전이 나온 뒤부터는 원저자에 의한 개발과 유지 보수가 사실상 중단됐다. 뭐, 지금도 그럭저럭 쓸 만하긴 하지만 한 가지 문제가 있다.
Windows 7인지 8인지 10쯤부터는 모듈을 열어 보면 내부적으로 무한 루프에 빠져서 분석하는 데 시간이 굉장히 오래 걸리고 불필요한 정보가 너무 많이 걸려 나오는 경우가 있다.

사용자 삽입 이미지

자세한 속사정은 모르겠지만, 요즘 마소에서는 운영체제 API DLL들을 분야별로 최대한 잘게 쪼개고 있다. Windows 7에서는 kernel32.dll 하나가 제일 먼저 시범타로 쪼개졌었다. 가령 api-ms-win-core-heap, processthreads, memory, file 따위로 말이다.
그랬는데 요즘은 다른 dll들도 마찬가지이다. 레지스트리 API는 전통적으로 advapi32에 있었는데 그건 api-ms-win-core-registry로 가고, gdi32조차 ext-ms-win-gdi-draw, font, paint, path 등등으로 리모델링 됐다.

응용 프로그램들이야 과거와의 호환성을 위해 여전히 kernel32, gdi32 따위로 링크 되겠지만, 이 운영체제에 내장된 기본 프로그램들은 저런 잘게 쪼개진 dll을 직통으로 사용하는 형태로 빌드 된다.
쪼개진 dll들은 시스템 디렉터리에 있지도 않고, winsxs 아래로 도무지 정체를 알 수 없는 길고 복잡한 디렉터리 한구석에 처박히는데.. 딱히 매니페스트가 있지도 않아 보이구만 어떤 원리로 직통 연결되는지 나로서는 모르겠다.

내가 보아하니, Dependecy Walker가 어떤 PC에서는 이런 쪼개진 stub DLL을 모종의 이유로 인해 제대로 분석하지 못하는 것 같다. 거기서 loop cut을 못 하고 위의 스샷에서 표시된 바와 같이 무한 순환 오동작을 일으킨다.
차라리 그 파일을 찾지 못해서 넘어가는 것이면 다행인데, 이것도 100% 올바른 동작이 아닌 건 마찬가지이다.
이런 게 고쳐졌으면 하지만, 저 프로그램은 현재 버전업이 중단된 상태이다. 그렇기 때문에 모 오픈소스 진영에서는 Dependency Walker의 클론을 직접 만들고 있기도 하다.

참고로, 과거의 Windows 9x에서는 kernel32.dll이 원초적인 dll이었다. 즉, 심벌을 export만 하지, 자신은 실행 과정에서 다른 dll을 import 하는 것이 없었다.
그러나 오늘날의 Windows는 ntdll.dll이 원초적인 dll이다.

2. 32비트 프로그램에서 실행 중인 64비트 프로그램의 경로 얻기

GetModuleFileNameEx는 현재 컴퓨터에서 실행 중인 다른 프로세스, 혹은 거기 안에 같이 load된 dll의 전체 파일 경로를 얻어 오는 함수이다.
그런데 얘는 전통적으로 32비트 프로그램에서 64비트 프로그램을 대상으로 정보를 요청하는 건 잘 되지 않는 것으로 유명했다.

그냥 단칼에 실행이 실패하는 것도 아니고, 경로를 되돌리기는 하는데 온전한 형태가 아니라 뒷부분이 짤린 일부만을 되돌렸다. 그리고 에러 코드도 ERROR_PARTIAL_COPY라고 당당히 되돌렸다.
32비트 프로그램이 64비트 프로세스의 주소 공간에 접근하는 게 기술적으로 쉬운 일은 아니겠지만 그건 걔네들 사정일 뿐이다. 사용자 내지 프로그래머의 입장에서는 겨우 이런 간단한 정보 하나 온전히 얻으려고 IPC용 64비트 exe를 따로 만들어야 하나.. 멀쩡한 함수가 무용지물이니 우회 경로를 뚫느라 굉장한 불편을 겪을 수밖에 없었다.

그랬는데 오늘 우연히 이 함수를 호출해 보니 Windows 10의 20xx이후의 업데이트 버전에서는 이 문제가 해결된 것 같다. 32비트 프로그램에서 다른 32비트나 64비트 프로그램의 전체 경로를 얻는 것.. 반대로 64비트 프로그램에서 다른 32비트나 64비트 프로그램의 전체 경로를 얻는 것 모두 아무 문제 없다.
Windows 10 구버전이나 Windows 7, 8 같은 거 64비트 에디션이 있으면 같은 프로그램을 구동해서 결과를 확인하고 비교할 수 있겠다만.. 대조군을 구하지 못해서 그것까지 실험은 못 해 봤다.

옛날에는 도대체 무슨 한계 때문에 이 함수가 제대로 동작하지 않았는지, 그리고 지금은 무엇이 해결되었는지 이 함수와 관련된 사연을 좀 알고 싶다.
이 함수는 원래 psapi.dll에 있던 시스템 정보 조회용 부가 액세서리에 가까운 물건이었으나..
앞서 언급한 바와 같이 Windows 7 즈음부터 시작된 API 재배치 정리 작업 과정에서 kernel32의 세부 카테고리로 본진이 이동한 듯하다. 사실, GetModuleFileName이 있던 곳과 같은 곳에 있는 게 논리적으로 훨씬 더 타당하기도 하다.

이런 커널 API 말고 GDI 쪽에서도.. 옛날에 AlphaBlend처럼 Windows 98에서 처음 추가된 그러데이션 그리기 함수들은 msimg32.dll이라는 별도의 DLL에 들어가 있다가 Windows XP인지 Vista인지 그때쯤부터 gdi32로 자리를 옮긴 적이 있었다.
새로 추가된 함수가 이런 식으로 재분류되는 게 완전히 새로운 관행은 아니었던 셈이다.

3. 파일 대화상자의 동작과 current directory

Windows에서 제공하는 파일 열기/저장 공용 대화상자는 사용자가 선택한 파일이 있는 곳으로 프로그램의 current directory도 같이 바꿔 버린다.
그래서 어떤 프로그램에서 USB 메모리 안에 있는 파일을 열기 대화상자로 골라서 열고 나면, 그 파일을 닫은 뒤에도 계속해서 USB 메모리가 사용 중이라면서 안전하게 제거가 되지 않는 불상사가 벌어진다. 파일을 열었던 프로그램을 통째로 종료하거나, 열기 대화상자를 꺼내서 다른 드라이브에 있는 파일을 열면 문제를 해결할 수 있다.

사실, 아주 극단적으로 특이하게 동작하는 물건이 아닌 이상, GUI 프로그램은 자기가 작업하는 파일의 절대 경로를 갖고 있다. 상대 경로를 통해 다른 파일을 참조한다 하더라도 기준이 되는 절대 경로가 따로 있지, 프로그램의 current directory 정보에 의존할 일은 없다. 게다가 current directory는 스레드가 아니라 프로세스마다 하나씩만 보관되는 정보이기 때문에 thread-safe 하지도 않다.

그러니 파일 대화상자가 굳이 저렇게 동작할 필요가 전혀 없어 보이는데도 current directory를 변경하는 이유는.. (1) 레거시 프로그램과의 호환도 있고.. (2) 그리고 다음에 파일 대화상자를 또 열 때 사용자가 마지막으로 선택했던 파일이 있는 곳을 current directory라는 수단을 통해 기억하고 공유하기 위해서이지 싶다.

도스 같은 명령 프롬프트 환경에서는 사용자의 타이핑 수고를 덜기 위해서 current directory라는 개념이 반드시 필요했으며, 그때는 아예 각 드라이브별로 current directory를 다 기억하고 있었을 정도였다. 지금 Windows 환경은 그 정도까지는 아니다.
그리고 어떤 프로그램은 불러들이는 문서 파일이 있는 디렉터리와 current directory가 동일하다는 게 보장돼야만 제대로 동작하는가 보다.

하지만 파일 대화상자도 OFN_NOCHANGEDIR라는 플래그가 있어서 사용자가 어느 파일을 선택하건 current directory를 건드리지 않게 하는 옵션 자체는 있다.
그리고 내부 동작도 바뀌어서 굳이 current directory에 의존하지 않고 자체적으로 사용자가 마지막으로 파일을 선택했던 위치를 기억해서 보여준다.

그러니 오늘날 새로 개발되는 프로그램들은 파일 대화상자를 꺼낼 때 가능한 한 OFN_NOCHANGEDIR를 사용하는 게 좋을 것 같다.
또한, 이런 조치와는 별개로 current directory 때문에 USB 메모리가 안전하게 제거되지 않는 문제를 운영체제 차원에서도 좀 최소화해야 하지 않나 싶다.

이건 모니터를 2~3대 연결해서 컴퓨터를 잘 쓰다가 일부 모니터의 선을 뽑아 버린 것과 비슷한 상황이다. 이 경우, 운영체제에서는 없어진 모니터의 영역에 있던 프로그램 창들을 재주껏 다른 모니터로 잘 옮겨 줘야 한다. 그런 것처럼 USB 메모리가 뽑혔다면, 거기를 current directory로 참조하던 프로그램은 다른 디렉터리를 참조하도록 상태가 적절히 바뀌어야 할 것이다.

4. 그래픽 뷰어

끝으로, 이건 프로그래밍과 큰 관계 없이 특정 앱에만 해당되는 사항인데..
요즘 Windows 10에 기본 내장돼 있는 그래픽 뷰어 말이다. 오랫동안 사용해 본 내 경험에 따르면, 얘는 좀 불안정한 것 같다. 창을 여러 개 띄워 놓다 보면(5개 이상 여러 파일)..

  • 종종 뻗으면서 지금까지 띄웠던 창들이 한꺼번에 싹 없어진다.
  • 혼자 CPU를 잔뜩 소모하면서 노트북 PC를 열받게 하기도  한다.
  • 사진 파일을 더블 클릭했는데 프로그램이 실행만 되고 창이 뜨지 않고 먹통이 되기도 한다. "파일 시스템 오류 (-805305975)" 이러면서 아예 실행이 안 된다.

2004/2009대 버전으로 개인용 컴과 회사 컴에서 모두 동일한 현상이 발생하니, 이건 내 환경만이 문제가 아닌 것으로 보인다.
한 가지 확실한 건 얘는 화면 표시에 그래픽 카드의 하드웨어 가속 기능을 적극 활용한다는 것이다. 뻗는 것도 여느 프로그램들 같은 메모리 버그 때문에 뻗는 게 아니며, 그래픽 카드 드라이버와의 충돌 내지 그쪽의 오류 때문에 뻗는다.

내 기억이 맞다면 Windows XP~7 사이의 기본 그래픽 뷰어는 256색 GIF에 대해서는 트루컬러 JPG와 달리 부드러운 확대/축소를 지원하지 않는다거나, GIF/APNG 애니메이션을 지원하지 않는다거나 하는 한계가 있었다.

지금 뷰어는 그런 한계가 다 없어지고 한 프로그램에서 사진과 동영상을 모두 취급할 수 있어서 기능 면에서는 제일 좋아졌다. 하지만 반대로 구버전에 비해서 안정성은 명백하게 떨어져 있는 게 아쉽다. 특히 앱이 실행되지 않기 시작하면 운영체제를 재시작/재로그인 하는 것 말고는 다른 해결책이 없다.

5. CPU 점유 문제

요즘 누구든지 컴퓨터나 폰을 다루면서 겪는 굉장히 성가신 문제 중 하나는.. 어떤 불필요한 프로세스/서비스가 나도 모르는 사이에 CPU 자원을 독식해서 기기가 갑자기 혼자 열받고 팬 돌아가고 배터리가 눈에 띄게 빨리 닳기 시작하는 것이다. 그러고 보니 스마트폰은 팬이 없구나~!

개인적으로는 이럴 때 CPU 도둑을 감지해서 “이놈이 지금 폭주 중인데 죽일까요?” 안내를 해 주는 유틸/툴이 있어야 한다고 생각한다.
도스 시절로 치면 memmaker, 윈도 XP 시절에 잠깐 있었던 바탕화면 정리 마법사, 어지간한 악성코드 진단 유틸 같은 명목으로 말이다.

물론 어지간한 컴잘알이라면 이럴 때 작업 관리자를 띄워서 CPU 사용량이 높은 놈을 강제 종료시킬 것이다. 하지만 범인이 평범한 프로그램이 아니라 서비스 같은 부류라면 뭐가 문제인지를 진단하기 어렵다.

본인의 과거 경험을 떠올려 보면 Windows Update와 관련된 서비스가 폭주한 적도 있었고, 크롬 브라우저가 쓸데없이 폭주한 적도 있었고.. 요 근래에는 WMI provider host인지 뭔지 하는 놈도 폭주해서 강제 종료시킨 적이 있었다.
자고로 업데이트는 CPU를 최하 우선순위로 받으면서 민폐를 절대 끼치지 않고 몰래 몰래 돌아가야 할 것이다. 사용자가 명시적으로 시키지 않은 작업이 저 따위로 돌아가는 건 어떤 경우든 디자인 결함이고 버그이지 않을까?

6. 프로그램 창의 떨림 현상

끝으로, 이건 프로그램이 뻗는 급의 치명적인 문제나 불편한 현상은 아니지만..
요즘 컴퓨터를 쓰면서 프로그램 창을 전환하다 보면, 아주 가끔씩 프로그램들이 non-client 영역(제목 표시줄, 메뉴 따위)이 수십 번 다시 그려지는지.. 수 초 동안 부르르 깜빡거리고 떨리는 경우가 있다.

이 역시 2004/2009급 Windows 10이 깔린 개인용과 회사용 컴퓨터에서 모두 목격하는 현상이다. 201x년대에는 딱히 본 적이 없었던 새로운 현상이다.
정확하게 어떤 조건 하에서 왜 발생하는지는 모르겠다.
내가 모르는 사이에 업데이트가 깔리면서 운영체제의 아랫단은 알게 모르게 많이 바뀌는데, 버그나 부작용도 슬그머니 들어갔다가 또 잠수함 패치되기도 하는 것 같다.

Posted by 사무엘

2022/01/23 08:34 2022/01/23 08:34
, ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1978

Windows API에서 LoadLibrary는 말 그대로 실행 파일(exe/dll)을 현재 프로세스의 주소 공간에다 불러들여서 거기 있는 코드를 실행하거나 리소스를 추출하게 해 주는 함수이다.
그리고 얘의 심화 버전은 LoadLibraryEx이다. Ex 버전은 옵션을 추가로 받아서 절대 경로 없이 파일명만 주어졌을 때 디렉터리를 탐색하는 순서를 지정할 수 있고, 파일이 이미 load되어 있을 때 레퍼런스 카운트 변경 여부 같은 것도 수동 지정할 수 있다.

하지만 그런 옵션들은 현업에서 잘 쓰이지 않는다. 저 함수에서 실질적으로 자주 쓰이는 옵션은.. DLL에서 리소스를 추출할 준비만 하고, 코드를 실행할 준비--기준 주소 재배치, DllMain 함수 실행--는 생략해서 로딩 속도를 좀 더 향상시키는 LOAD_LIBRARY_AS_DATAFILE이다. 특히 x86, x64, ARM 같은 아키텍처를 불문하고 동일 DLL에 있는 리소스 데이터를 추출하려면 이 '간소화' 플래그를 반드시 지정해야 한다(다국어 UI 리소스 같은..).

그런데 문제는.. 이 DATAFILE 간소화 로딩이란 게, 과거에는 "리소스 추출에만 특화"이라는 자기 본연의 기능에도 모종의 이유로 인해 뭔가 2% 부족한 구석이 있었다는 것이다.

Windows 9x 시절에는 이 제약이 제일 심했다. 간소화 로딩된 DLL 핸들에 대해서는 (1) 리소스를 제일 저수준에서 탐색하는 EnumResourceLanguages/Names/Times 및 Enum/Find/LoadResource 계열 함수만 사용할 수 있었다. 이들보다 상위 계층에서 동작하는 Load*계열 함수들은(string, menu, bitmap, image 따위) 지원되지 않았다. 그러니 간소화 로딩의 활용성이 부족했으며, 여전히 기존 full(?) 방식 로딩을 해야 하는 경우도 있었다.

허나, 한편으로는 저 제약이 그렇게까지 본질적이고 치명적인 문제가 아니었다.
Windows 프로그램에서 리소스 전용 DLL을 사용하는 주 목적은 다국어 UI 제공.. 아니면 대화상자· 메뉴 같은 표준 리소스가 아니라 자기 자신만 사용하는 custom 데이터의 저장이기 때문이다.

그리고 표준 리소스들도 특정 언어에 속하는 놈을 지정하려면 "DLL 핸들 + 리소스 ID"만으로는 어차피 충분치 않다. FindResourceEx와 LoadResource의 결과값인 메모리 포인터를 줘야 하며, 함수도 LoadMenuIndirect, DialogBoxIndirect처럼 뒤에 indirect라는 단어가 붙은 '저수준 버전'을 써야 한다.

그렇기 때문에 리소스 추출용 간소화 방식으로 load한 DLL은 저수준 함수로만 다룰 수 있더라도 그럭저럭 사용할 만했다. 그런데 여기에는 다른 이상한, 자잘한 문제도 있었다.

DialogBoxIndirect 함수는 대화상자 리소스를 "모듈(인스턴스) 핸들 + 리소스 ID"가 아니라 대화상자 템플릿 포인터 하나로만 곧장 지정함에도 불구하고, 모듈 핸들을 여전히 인자로 받는다. 내부적으로 CreateWindowEx 함수를 호출할 때 모듈 핸들이 필요하기 때문이다(대화상자 자신, 그리고 내부의 child 컨트롤들 생성).

그런데 (2) 이때 리소스 추출 간소화 방식으로 load한 DLL의 핸들을 주면.. 구형 운영체제에서는 여러 문제들이 발생했다.
일단, 자기 자신이 내부적으로 사용하는 커스텀 컨트롤--표준 컨트롤이 아니고, CS_GLOBALCLASS 등록된 커스텀 컨트롤도 아닌 놈--이 만들어지지 않는다. 이건 CreateWindowEx 함수의 특성상 자연스러운 귀결이지만, 그 이상으로..

내 기억이 맞다면 대화상자의 배경색이 일반적인 회색이 아니라 흰색으로 바뀌고 좀 만지다 보면 프로그램이 뻗었다. Windows 9x뿐만 아니라 나름 NT 계열인 2000에서도 말이다.
그 이유는 딱히 알 수 없었다. 그저 경험적으로 이런 DLL 핸들을 집어넣어서는 안 된다고 날개셋 한글 입력기 소스의 주석에도 엄청 옛날에 적혀 있었다.

물론 이 역시 본질적이고 치명적인 문제는 아니다.
윈도우의 생성과 관련해서 전달하는 인스턴스/모듈 핸들은 그 윈도우의 클래스를 등록한 주체를 식별하는 용도이다. 애초부터 리소스가 전혀 아니라 코드와 관계가 있다. 그러니 여기는 애초에 리소스 추출 간소화 방식으로 load된 DLL이 들어갈 자리가 아니다. 그런 DLL을 집어넣은 것은 사실상 프로그래머의 실수에 지나지 않는다.

하지만 이쯤 되니 의문이 생긴다. 프로그래머가 아무리 실수할 수 있기로서니, 그걸 넘겨주면 단순히 custom 컨트롤이 생성되지 않는 것 이상으로 왜 다른 이상한 부작용까지 발생한 것일까? 차라리 깔끔하게 에러와 실패 처리를 하는 것도 아니고 말이다.
DLL을 일반적인 방식으로 load하는 것과 datafile(리소스 특화 간소화) 방식으로 load하는 것은 내부적으로 무슨 차이가 있는 걸까?

오늘날의 32비트 및 64비트 Windows 환경에서는 DLL을 로딩한 결과 핸들(HMODULE / HINSTANCE)은 그 파일 내용을 가리키는 데이터 포인터와 거의 동급이라고 여겨진다. 파일을 memory-mapped file 형태로 통째로, 혹은 약간의 보정만 거쳐서 읽어들인 첫 지점이다. 쉽게 말해 그 핸들이 가리키는 메모리에는 EXE 파일 시그니처인 MZ부터 쭉 나온다는 것이다.

그리고 실행 파일은 메모리 주소가 언제나 64KB의 배수 단위로만 배당된다는 것도 이 바닥에서 프로그래밍 좀 한 사람들은 아실 것이다. 그 말인즉슨, 일반적으로 HMODULE 내지 HINSTANCE의 값은 64KB의 배수이며, 하위 word가 언제나 0이 된다는 뜻이다.
하지만 특수한 상황에서는 이런 조건을 만족하지 않는 핸들도 있을 수 있다.

(1) 먼저, 과거의 Windows 9x 환경에서는 16비트 프로그램에서 호출한 LoadLibrary의 리턴값이 대표적인 예이다. 얘들은 핸들의 크기 자체가 16비트밖에 안 되니 리턴값과 내부 의미 역시 32비트 프로그램과는 완전히 다른 형태여야 한다.
물론 이미 32비트 형태로 빌드된 프로그램이야 이런 거 신경 쓸 필요가 전혀 없으며, 16비트와 32비트 프로그램을 모두 한데 관리하는 운영체제의 관점에서나 구분이 필요하다.

(2) 그리고 LoadLibraryEx + datafile 방식으로 불러들인 dll 핸들도 형태가 약간 달라진다. 운영체제의 버전에 따라 차이가 있지만 일단 해당 DLL의 preferred base는 완전히 무시되며, 굳이 64KB라는 큼직한 단위로 주소가 배당되지 않는다.
결정적으로는 최하위 비트에 1이 추가돼서(= 홀수!!) 얘는 datafile 방식으로 생성되었다는 것을 나타낸다. 메모리 주소로서의 DLL 핸들은 하위 16비트에 어차피 유의미한 정보가 담겨 있지 않으니.. 그 잉여 공간에다 이런 정보를 보관한다는 뜻이다.

요컨대 HMODULE / HINSTANCE는 16비트 프로그램 또는 datafile 방식에 한해서는 64KB의 배수 단위인 깔끔한 포인터가 아니게 된다. 그런데 과거에는 운영체제 내부에서 이런 변칙적인 핸들을 취급하는 방식이 서로 충돌했던가 보다.

kernel32는 이 DLL이 datafile 방식으로 load되었다는 것을 식별하기 위해서 핸들값에다가 1을 추가했다. 하지만 user32의 대화상자 표시 함수는 datafile 방식에 대해서는 전혀 관심이 없으며, 이 핸들값이 하위 16비트가 비영인 것을 보고는 이건 16비트 모듈이라고 인식해 버렸다. 그리고 16비트 프로그램과의 하위 호환을 위한 보정 처리를 수행했다.

그 보정 처리 중에는 대화상자 내부의 각 에디트 컨트롤들에 대해 고유한 데이터 세그먼트를 생성하는 것도 있었다.
아시다시피 에디트 컨트롤, 특히 multiline으로 동작하는 놈은 혼자서 수백, 수만 바이트에 달하는 텍스트를 저장할 수 있다. 모든 컨트롤들이 한 64KB 데이터 세그먼트를 공유할 게 아니라 각각이 고유한 세그먼트를 갖는 게 낫다. 이것을 대화상자 표시 함수가 내부적으로 해 줬다.

(그럼 이건 특별히 메모리가 많이 필요한 에디트 컨트롤에 대해서 고유한 스타일을 줘서 그 컨트롤이 알아서 처리하면 되지, 이런 걸 왜, 어떻게 상위 윈도우인 대화상자에서 처리하는지는 잘 모르겠다. 그리고 그런 식이면 에디트 컨트롤뿐만 아니라 리스트나 콤보박스도 수천 개의 아이템을 추가하느라 메모리가 많이 필요할 때가 있을 텐데 걔네들은 어떻게 처리되는지도.. 개인적으로는 잘 모르겠다. ㄲㄲ)

어쨌든.. 대화상자를 생성할 때 datafile DLL의 핸들이 지정되면 저런 복잡한 이유로 인해 16비트 보정이 수행되는데.. 실제로 대화상자를 돌리는 이 프로그램은 16비트 프로그램이 아니다. 그래서 보정 처리가 제대로 되지 않고 프로그램이 죽는 등 갖가지 오동작과 이상 현상이 발생한다고 한다. 그래서 그랬던 것이군~!! (☞ 더 자세한 설명)

대화상자에도 스타일이 있다. 하지만 이건 윈도우 스타일의 형태로 지정해 주는 게 아니고 DialogBox 계열 함수에다가 인자로 전하는 것도 아니며, 그냥 대화상자 리소스 템플릿에 박혀 들어가는 값일 뿐이다. 그러니 다른 스타일 플래그들에 비해 인지도가 매우 낮으며 프로그램 코드에서 볼 일이 없다시피하다.

이 대화상자가 다른 대화상자의 child로 들어갈 수 있음을 나타내는 DS_CONTROL, 용도가 좀 모호하긴 하지만 [?] 모양의 도움말 버튼을 우측 상단에다 표시하는 DS_CONTEXTHELP 같은 건.. 오늘날까지도 유효하다. 하지만 16비트 시절의 잔재이고 오늘날은 아무 의미 없는 플래그도 있다.

대표적으로 DS_3DLOOK은.. Windows 95/NT4부터는 대화상자들이 처음부터 기본적으로 버튼과 동일한 은색/회색이고 각종 테두리도 양각 음각 입체(?) 효과가 적용되어 나오므로 존재의 의미가 없어졌다.
그리고 DS_LOCALEDIT라는 놈이 있는데.. 얘는 자기 내부의 모든 에디트 컨트롤들이 고유한 데이터 세그먼트가 아니라 기본 제공되는 단일 64K 세그먼트를 공유하게 해서 메모리를 아끼는 플래그이다. 에디트 컨트롤에 많아야 수십~수백 자밖에 들어가지 않는다는 게 보장되면 사용해 볼 만한 옵션이었다. 32비트 이후부터는 아무런 의미가 없어졌지만..

그리고 이렇게 DS_LOCALEDIT 옵션이 적용된 대화상자는 아까처럼 Windows 9x에서 datafile DLL 핸들을 지정해 주더라도 16비트 보정 처리가 행해지지 않기 때문에 오동작· 오류도 발생하지 않았다고 한다.
물론 이 문제는 Windows NT 계열을 넘어 16비트 프로그램 자체가 존재하지 않는 64비트 운영체제의 관점에서는 더욱 무의미한 지나간 옛날 추억이 되었을 뿐이다.

16비트에서 32비트로 넘어갈 때는 16비트 환경에서도 far이니 huge니 하면서 어떻게든 16비트 코드에서 64KB를 초과하는 메모리 영역을 다루려고 애썼으며, 반대로 32비트 주소 공간에서 16비트 코드를 수용하고 실행하려고 온갖 발악을 했었다. 하지만 32비트와 64비트는 서로 완벽하게 격리된 채 공존할 뿐, 상대방 영역을 전혀 건드리지 않는다는 차이가 있다.

이상이다.
여담이지만 날개셋 한글 입력기의 소스를 뒤져 보니.. 어떤 DLL을 datafile 방식으로 읽어들인 상태에서는 그 DLL에 대해서 VerQueryValue 같은 버전 정보 확인 API도 제대로 동작하지 않았다는 주석이 적혀 있다. 그래서 버전 리소스를 수동으로 직접 파싱하는 방식으로 기능을 구현했다.
Windows Vista 이상 또는 심지어 9x 계열에서도 괜찮았으며 2000/XP에서만 문제가 발생했다고 하는데.. 이 역시 LoadLibraryEx 함수의 부작용이 아니었나 추측해 본다. 과거에 일반 로딩과 datafile 특화 로딩은 내부 동작이 여러 모로 차이가 컸던 모양이다.

Posted by 사무엘

2021/10/15 08:34 2021/10/15 08:34
, ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1943

Windows에서 리스트뷰 컨트롤은 아이템 기반의 정보를 나열하는 용도로 굉장히 편리하고 유용한 물건이다. 본인은 수 년 전에 얘에 대해서 전문적으로 리뷰를 한 적도 있다. (☞ 이전 글 보기)
하지만 그럼에도 불구하고 얘에 대해서 본인은 Windows 프로그래머로서 다음과 같은 점을 아쉽거나 의아하게 생각한다.

(1) 먼저, ‘작은 아이콘’ 모드라는 게 정체성이 너무 어정쩡 모호한 건 둘째치고라도, 아이템 배치와 관련된 제어가 제대로 안 된다는 것이다.
얘는 작은 아이콘의 옆에 텍스트가 한 줄 붙는다는 점에서는 목록 모드와 매우 비슷하다. 하지만 얘는 그래도 횡대와 종대 아무 방향으로나 align이 되고, group도 적용된다는 것이 목록 모드와의 차이점이다. 즉, 나름의 쓸모도 있다는 것이다.

그런데.. 얘는 아이템의 간격 내지 폭을 어떻게 조절해야 할지 모르겠다.
LVM_SETICONSPACING은 큰 아이콘 모드의 간격이고 LVM_SETCOLUMNWIDTH는 목록 모드의 폭이고, LVM_SETTILEVIEWINFO는 타일 모드에서의 크기이다.
그런데 작은 아이콘 모드는 내가 아는 한 아무리 눈을 씻고 찾아 봐도 이런 API가 존재하지 않는다.

사용자 삽입 이미지

아이템이 이렇게 제멋대로 막장으로 출력되는 걸 막을 길이 없더라.
내가 날개셋 제어판의 외부 모듈 목록에다가 ‘작은 아이콘’ 모드도 추가해 볼까 하다가 이걸 보고는 단념했다.

(2) 그리고 악명 높은 화면 잔상 버그 말이다.
check list를 건드렸을 때 선택 막대가 진해지는 문제는 이미 이전 글에서도 지적했던 바 있다.
스타일을 바꿔서 우회하는 방법도 있긴 하지만 완전한 해결책이 아니며, 원래는 그렇게 우회하지 않더라도 그런 문제가 발생하지 않아야 한다. Windows 10 이전에는 문제가 없었기 때문이다.

사용자 삽입 이미지
(자세히 모드에서 칼럼의 폭을 조절해도 이렇게 해당 칼럼이 덧칠되는 문제가..)

게다가 ‘작은 아이콘’ 모드일 때는 아이템에다가 마우스를 가져가기만 해도 글자가 사라져 버리는 치명적인 문제가 존재한다.
이건 뭔가 내부 계산 로직이 대놓고 잘못된 거나 마찬가지이다. 마소에서도 자체적으로 작은 아이콘 모드를 쓰지를 않기 때문에 버그를 인지하지 못하는 게 아닌가 하는 생각이 들 지경이다.

사용자 삽입 이미지

Windows는 내 컴퓨터 내지 탐색기 UI에서 전통적으로 리스트뷰 컨트롤을 사용해 왔다.
초창기 Windows 95/98은 이 컨트롤이 제공하는 ‘큰 아이콘, 작은 아이콘, 목록, 자세히’라는 네 가지 보기 모드를 그대로 제공했었다.
그러다가 2000/ME에서는 그림이나 문서의 내용 thumbnail이 표시되는 미리 보기 모드가 추가되어 5개가 되었다. 이건 기술적으로는 물론 ‘큰 아이콘’의 확장판이었다.

XP에서는 정체성이 어정쩡한 작은 아이콘 모드가 삭제되고, 제5의 모드인 ‘타일 모드’가 ‘큰 아이콘’이라는 이름으로 등장했다. 기존의 ‘큰 아이콘’은 그냥 ‘아이콘’으로 바뀌었고.. 그러니 전체 개수는 5개로 변함없었다. 원래 있던 작은 아이콘 모드는 마소에서도 완전히 버린 자식 취급하고 있는 게 틀림없다.

사용자 삽입 이미지

그 다음 Vista/7 이후부터는 아이콘의 크기를 매우 다양하게 조절할 수 있게 되었다. 게다가 개념적으로 예전의 ‘작은 아이콘’에 해당하는 모드도 부활했다.
하지만 이건 리스트뷰 컨트롤이 제공하는 ‘작은 아이콘’으로 구현한 게 아니다. 내부적으로는 그냥 ‘타일 모드’에다가 아이콘만 작은 걸 준 게 아닐까..?? 작은 아이콘에서는 이렇게 길다란 아이템을 뒷부분을 생략해서 표시하는 게 가능하지 않다. 타일에서만 가능하다.

사용자 삽입 이미지

그리고 굉장한 뒷북인 놀라운 사실이 있다.
무려 Windows 7부터는 운영체제의 탐색기와 파일 공용 대화상자에서 애초에 리스트뷰 공용 컨트롤을 사용하지 않고 있다. 일반인에게 스펙이 공개되지 않은 별개의 자체 구현 컨트롤을 쓴다..!!

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

아.. 어쩐지~~
(1) 원래 리스트뷰 컨트롤의 '목록' 모드는 이렇게 카테고리라고 해야 하나 그룹 분류 기능을 지원하지 않는다. 목록 모드라는 것은 스크롤바가 세로가 아니라 가로로 난 것을 통해 알 수 있음..
탐색기의 저런 모양은 공용 컨트롤로는 구현 불가능하다. 또한, 저렇게 칼럼마다 폭이 유동적으로 다른 목록도 공용 컨트롤은 지원하지 않는다.

사용자 삽입 이미지

(2) 아울러, '내용'이라는 이 특이한 보기 모드도 기존 리스트뷰 컨트롤로는 구현 불가능하다.
이런 걸 어떻게 구현했을지, 온통 owner draw 개조로 도배했을지 궁금했는데.. 답은 간단했구나. 그냥 자체 컨트롤을 만든 것이었다.;; 영문 위키백과에서도 Windows 7에서부터 바뀌거나 사라진 기능 중 하나로 다음과 같이 언급되어 있다.

An undocumented incompatible Item view control replaces the List view control used in Windows Explorer... The Item view also does not support custom positioning, custom ordering, or hyperlinks, which the Windows Vista list view did support.


아.. 옛날엔 Office 팀에서 파일 열기/저장 대화상자들 자체 제작해서 썼더니만, 그건 없어졌고 이젠 운영체제 셸 팀에서 뷰 컨트롤을 자체 제작해서 쓰기 시작했군...

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

Windows Vista와 7의 탐색기는 외형상 굉장히 비슷해 보인다.
그런데 "Hard Disk Drives / Devices with Removable Storage"라고 아이템을 분류해 놓은 카테고리 부분을 보자.
아이템들을 몽땅 숨기거나 펼칠 수 있는 삼각형 마커가 Vista는 오른쪽 끝에 있는 반면, 7과 그 이후(현재의 10까지 포함)부터는 왼쪽 끝에 있다. 왼쪽 끝에 있으니 무슨 트리 컨트롤을 다루는 것 같은 느낌이 든다.

(3) 마커가 왼쪽에 있을 뿐만 아니라 일반 아이템보다 글자 크기도 더 큰 건.. 내가 알기로 공용 컨트롤 리스트의 기본 옵션만 바꿔서 구현할 수 있지 않다. 비슷해 보여도 Vista는 리스트뷰 컨트롤을 썼지만 7부터는 그렇지 않다는 것이다.

트리와 리스트뷰 컨트롤을 적당히 배합해서 '탐색기' 짝퉁을 만드는 게 Windows UI/셸 프로그램의 정석 코스로 통용되곤 했는데.. 이젠 100% 동일하게 동작하는 탐색기를 이런 식으로 만드는 건 근본적으로 불가능해졌다.

단, 바탕 화면은 그냥 아이콘들만 늘어놓으면 되니 지금도 여전히 기존 리스트뷰 컨트롤 기반이다.
결론을 내리자면.. 마소에서는 자기들도 리스트뷰 컨트롤의 한계를 인지했는지 탐색기에서 얘를 더는 사용하고 있지 않다. 작은 아이콘 모드의 활용성을 좀 강화하고, 목록 모드에서도 그룹 구분을 지원하고, 화면 잔상 버그들을 좀 고쳤으면 좋겠다.

Posted by 사무엘

2021/10/06 08:36 2021/10/06 08:36
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1940

남의 코드를 읽으면서 신기하게 느꼈던 점들을 다음과 같이 정리했다. 요즘 C++은 변해도 너무 많이 급격하게 변하고 있는 것 같다. 뭔가 다른 언어에 있던 기능이 비슷한 형태로 그대로 도입되는 편이다.

1. final과 override

본인은 C++에 클래스에 뭔가 제약을 가하는 기능이 부족한 편이라고 볼멘소리를 늘어놓아 왔다. 어떤 클래스가 더 상속이 되지 않게 하기, 이 함수가 더 오버라이딩이 되지 않게 하기, 대입이나 복제가 되지 않게 하기 등...
하긴, 이런 불평은 나만 하는 게 아니며, 오히려 나보다 더 깐깐한 불편러 PL 순수주의 성향인 사람도 많다.

함수 차원에서 제약을 가하는 것은 요즘 C++에서는 = delete 문법이 생겨서 불편이 많이 해소됐다.
그런데 이것 말고도 C++에 final과 override라는 조건부 키워드도 드디어 추가되었다니 참 놀랍다.

class Parent final { }

요렇게 해 주면 Parent를 기반으로 삼는 파생 클래스를 만들 수 없다. class Child: public Parent {} 이런 걸 시도하면 에러가 난다.
한편,

class Parent {
public:
    virtual void foo() {}
};
class Child: public Parent {
public:
    void foo() override {}
};

여기서 override는 Child의 foo가 기반 클래스의 foo를 재정의한다는 것을 나타낸다. 이 표기는 당연히 전적으로 optional이기 때문에 하든 안 하든 컴파일러의 코드 생성과 프로그램의 실행에는 아무 영향을 주지 않는다.

단지, 기반 클래스에 저런 함수가 없는데도 파생 클래스의 함수에 override가 선언돼 있다면 컴파일러는 에러를 찍어 준다. 그러므로 저걸 집어넣으면 내가 함수의 스펠링이나 매개변수를 실수로 잘못 넣었는지 여부를 곧장 알 수 있다.
그리고..

void foo() final;

final을 집어넣으면 짐작하다시피 이 함수는 파생 클래스에서 오버라이딩을 할 수 없게 된다. override와 final을 동시에 지정하는 것도 가능하다.

멤버 함수의 선언 뒤에다가 뭔가 속성을 지정한다는 점에서 const와 비슷해 보인다. 허나, 다시 말하지만 얘들은 전적으로 컴파일 때의 편의를 제공하는 hint일 뿐이다. 코드의 생성 방식이나 심지어 명칭의 decoration에도 전혀 영향을 주지 않는다.
const는 이거 지정 여부로 함수 오버로딩을 가능하게 하는 변별 요인이지만 override와 final은 그렇지 않다는 것이다. 그렇기 때문에 클래스 안에 함수의 선언부에다가만 지정하고 함수의 몸체 정의에다가는 생략도 가능하다. const는 그렇지 않다.

2. [[??]] 속성 지정자

함수의 선언에서 리턴 타입보다도 먼저 맨앞에 붙어 있는 [[nodiscard]] 이런 문구가 정체가 무엇인지 궁금해서 찾아 봤는데..
이 함수의 리턴값을 무시하지 않게 하는 자잘한 속성 지정자였다. 이 함수는 호출만 하고 리턴값을 무시하는 경우, 호출하는 쪽의 코드에다가 경고를 날리게 된다.

&를 두 개 써서 R-value 참조자라는 걸 추가했듯이, 여는 대괄호도 2개를 써서 저런 새로운 문법을 만든 것이다.
nodiscard 말고도 컴파일러의 최적화 전략에 단서를 제공하는 속성이 몇 가지 더 존재하며, C++ 언어의 버전이 올라가면서 아이템들이 추가되곤 했다.

함수의 선언에는 함수의 이름, 리턴 타입, 그리고 인자들 목록과 타입만 있는 줄 알았는데 그 사이에 calling convention 지정부터 시작해서 갖가지 추가적인 정보와 단서들을 지정하는 문법이 야금야금 도입돼 왔다.

extern "C"도 있고, 그리고 Visual C++이 전통적으로 사용한 방법은 __declspec(????)이다.
특히 deprecated는 [[]]와 __declspec()에 모두 존재해서 이제 기능이 완벽하게 겹치는 것 같다. 자기들이 필요하니까 마소에서 먼저 deprecated API를 지정하는 속성을 비표준 방식으로 추가했는데 그게 이제야 표준에도 도입된 셈이다.

그런데 C/C++은 태생적으로 함수를 선언할 때 function이나 그에 준하는 별도의 키워드를 두지 않았고, "리턴값 함수명(인자)"라는 문법 형태만으로 함수를 선언 및 정의할 수 있게 해 놓았다. 그러다 보니 그 사이에다 추가적인 정보를 집어넣는 문법이 좀 지저분해진 것은 피할 수 없어 보인다.
관점에 따라서는 아까 저 final, override 같은 힌트 속성도 [[]] 형태로 일관되게 넣을 수도 있어 보이지 않는가?

이런 부가 정보들을.. 단순히 경고만으로 끝나는 것, 컴파일 가능 여부에 영향을 주는 것(final), 코드의 생성 방식에 영향을 주는 것(호출 규약), 최적화 방식에 영향을 주는 것, C++ name mangling에 포함되는 것(const) 등으로 한데 분류해 보는 것도 의미가 있을 것 같다.

3. Variadic macro

요즘 C/C++에서는 #define 매크로 함수도 마지막 인자에다가 ...를 줘서 가변 인자 형태로 만들 수 있다.

이게 없던 옛날에는 가변 인자를 받는 함수를 매크로 함수로 간단하게 치환할 수 없어서 그냥 이름만 치환하는 매크로 상수를 써야 했다. 그리고 매크로 상수로는 가변 인자의 앞에다가 추가적인 인자를 삽입해서 다른 함수를 호출하는 식의 응용을 할 수 없었다. 하지만 이제는 그게 가능해졌다.

물론 가변 인자라는 건 근본적으로 C++의 이념과 그닥 어울리지 않는 물건이다. C++이 자체 제공하는 함수 오버로딩이나 default argument와 충돌하기 쉬우며, type-safety와 객체지향(특히 임시 객체에 대한 생성자/소멸자) 처리가 보장되지 않는다. 그러니 가변 인자로 주고받는 건 반드시 정수나 포인터 같은 단순 POD로 한정돼야 한다.

C++과 어울리는 물건이 아니다 보니 얘는 auto니 템플릿이니 하는 쪽에 관심이 온통 쏠려 있는 modern C++의 산물이 아니다. C99에서 맨 처음 도입됐던 것을 C++11이 나중에 같이 받아들인 것이다.
애초에 #define 전처리기도 C++보다는 꽤 C스러운 물건이다. 거기에 또 다른 C의 냄새가 풀풀 풍기는 ...이 잘 결합한 것 같다.

전처리기에는 ##이라는 연산자가 있어서 기존 명칭의 스펠링에다가 뭘 앞뒤로 붙여서 새로운 명칭을 만들게 해 준다.
그것처럼 가변 인자 매크로의 내부에는 가변 인자들 묶음을 한꺼번에 나타내는 __VA_ARGS__라는 특수한 매크로 상수가 정의되어서 사용 가능하다. 가변 인자 지원을 위해서 언어 문법이 확장이라면 확장된 셈이다.

사실, 이게 문법도 변형이 존재한다..

#define my_printf(a, ...)   printf(a, __VA_ARGS__) //A형
#define my_printf(a, args...)   printf(a, ##args ) //B형

지금은 A형이 표준인데 GNU C에서는 B형도 존재했는가 보다. Visual C++에서는 B형은 지원되지 않는다.

요즘 가변 인자가 활발하게 쓰이는 분야 중 하나는 printf 가변 인자 스타일로 문자열을 포매팅하는 디버그 로그 쪽일 것이다.
개발자가 잠깐 보고 마는 정보이니 문자열까지 쓸 것도 없이 간단히 char buff[256]으로 때워도 무관할 것이고 굳이 C++ string이나 stream을 쓸 필요가 없다. 더구나 이거야말로 디버그 빌드 여부냐에 따라 각종 조건부 컴파일과 전처리기 치환이 절실히 필요한 분야이니.. 가변 인자 매크로는 생각보다 개발 명분과 정당성이 풍부해 보인다.

추신:
글을 다 써 놓고 나중에 알고 보니 C++도 variadic macro와 비슷한 개념이 더 괴물 같은 형태로 템플릿에 이미 도입되었다.;; 이름하여 variadic template. template<typename... T> void foo(T.. args) {} 이러면 args가 __VA_ARGS__와 얼추 비슷한 argument pack 역할을 하게 된다.
이것 말고도 온갖 복잡한 용법이 많다. 이거 예시를 보이기 위해서 C++ 코드에다가 굳이 printf를 호출하려고 애쓰는 걸 보니 뭔가 느낌이 짠하다.

4. 현재의 함수 이름을 나타내는 매크로 상수

ANSI C에는 디버깅을 위해 __FILE__, __LINE__처럼 현재 컴파일 되는 파일 이름과 줄 번호로 유동적으로 치환되는 표준 매크로가 정의되어 있다. 이런 게 디버그 로그 내지 assert failure 매크로에서 즐겨 쓰인다.

그런데 현업에서는 이 뿐만 아니라 현재의 코드가 소속되어 있는 함수 이름 문자열을 나타내는 매크로도 쓰인다.
ANSI C 급의 원조 표준은 아니지만 #pragma once나 __super처럼 업계에서 오랫동안 사실상의 표준이나 마찬가지였던 물건이 있는데.. 바로 __func__이다.

얘는 C++11에서는 결국 정식 표준으로 등재되기도 했다. C++ 문법과 관계 있는 물건은 아니니 가변 인자 매크로처럼 C99에서 먼저 도입됐던 것이 추후 수용된 게 아닌가 싶다.
그리고 __FUNCTION__이라는 바리에이션도 __func__과 동일한 역할을 한다.

Posted by 사무엘

2021/08/12 08:33 2021/08/12 08:33
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1920

2021년이 되니 마소 진영으로부터 신선한 소프트웨어 소식이 전해지는 게 좀 있다.
1위는 단연 Windows 11이다.
Windows 10 이후로 주 버전명을 불변으로 고정할 거라더니, 그 정책을 6년 만에 번복하게 됐다. (Windows 10이 처음 나온 게 2015년) 업데이트로 찔끔찔끔 제품을 바꿔 나가는 것에 한계를 느낀 모양이다.

새 버전은 이제 32비트 전용 CPU의 지원을 끊고 64비트로만 나올 예정이다. 이건 뭐.. 서버 제품군에서는 이미 10년도 더 전, Vista인가 7인가 그때부터 32비트의 지원을 끊은 상태이기 때문에 전혀 새삼스러울 게 없는 결정이다. 또한 가정용 개인용 PC도 램 크기가 4GB를 넘어간 지는 이미 10년 이상 전의 일이기는 마찬가지다.

이야 그러면 버전의 명명 방식도 번호(1~3) → 연도(9x, 20xx) → 브랜드명(XP, Vista)이다가 이제 다시 번호로 회귀하는 건가 싶다(7~11). 역시 역사는 돌고 돈다. 7~8 시절에는 커널 버전과 저 번호가 일치하지 않았었는데, 10부터는 커널 버전도 대외적인 버전 번호와 일치하게 됐다.

그리고 운영체제뿐만 아니라 개발툴인 Visual Studio도 말이다. 2019 이후로 3년째 16.9.x까지 마이너 업데이트만 계속하고 있어서 이제 쟤도 메이저 업데이트를 중단했나 싶었는데.. 그렇지는 않다. 2022가 나올 예정이라고 한다.
게다가 2022는 devenv.exe IDE가 드디어 100% 64비트 기반으로 만들어진다. 이것만으로도 메이저 업데이트의 명분은 충분하다고 하겠다.

아니 그럼 지금까지는 64비트가 아니었나? 응, 의외이지만 아니었다. xcode라든가 Android Studio 같은 타 개발툴과는 상황이 다르다.
마소의 제품 중에서도 운영체제인 Windows는 XP/Vista 때 이미 x64 에디션이 나왔고 Office도 10년도 더 전의 2010부터 x64 에디션이 나왔던 반면.. 정작 개발툴 IDE는 기술적인 난관 때문인지 64비트 포팅이 굉장히 늦었다.

물론 컴파일러야 x64 타겟은 네이티브와 32-64 크로스 모두 당연히 진작부터 제공됐다. 하지만 Visual Studio IDE 자체는 여전히 32비트 바이너리였다. 그렇기 때문에 수만 개의 소스 파일들로 구성된 방대한 프로젝트를 열고 소스 코드의 인텔리센스 데이터를 관리하는 것엔 아무래도 한계가 있었다.

그래도 신기한 건 이 32비트 IDE로도 64비트 바이너리의 디버깅까지 32비트의 것과 아무 차이 없이 자연스럽게 할 수 있었다는 점이다. 원래 32비트 프로세스는 64비트 프로세스 주소 공간을 들여다보거나 훅킹 코드를 주입할 수 없다는 걸 생각하면 굉장히 신기한 일이다. Visual Studio IDE가 디버깅을 위한 64비트 호스트 프로그램을 별도로 구동하고, 얘가 32비트 IDE와 IPC(프로세스 간 통신)을 굉장히 정교하게 잘 했던 것으로 보인다.

이렇게 Visual Studio가 32비트 IDE로나마 64비트 개발과 디버깅을 정식으로 지원하기 시작한 건 무려 2005 버전부터였다.
그로부터 17년이나 뒤에야 IDE가 정식으로 64비트 기반으로 만들어지니.. 이때부터는 64비트 바이너리를 저런 별도의 디버깅 호스트 없이 IDE에서 직통으로 디버깅을 할 수 있을 것이다. (이젠 반대로 32비트 프로세스를 디버깅 할 때 디버깅 호스트를 따로 마련해야 할 듯) 취급 가능한 프로젝트의 규모가 64비트에 걸맞게 엄청 커지는 건 덤이고 말이다.

Visual C++에서 생성되는 Windows 프로젝트의 기본 configuration이 ANSI (1바이트 문자 집합) 대신 유니코드로 바뀐 첫 버전도 내 기억으로 2005이지 싶다. TCHAR이 char에서 wchar_t로 바뀌었듯, 프로그램들도 하나 둘 64비트로 포팅되면서 순수 32비트 프로그램은 갈수록 보기 어려워지는 게 느껴진다.

하긴, 과거 레거시의 압박이 훨씬 덜한 안드로이드나 iOS 같은 모바일 진영은 과거에 연연할 게 없으니 진작에 64비트로 다 갈아탔다.
요즘은 경전철이라고 해서 협궤를 쓰는 게 아니듯, 쬐끄만 스마트폰용 CPU조차도 다 64비트이다. 안드로이드와 iOS 모두, 32비트 앱의 지원은 PC보다도 더 일찍 진작에 다 끊었다.

Posted by 사무엘

2021/08/02 08:33 2021/08/02 08:33
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1916

1. 경고와 에러

C/C++ 컴파일러가 소스 코드를 컴파일 하는 중에 내뱉을 수 있는 메시지는 흔히 에러 또는 경고라는 두 종류로 나뉜다. 그런데 이걸 더 세분화하면 에러의 앞에는 수위가 더 높은 ‘심각한 에러’(fatal error)라는 게 있다.

얘는 컴파일 중에 컴파일러 자체가 뻗거나 메모리가 부족할 때처럼, 외부 요인에 의해 컴파일이 더 진행될 수 없을 때 나는 편이다. 그런 게 아니면 소스 코드가 문법상으로는 이상이 없지만 각종 수식이나 명칭 선언이 괄호가 너무 깊게 들어가고 복잡할 때, 리터럴 데이터 같은 게 너무 많아서 도저히 감당이 안 될 때, #include 깊이나 #define 치환 단계가 너무 깊을 때..

한 마디로 컴파일러의 한계 때문에 코드 생성이 안 되는 것이 심각한 에러로 분류되는 편이다. 이건 통상적인 컴파일 에러와는 성격이 다르기 때문이다. 참, #include 파일을 아예 찾을 수 없는 것, 그리고 #error로 대놓고 에러를 발생시킨 것 역시 추가적으로 심각한 에러의 범주에 든다.

일례로, int a = 999999999999999999999999; 이런 거야 상수가 너무 커서(32비트 범위 초과) 토큰의 스캐닝 단계에서 튕겼기 때문에 일반 컴파일 에러이다.
하지만 int tbl[] = { 10,45,34,33, ... }; 다음에 숫자가 한 100만 개쯤 있다거나,
char msg[] = "......" \ 이런 리터럴이 100MB쯤 이어져서 컴파일이 실패하는 것은 심각한 에러가 되는 셈이다.

그리고 괄호들이 닫히지 않은 채로 구문을 종료하는 세미콜론이 나오면 일반 에러이지만.. 그 상태로 파일 내용이 끝나 버리면 보통 심각한 에러로 간주된다.

에러 말고 경고는.. 컴파일러들이 경고를 이미 여러 단계로 분류해 놓은 편이다. 가령, 초기화되지 않은 변수를 사용하는 것은 다소 심각한 수위의 경고이지만.. 선언만 해 놓고 사용하지 않은 변수는 상대적으로 덜 심각한 경고이다.

또한 요즘은 정적 분석기가 함수 인자의 annotation까지 참조해서 미주알고주알 지적해 주는 잠재적 오류 가능성도 경고의 연장선이라고 볼 수 있다. "null 포인터를 참조할 가능성이 있다, 버퍼 오버런이 발생할 수도 있다" 따위 말이다.
요즘 세상에 코딩을 글쓰기에다 비유하자면 컴파일· 빌드는 인쇄· 배포에 대응하고, 정적 분석은 맞춤법 검사기와 비슷해 보인다.

2. 빌드 툴들이 말귀를 도무지 못 알아들을 때 확인해 볼 사항

  • 그 소스 파일이 프로젝트에 포함돼 있긴 한가? 포함돼 있더라도 혹시 exclude from build 이런 낚시 옵션에 걸려 있지 않은가?
  • 문제의 구간이 #if 조건을 만족하는 구간에 속해 있는가?
  • 명칭이 이상한 매크로 때문에 다른 엉뚱한 형태로 치환되고 있지는 않은가? (주로 C)
  • C++의 경우, 복잡한 namespace나 using 으로 인한 문맥 차이가 존재하지 않는가?
  • 링크 에러의 경우, extern "C"로 인한 name mangling 방식 차이가 존재하지 않는가?

3. 빌드 속도

특별히 다른 부하가 걸린 게 없는 멀쩡한 개발자용 평균 사양의 2~3GHz급 컴터에서.. 2000줄 이하의 평범한 복잡도의 C++ 소스 파일이.. (IOCCC 입상작급 기괴한 난독화, 다단계 템플릿, namespace, #define 떡칠, 다중 다단계 상속 등의 남발.. 이 아닌 "평범한". -_-)
그것도 네트웍도 아닌 로컬 환경에서, 더구나 딱히 빡세게 최적화를 걸지도 않은 디버그 빌드가 컴파일하는 데 개당 0.8초 이상씩 걸리는 건.. 본인은 납득하기 어려운 상황이라고 간주한다. 소스 코드의 #include 구조 및 빌드 시스템에 문제가 있을 가능성이 높다.

당장 precompiled header가 제대로 적용돼 있는지, 덩치 큰 라이브러리 헤더의 연쇄 인클루드와 파싱이 무식하게 매번 반복되고 있지 않은지부터 확인해야 한다. 저건 컴터한테나 인간에게나 좋지 않은 상황이다.
DB 테이블로 치면 primary key 지정이나 인덱싱과 비슷한 최적화가 필요하다.

4. 안드로이드 앱용 JNI 라이브러리의 빌드

(1) 안드로이드 앱을 개발할 때, 겉에서 돌아가는 java 내지 kotlin 코드 기반의 프로그램이야 로컬 환경에서 Android Studio로 간편하게 빌드할 수 있다. 이 IDE는 Windows용과 mac용이 모두 깔끔하게 존재한다.
하지만 이 앱이 내부적으로 사용하는 native code 라이브러리들의 빌드 환경은 내 경험상 mac이건 리눅스건 여전히 유닉스 기반 터미널에 의존하고 있다. Windows에서 바로는 안 된다는 게 특이한 점이다.;; JNI 쪽 빌드도 IDE와 연계해서 같이 되게 할 수는 없는지..

(2) 디버깅도 앱은 breakpoint와 step in/out/over, 지역변수 값 확인, call stack 같은 통상적인 방법론이 IDE 차원에서 모두 지원되는 반면, 그 아래의 라이브러리는 그렇게 할 수 없다. 그런 내부 동작은 로그 printf 신공에 의존해서 추적하는 수밖에 없으니 몹시 불편하다.

(3) 그래도 이런 라이브러리들은 빌드 시스템이 멀티코어/멀티스레드 환경과 굉장히 잘 연계하는 편이다. 그래서 고성능 빌드 서버에서 make -j8 , -j16 이런 식으로 코어 수를 늘려 주면 빌드 속도가 정말 눈에 띄게 매우 빨라진다.
그런데 이 시설에도 이 기능에도 매우 아쉬운 점이 있는데... 코어 수가 늘어나면 빌드 에러 메시지도 진짜 정신없게 중구난방으로 튀어나와서 확인이 어려워진다는 것이다.

Visual Studio처럼 메시지의 앞에 코어 내지 프로젝트의 번호라도 좀 찍어 주면 읽기가 좀 더 수월할 텐데 말이다.
그리고 터미널 접속 프로그램의 본좌인 putty에는 특정 단어나 문자열이 등장했을 때 highlight를 시켜 주는 간단한 기능이 좀 있었으면 좋겠다.

putty는 20년이 넘게 0.x대의 버전 번호를 고수하고 있고, 유니코드(W)가 아닌 ANSI API를 사용하는 게 이색적이다.
ANSI API, 0.x 버전, 크로스 플랫폼 공개 소프트웨어라는 점에서는 DOSBOX하고도 무척 비슷하다.

5. 구조체 전방 선언의 부작용(?)

C/C++ 코드에서는 모듈 간의 include 의존도(= coupling)을 낮추기 위해서 자신이 내부적으로 취급하는 구조체는 불완전하게 전방 선언만 명칭만 노출하는 경우가 많다. 외부에서는 전방 선언 구조체의 포인터만 핸들 마냥 갖고 있고, 실제 조작은 실제 내부 구조를 아는 함수의 호출을 통해서를 하는 것이다. 뭐, 이게 C++이 말하는 정보 은닉과도 일맥상통하는 개념이며, 충분히 바람직한 디자인 패턴이다.

하지만 디버깅을 하는 상황이라면 어떨까..??
조작하는 함수로 들어가기 전에, 즉 밖에서 breakpoint를 걸었다. 이때도 이 포인터가 가리키는 구조체 내용을 좀 조회할 수 있었으면 좋겠는데 그게 Visual Studio IDE에서 안 돼서 답답했던 경우가 많다. 그렇다고 구차하게 소스 코드를 고쳐서 디버깅일 때에 한해서 감춰 놓은 내부 구조체 몸체 선언 include를 시키고 싶지도 않다.
특정 상황에 한해서 컴파일 때는 참고하지 않는 다른 소스 코드의 디버깅 정보를 가져오는 기능이 있으면 좋을 것 같다.

6. 나머지

(1) 컴파일러와 링커는 오늘날까지도 환경 변수라는 게 쓰이는 얼마 안 되는 분야이기도 하다. 환경 변수라는 게 명령줄에서 실행 파일을 자동으로 찾는 PATH, 그리고 컴파일러가 사용하는 기본 include 및 라이브러리 디렉터리... 이것 말고는 쓰이는 곳이 정말 드물지 않은가? 자체적인 환경 설정 파일 같은 게 동원될 법도 한데 컴파일러와 링커는 GUI 프로그램이 아니다 보니 좀 더 저수준이면서 실행되는 세션별로 사용자가 값을 더 간단하게 변경할 수도 있는 환경 변수를 대신 선택한 것 같다.

(2) 과거에 도스용 Turbo C/C++ 같은 물건은 굳이 프로젝트 파일을 안 만들어도 소스 하나만 단독으로 달랑 열어서는 곧장 빌드해서 돌려 볼 수 있었다. 그러나 요즘 개발툴들은 단순 텍스트 에디터 이상의 매우 복잡하고 방대한 물건이기 때문에 그렇게 할 수 없다. Hello world! 한 줄짜리 프로그램을 만들더라도 최소한의 프로젝트 세팅은 한 뒤에야 빌드와 디버깅이 가능하다.

(3) 그리고 요즘 개발툴들은 여러 소스 파일들을 한데 묶은 프로젝트로도 모자라서.. 프로젝트도 여러 개를 한데 묶은 '솔루션, workspace'라는 개념으로 운용된다는 것이 주지의 사실이다. 이 정도는 돼야 좀 규모 있는 소프트웨어를 원활히 개발 가능하기 때문이다.

(4) 컴터 프로그램 개발을 하다 보면.. 디버깅 로그가 실시간으로 뜨게 해 놓은 채로 디버기 프로그램을 구동하고 일정 주기로 결과를 확인할 때가 있다.
그런데 이때 프로그램이 출력하는 로그만 넣는 게 아니라, 사용자가 로그에다가 인위로 "=======" 같은 가로줄 같은 걸 즉석에서 추가할 수 있으면 좋겠다는 생각이 든다. 한 프로그램에서 동작 시험을 여러 번 할 때 로그의 영역을 하기 위해서이다.

(5) 앞으로는 "주 메모리에 로드되어 실행된 프로그램 / 하드디스크에 설치돼 있는 프로그램 / 원본 설치 패키지"라는 소프트웨어의 통상적인 3단계 구분이 더 모호해지고 단순화되지 않을까 생각된다.
일단 웹 프로그램은 설치라는 과정이 없는 게 확실하며, 메모리 계층에서 보조 기억장치와 주 메모리의 구분이 모호해지는 것도 이런 추세를 더욱 부채질할 테니 말이다.

Posted by 사무엘

2021/06/26 08:35 2021/06/26 08:35
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1903

컴퓨터 소프트웨어계에는 이미 작성되어 있는 프로그램을 실제로 돌려 보지 않고(샌드박스 가상 머신 안에서..) 형태만 들여다보고는 버퍼 오버런이나 메모리 누출 같은 잠재적 위험성 및 논리 결함을 어느 정도 찾아 주는 '정적 분석'이라는 기술이 존재한다. 그 프로그램이 기계어 바이너리 형태이건, 고급 언어 소스 코드이건 형태는 무엇이건 상관없다.

그런데 정적 분석 툴은 그 누가 만든 것이라도 원천적으로 이론적으로 근본적으로 100% 정확하게 작동하지는 못한다.
이에 대해서 "아니 소스 코드가 무슨 자유 의지를 지닌 생명체도 아닌데 그 뻔한 로직을 분석해서 결과를 사전 예측하는 게 그렇게 어려운가? 단순히 소모하는 메모리와 계산량만 많아서 어려운 거라면 컴퓨터의 성능빨로 극복 가능하지 않은가? AI 기술을 접목하면 되지 않는가?" 처럼 생각하기 쉽다.

하지만 그렇지 않다. 그 말은 저런 차원이 아니다.
그런 함수는 단순히 현실적으로 구현하기가 어려운 정도가 아니라 논리 차원에서 모순에 빠지며 존재 불가능하기 때문이다. "모든 창을 막는 방패와 모든 방패를 뚫는 창 세트"와 동급으로 존재 불가능하다~! 창이나 방패의 제조 기술과는 무관하게 말이다.

가장~~ 원초적인 정적 분석 프로그램을 생각해 보기로 한다.
분석할 대상인 프로그램 코드, 그리고 그 프로그램에다가 넘겨줄 입력 데이터.
이 둘을 인자로 받아서 이 프로그램의 시시콜콜한 무슨 메모리 문제 따위를 진단하는 게 아니라..
이 프로그램이 무한 루프에 빠지지 않고 실행이 종료되기는 할지를 정확하게 판단해 주는 bool DoesThisProgramReturn(func, argument) 라는 가상의 함수 프로그램을 생각해 보자.

argument는 현실의 프로그램으로 치자면 명령 인자뿐만 아니라 프로그램이 파일이나 네트워크 형태로 읽어들이는 방대한 입력 데이터까지 모두 포함하는 개념이다. "일괄 처리 형태가 아니라 입출력이 실시간으로 들어오는 프로그램은요?" 이건 이 시점에서 그리 중요한 문제가 아니니 논외로 한다.
func는 뭐.. C/C++로 치면 기계어 코드를 가리키는 함수 포인터 정도로 생각하면 이해하기 편하겠다.

당연한 말이지만 저 함수 자체는 절대로 무한 루프에 빠지지 않고 언제나 유한 시간 안에 답이 나오는 게 보장된다. 무한 루프에 빠지는 프로그램을 의뢰했더라도 말이다. 그러므로 DoesThisProgramReturn(DoesThisProgramReturn, xxx)는 xxx로 무엇을 넘겨주건 그 정의상 리턴값이 언제나 true가 된다.

그럼.. 저 가상의 함수는 어떤 식으로 동작할지를 생각해 보자.
func가 가리키는 코드를 읽으면서 while(true); 같은 패턴을 발견한다거나,
더 구체적으로는 예전에 한번 거쳤던 state와 동일한 state로 이미 지났던 지점을 또 지나는 게 감지되면.. 이 프로그램은 실행이 끝나지 않는다는 결론을 내릴 수 있을 것이다.

이거 만델브로트(망델브로) 집합을 그릴 때 주어진 복소수의 발산 여부를 판별하는 것과 비슷하게 느껴진다.
배배 꼬인 복잡한 프로그램에서는 좀 어렵겠지만 그래도 도저히 불가능한 문제는 아니어 보이는데..??

하지만 튜링 기계는 우리가 흔히 생각하는 것보다 자유도가 더 높은 계산 모델이다.
메모리에 저장된 주소값에 해당하는 다른 메모리의 값을 마음대로 읽고 쓸 수 있을 뿐만 아니라(= 포인터) 거기 저장된 데이터를 코드로 간주해서 실행할 수도 있다(= 함수 포인터).

재귀 호출도 되고.. 또 앞서 살펴보았듯이 DoesThisProgramReturn 자신조차도 튜링 기계에서 실행되는 함수이기 때문에 DoesThisProgramReturn의 인자로 전달할 수 있다. 그리고 분석 대상인 타 함수가 얘를 또 호출할 수도 있다.
이런 상황까지 다 허용 가능해야 한다면 DoesThisProgramReturn의 존재 가능성은 굉장히 난감해진다.

아래와 같이.. DoesThisProgramReturn가 true라고 판정한(= 실행이 끝난다) func에 대해서는 "반대로" 자신이 무한 루프로 가 버리고, 실행이 끝나지 않는 함수에 대해서는 실행을 끝내는 HangIfReturns이라는 함수를 정의해 보자.

bool HangIfReturns(func) {
    if (DoesThisProgramReturn(func, func)) while(true);
    return true;
}

그러니 HangIfReturns(DoesThisProgramReturn)을 하면.. 얘는 무한 루프에 빠지게 된다.
DoesThisProgramReturn은 자기 자신에 대해서는 앞서 정의한 바와 같이 언제나 true를 되돌리고(= 늘 깔끔하게 실행 종료) if문을 만족하기 때문이다. 여기까지는 쉽다.

하지만 반대로 HangIfReturns가 DoesThisProgramReturn의 인자로 들어가면 어떤 일이 벌어질까? DoesThisProgramReturn(HangIfReturns, HangIfReturns)는 리턴값이 무엇이 되는 게 이치에 맞을까? 이제 좀 머리가 복잡해질 것이다.

DoesThisProgramReturn(HangIfReturns, HangIfReturns)가 true라면.. HangIfReturns 안의 if문은 true가 되므로 HangIfReturns은 무한 루프에 빠진다. 그러면 저 함수의 리턴값은 원래 false가 되어야 하게 된다.
반대로 저 리턴값이 false라면.. 역시 이제 HangIfReturns는 실행이 깔끔하게 종료되므로 저 함수의 리턴값을 정면으로 부정하는 결과가 나온다.

요컨대 HangIfReturns가 무한 루프에 빠지는지의 여부는 DoesThisProgramReturn의 리턴값에 따라 달라지는데, 이 과정에서 서로 물고 무는 구조적인 모순이 발생하는 셈이다.
이 모순은 DoesThisProgramReturn라는 함수가 존재한다는 가정으로부터 비롯되었다. 그러니 튜링 기계 하에서 다른 코드의 실행 종료 여부를 완벽하게 판단하는 코드를 똑같은 튜링 기계 기반으로 구현하는 것은 불가능하다는 것이 입증된다.

이 논리는 "정지 문제"(halting problem)이라고 불리며, 컴퓨터라는 기계의 계산 가능 범위를 고민하게 하는 매우 탁월한 통찰이다. 이걸 처음으로 생각해서 논문으로 발표한 사람이 바로 그 이름도 유명한 앨런 튜링이다.

과학 철학에서 "반증 가능한가", 천문학에서 "관측 가능한가"처럼.. 전산학에서는 "계산 가능한가, 튜링 기계를 돌려서 답을 구할 수 있는 문제인가"가 중요한 고민거리가 된다. 계산 자체가 이론적으로 가능해야 그 다음 관심사는 "실용적으로 유의미한 시간 만에 빨리 해결할 수 있는가?", 더 구체적으로는 "입력 크기 N에 관한 다항식 급의 시간 안에 해결 가능한가 (팩토리얼이나 지수 함수 급이 아니라)"라는 시간 복잡도가 될 것이다.

TSP(순회하는 세일즈맨) 문제 같은 NP-완전 문제는 이론적으로 알려진 시간 복잡도가 너무 높기 때문에 실생활에서는 적당히 성능이 좋은 다항 시간 근사 알고리즘이 쓰인다.
그래도 정지 문제는 3-SAT 문제라든가 NP-완전처럼 시간 복잡도를 따지는 증명보다는 덜 난해하고 직관적인 설명도 가능하기 때문에 수식 없이 블로그에다 증명 방식을 소개도 할 수 있다. 현실에서는 논리적으로 100% 완벽하고 헛점이 없고 100% 정확하게 동작하지는 못하지만 그래도 현실적으로 충분히 정확하고 속도도 적절한 각종 소스 코드 정적 분석 기능이 개발되어 쓰이고 있다.

Posted by 사무엘

2021/05/24 19:36 2021/05/24 19:36
, , , ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1891

1. 비트 연산 관련 버그

프로그래머가 살면서 설마 컴파일러의 버그를 볼 일이 얼마나 될까? 이건 마치 버스· 트럭· 택시 등 운전으로 먹고 사는 기사 아저씨가 잘 가다가 차량의 엔진 결함이나 급발진을 경험하는 것만큼이나 끔찍한 경험일 것이다.

본인은 최적화 옵션을 빡세게 주고 나면 Visual C++ 컴파일러가 비트 연산 쪽으로 유난히도 말귀를 못 알아먹는 현상을 종종 목격했다.
7년쯤 전에 VC++ 2010 기준으로 (1) bit rotate 연산을 <<, >> | 따위로 구현한 게 제대로 동작하지 않는 것을 목격했다. 그 함수만 #pragma를 줘서 최적화를 강제로 꺼야 오류가 발생하지 않았다.

그리고 2019년쯤에는 (2) WORD, BYTE 따위를 비슷한 연산으로 한데 합쳐서 DWORD를 만들려고 했는데.. 이것도 변수 내용을 강제로 로그를 찍으면 문제가 없지만 간단하게 값만 되돌리게 하면 틀린 값이 돌아왔다.
인라인 함수, 매크로 함수, 최적화 강제 해제 등 별별 방법을 써도 소용없어서 결국은 무식하게 memcpy로 값을 오프셋별로 강제 복사해서 문제를 회피해야 했다.

그 뒤, 19.5.x급으로 그 당시로서는 최신 업데이트가 적용됐던 Visual C++ 2019에서 더욱 황당한 일을 겪었다.
내가 하고 싶은 일은 8비트 char 값을 그대로 부호 없는 형태로만 바꿔서.. 즉, -3을 253으로만 바꾼 뒤 다른 산술 연산 처리를 하는 것이었다. 그런데 (3) 컴파일러가 말귀를 못 알아듣고 숫자를 32비트로 취급하면서 앞에 0xFFFFFF00를 제멋대로 붙였다.

숫자는 내가 기대한 것보다 엄청나게 큰 값으로 바뀌었으며, 프로그램은 이 때문에 오프셋 계산을 잘못해서 메모리 오류가 발생했다. 내가 아무리 강제 형변환 연산을 집어넣어 줘도 오류는 없어지지 않았다. 계산값에다가 원래는 할 필요가 없는 &0xFF 필터링을 강제로 하거나, 이 역시 최적화를 꺼야만 오류가 사라졌다. 이런..

이 세 사례는 모두 비트 연산 + 최적화와 관련된 컴파일러의 난독증이라는 공통점이 있었다. 2010으로 32비트 코드를 빌드하던 시절이나, 2019로 64비트 코드를 빌드하던 시절이나 마찬가지이니.. 딱히 버전과 아키텍처를 가리지도 않는 것 같다.

더 자세한 정황을 나열하지 못하는 이유는 이것들이 전부 방대한 회사의 코드를 취급하다가 발생한 일이기 때문이다. 그래서 동일 문제를 재연할 수 있는 최소한의 케이스를 따로 분리할 수가 없다. 그 함수만 텅 빈 프로젝트에다가 떼어내서 돌리면 당연히 문제가 발생하지 않는다.
하지만 동일 코드를 사용하여 macOS, 안드로이드 등 타 플랫폼에서 돌아가는 제품에서는 버그가 발생하지 않으니 이건 일단 Visual C++만의 문제라고 봐야 할 듯하다.

2. UTF-8 지원 여부와 미스터리한 오동작

Windows는 전통적으로 ANSI 인코딩(?) 천국이던 운영체제였다. 그래서 유니코드 자체는 진작부터 지원했지만 UCS-2 내지 UTF-16 같은 별도의 2바이트 단위 인코딩 형태로만 지원하는 것을 선호했다. 1바이트 단위 인코딩인 UTF-8의 형태로 지원하는 것에는 대단히 보수적이고 인색했다.

오죽했으면 Visual C++이 취급하는 리소스 스크립트 *.rc라든가 resource.h의 기본 포맷도 유니코드 기반으로 바뀌긴 했는데.. UTF-8이 아니라 UTF-16으로 바뀌었다. 거 참..

그래도 세월이 흐르니 마소에서도 대세를 거스를 수 없는지라, 명령 프롬프트에서 제한적이나마 65001 UTF-8 코드 페이지를 지원하기 시작했다. Windows 10 19xx 버전부터는 메모장이 기본으로 지정하는 텍스트 저장 인코딩이 UTF-8로 바뀌기도 했다.
심지어 Visual C++ 컴파일러 역시 UTF-8 인코딩의 소스 코드를 인식하기 시작했다. 단...!! 이건 2% 부족한 아쉬운 면모가 좀 있다.

바로.. 파일 앞부분에 BOM이 있을 때만 UTF-8로 인식한다는 것이다. 그렇지 않으면 그냥 ANSI이다.
소스 코드의 인코딩을 강제로 지정하는 옵션이 소스 코드 내부에 #pragma 같은 형태로 좀 있었으면 좋겠지만 그렇지는 않다. #pragma code_page라는 게 있긴 한데, C 문법을 일부 빌려 온 리소스 스크립트에만 쓰인다.
파일 내부 대신, 컴파일러의 옵션으로 /source-charset:utf-8 요런 게 존재하고, 줄여서 그냥 /utf-8이라고만 해도 된다.

생각해 보면 설정이 하나만 있는 것으로 충분하지 않다. 소스 코드 자체는 인코딩이 UTF-8인데 그 안에서 L로 둘러싸이지 않은 "한글"이라는 문자열 리터럴은 KS X 1001로, 즉 길이가 4바이트이고 전체 크기가 5바이트인 문자열을 의도한 것일 수 있다. 그렇게 실제로 의도된 인코딩을 지정하는 옵션은 /execution-charset이라고 따로 있으며, /utf-8은 두 charset을 모두 utf-8로 지정한 것과 같은 효과를 낸다.

그런데 컴파일러는 그렇게 인식시키면 되지만 에디터의 동작에 여전히 함정이 남아 있다.
BOM도 없고 딱히 한글· 한자 같은 문자도 없이 모든 문자열이 간단한 1바이트 숫자· 알파벳 따위로만 구성된 평범한 파일의 경우, Visual Studio IDE는 얘를 기본적으로 ANSI 인코딩 파일로 간주한다. 그 파일에 나중에 한글· 한자가 부주의하게 추가된다면 인코딩이 영락없이 잘못 지정될 수 있다. 이 기본 동작을 고치는 방법이 있는지는 난 아직 모르겠다.

그런데 그렇다고 BOM을 넣어 버리면..?? BOM은 Windows 동네에서나 통용되지, 리눅스 등 타 운영체제에서는 그냥 민폐 덩어리인 문자이다. 소스 파일의 앞에 저런 문자가 떡 있으면 컴파일러가 잘못 먹고 체하는 수가 있다.
그러니 한 소스를 여러 플랫폼에서 공유하는 경우, 모든 코드의 인코딩은 그냥 닥치고 BOM 없는 UTF-8로 통일하는 게 안전하다. 이 문제에 관한 한은 Visual C++이 타 빌드 툴들의 표준 관행에 맞춰 줘야 한다. BOM는 이식성을 저해하기 때문이다.

모종의 이유로 인해 Visual C++에서 소스 코드의 인코딩이 잘못 인식되면 빌드 과정에서 깨진 문자가 있다고 C4819라는 경고가 발생한다. 깨진 문자가 주석 내지 조건부 컴파일에 걸려서 어차피 빌드되지 않는 영역에 있을 때는 저게 딱히 문제될 게 없다. 단지, 문자열 리터럴 내부에 들어있던 한글· 한자가 깨지면 심각한 문제가 될 것이다.

그런데 내 경험상.. 주 번역 단위에 해당하는 소스 파일과, 걔가 인클루드 하는 헤더 파일 간에 인코딩이 다를 때도 상당히 골치 아픈 문제가 발생하곤 했다.
C4819 말고도 C4828이라고 파일의 줄 수가 아닌 오프셋 운운하면서 굉장히 기괴한 경고가 떴다. 최신 컴파일러에서는 이 경고가 삭제되었는지 조회되지도 않더라.

그리고 정말 믿을 수 없지만 컴파일러가 완전히 뜬금없는 에러를 내면서 동작을 멈췄다. 실제로 문법 오류가 전혀 없는 구문에서도 쓸데없는 에러가 발생했으며, 그 소스 파일에 실제로 존재하지 않는 칸 번호를 언급하기도 했다.
이렇게만 말하는 나도 황당하고 읽는 분들도 상황을 받아들이지 못하시겠지만.. 내가 실제로 겪은 상황이 저랬다.

이 역시 회사에서만 겪었기 때문에 정확· 엄밀하게 재연 케이스를 만들지는 못하겠다. 아까 얘기했듯이 (1) /utf-8 옵션을 global하게 준 상태에서 소스와 헤더 파일들의 인코딩이 충돌 난 것, 그리고 아마도 (2) precompiled 헤더를 쓰는 소스와 그렇지 않은 소스가 한 프로젝트 안에서 좀 뒤섞여 있는 것, (3) namespace와 using이 좀 복잡하게 얽혀서 인텔리센스도 오락가락 하는 상황인 것이 다 조금씩 영향을 주지 않았을까 생각된다.

이 난국은 모든 코드의 인코딩을 BOM 없는 UTF-8로 정리하고, 모든 코드에다가 한글로 dummy string을 만들어서 Visual Studio IDE가 파일을 ANSI (cp949) 인코딩으로 잘못 저장하는 일이 없게 조치를 취함으로써 해결되긴 했지만..
그때 그 문제가 왜 발생했으며 그 상황을 어떻게 재연할 수 있는지는 모른 채 미스터리로 남게 되었다.

회사에서는 길지 않은 기간 동안에도 이 정도의 이상한 버그를 몇 차례 경험했는데.. 개인적으로 날개셋 한글 입력기를 20여 년 가까이 만들어 온 동안은 컴파일러의 버그를 경험한 적이 거의 없다는 것이 참 신기하다. IDE야 불필요하게 다운되거나 뻗는 버그를 여럿 경험했지만 컴파일러가 문제를 일으킨 적은 없었다.
모든 코드가 깔끔하게 KS X 1001 레거시 인코딩이고, 회사 코드보다는 규모가 작고 모듈 구조가 깔끔하고, 전부 precompiled 헤더를 사용하기 때문이 아닌가 생각한다.

소스 코드의 인코딩이 UTF-8이 아니거나, UTF-8이더라도 앞에 BOM이 있는 것 자체를 경고로 처리하는 건 너무 과격할까? 그리고 #include에서 경로 지정을 /가 아닌 \로 한 걸 경고로 처리하는 옵션도 있으면 좋겠다. 이런 건 Windows 환경에서나 통용되지 밖에서는 전부 민폐 에러 요인이 되기 때문이다. 본인이 직장의 공동 작업 과정에서 종종 실수했던 적도 있는 사항들이다.

3. 인텔리센스의 오동작

끝으로, 이건 실제로 생성된 exe/dll의 동작과 관계 있는 치명적인 문제는 다행히 아니지만.. Visual C++ IDE가 텍스트 에디터에서 사용하는 인텔리센스도 일부 특이한 상황에서는 말귀를 못 알아듣고 오동작할 때가 있다.

본인이 겪은 경우는 클래스(가령 A)의 선언 내부에 MFC의 DECLARE_DYNAMIC 같은 복잡한 custom 매크로를 넣은 뒤, 곧장 private/public/protected 같은 접근 권한 지정자가 나올 때이다. 그러면 인텔리센스가 그 뒤에 이어지는 멤버 및 내부 enum/class (가령 B) 따위 선언을 파싱을 제대로 못 한다. ClassView를 보면 A의 멤버 목록에 B의 멤버들이 잘못 표시되며, B 선언 이후에 등장하는 A의 진짜 멤버들은 전혀 인식되지 않는다.

ClassView뿐만 아니라 텍스트 에디터에다 불러온 소스 코드에서도 각종 경고와 에러 밑줄이 A의 멤버들이 누락된 것처럼 쭈루룩 뜬다.
그렇기 때문에 A 클래스의 구현부에서는 인텔리센스와 자동 완성, 심벌 위치 조회 같은 기능들을 활용하지 못하면서 코딩을 꽤 불편하게 해야 한다.

이런 초보적인 문제는 Visual C++ 6 ncb 시절에나 보던 게 아니었나? 왜 발생하는지 모르겠다.
최신 업데이트를 적용한 Visual C++ 2019에서도 동일하게 발생한다. 본 컴파일러가 아니라 인텔리센스 컴파일러이니 딱히 특정 Visual C++ 컴파일러 툴킷만의 문제도 아닐 것이다.

뾰족한 해결책은 없고, 인텔리센스를 헷갈리게 하는 그 문제의 매크로를 클래스 선언의 맨 앞이 아니라 맨 뒤로 옮김으로써 문제를 회피할 수 있었다. 흠...

4. 도킹 하다가 뻗음

역시 컴파일러가 아닌 IDE 얘기이고, 옛날 버전에서만 발생하는 문제이기 때문에 지금 큰 의미는 없지만..
Windows 10 19xx대 버전부터인가 Visual Studio 2013 (그리고 아마 2015도)에서 각종 문서 편집 창이나 보조 윈도우(출력, 속성, 디버그 등등)를 어디에든지 도킹을 해서 붙이면 프로그램이 뻗어 버린다.

2010이 언제부턴가 실행될 때 Microsoft.Vsa.tlb 파일이 없다는 에러를 내는 것과 비슷한 현상인 것 같다. 그래도 얘는 정상 실행은 되고 프로그램 사용에 문제가 없는 반면, 저건 창을 내 마음대로 배치할 수 없게 만들고 프로그램이 뻗기까지 하기 때문에 상당히 심각한 문제이다.
저런 단순 UI는 운영체제건 VS건 한번 만들고 나서는 고칠 일이 없는 기능일 것 같은데.. 둘 다 내부적으로 뭘 건드리길래 이런 부작용이 발생하는 걸까..??

하긴, 더 옛날엔 Visual Studio 2005도 Windows Vista에서 실행하려면 sp1에다가 Vista 지원 추가 패치까지 설치해야 겨우 돌릴 수 있었다. 아래아한글 2005와 2007도 Vista 이후의 운영체제에서 실행하려면 업데이트부터 대판 설치해야 했었으니 이런 예가 전혀 없지는 않구나.

어떤 프로그램이 후대의 운영체제에서 단순히 GUI나 외형의 glitch 정도가 발생하는 걸 넘어 아예 뻗고 실행이 안 되는 건.. 대부분 보안 강화 때문이지 싶다. 문서화되지 않고 미래에 얼마든지 달라질 수 있는 특성이나 동작에 의존하게 프로그램이 만들어진 경우야 걔의 잘못이겠지만, 흔한 경우는 아닐 것이다.

Posted by 사무엘

2021/05/19 08:35 2021/05/19 08:35
, ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1889

프로그래밍에서 메모리를 가리키는 포인터라는 건.. 그 특성상 돌아가는 컴퓨터의 machine word와 크기가 동일하다. 하지만 현실에서 포인터(= 메모리 주소)를 구성하는 모든 비트가 골고루 쓰이는 일은 몹시 드물었다.

먼저, 컴퓨터의 실제 메모리 양이 포인터가 가리킬 수 있는 범위보다 훨씬 적다. Windows의 경우, 32비트 시절에는 user mode에서는 대부분의 경우 포인터의 상위 비트가 언제나 0이었던 것이 잘 알려져 있다(하위 2GB까지만 사용).
하물며 64비트는 공간이 커도 너무 크기 때문에 가상 메모리 관리 차원에서도 아직은 40~48비트까지만 사용한다. 상위의 무려 16비트가량이 쓰이지 않는다는 것이다. 램이 32GB여도 겨우 35비트면 충분하니까..

가난하고 배고프던 20세기 16비트 시절에는.. 반대로 포인터 하나만으로 겨우 몇백 KB~수 MB 남짓한 메모리도 한번에 다루지 못했다. 그래서 far 포인터니 huge 포인터니 별 삽질을 다 해야 했는데 그때에 비하면 지금은 격세지감이 따로 없다.

저렇게 상위 비트뿐만 아니라 하위 비트도 마찬가지이다. padding, align 같은 이유로 인해, 메모리 할당 함수의 포인터 리턴값이 홀수가 될 일은 일반적으로 없다. 아니, 겨우 2의 배수가 아니라 4나 8의 배수가 될 수도 있으며, 이 경우 하위 2~3개 비트도 0 이외의 값을 가질 일이 없게 된다.

그러니 포인터를 저장하는 공간에서 0 이외의 값이 들어올 일이 없는 비트에다가 자신만의 정보를 넣는 꼼수를 부리는 프로그램이 예로부터 줄곧 존재해 왔다.
이거 무슨 변태 같은 짓인가 싶지만.. 이제 막 32비트로 넘어가긴 했지만 아직 가정용 컴퓨터들의 평균적인 메모리 양이 수 MB대밖에 안 됐던 시절이 있었다. 이때는 메모리가 부족해서 하드디스크 스와핑이 일상이었다. RAM을 1바이트라도 더 아끼는 최적화가 필수였다.

가령, 다재다능한 자료구조인 빨강-검정 나무를 생각해 보자.
노드의 색깔을 나타내는 겨우 1비트짜리 정보를 위해서 굳이 bool 멤버를 추가하는 건 굉장한 낭비라는 생각이 들지 않는가? 단 1비트 때문에 구조체 패딩까지 감안하면 무려 2~4바이트에 달하는 공간이 매 노드마다 허비되기 때문이다.
안 그래도 노드의 내부엔 left/right 같은 딴 노드 포인터가 있을 것이고, 포인터 내부에 쓰이지 않는 1비트 공간이 있으면 거기에다 색깔 정보를 박아 넣고 싶은 생각이 들 수밖에 없다. 비트필드와 포인터의 union 써서 말이다.

물론, 그렇게 0으로만 채워지던 공간을 운영체제에서도 나중에 유의미하게 사용하기 시작하면.. 그 꼼수 프로그램은 재앙을 맞이하게 된다.
대표적인 예로 마소에서는 32비트 기준으로 사용자:커널이 통상적인 2GB:2GB가 아니라 3GB:1GB로 주소 공간을 분할하는 기능을 Windows에다가 추가했다.

이러면 사용자 모드의 포인터도 2GB가 넘는 영역에 접근할 수 있으며 최상위 비트가 1이 될 수 있다. 그런데 포인터의 최상위 비트를 자기 멋대로 사용하고 있는 프로그램은.. 뭐 메모리 뻑나고 죽을 수밖에 없다.
64비트 환경에서는 겨우 1비트가 아니라 상위 word 전체를 다른 용도로 전용해도 당장 이상이 없으며 이 추세가 앞으로 몇 년은 가지 싶다. 컴퓨터의 램이 256~512GB나 1테라까지 간다면 모를까..

요즘 컴퓨터야 메모리가 워낙 많고 풍족하니, 굳이 저런 꼼수를 동원하는 프로그램은 별로 없을 것이다.
하지만 저 때가 되면 또 꼼수 부리는 말썽꾸러기 프로그램과의 호환성 때문에 주소 공간을 옛날처럼 상위 16~32GB까지로 봉인하는 옵션 같은 게 또 등장할지도 모른다.;;; HIGH_DPI_AWARE처럼 LARGE_ADDRESS_AWARE 시즌 2 말이다.

여담이지만 Windows의 경우, 실행 파일은 시작 주소가 언제나 64KB의 배수 단위로 부여되기 때문에 HINSTANCE/HMODULE은 아래쪽은 무려 word 덩어리가 언제나 0이 된다. 이 특성을 이용해서 운영체제의 LoadLibraryEx 함수도 하위 몇 비트를 자기 마음대로 활용하기도 한다.

※ 나머지 메모

(1) unsigned 타입에 대해서 단항 연산자 -를 적용해서 -a 이런 값을 구하는 코드를 우연히 보고는 개인적으로 신박하다는 생각이 들었다. 흐음~ Visual C++의 경우 이건 원래 경고인데, 요즘 버전에서는 더 엄격하게 에러로 처리하는가 보다.
-a는 2의 보수의 특성상 ~a+1과(비트 not보다 1 크게) 완전히 동일한 효과를 내며, 앞에 0을 붙여서 이항 연산자로 만들어도 에러를 회피할 수 있다.

(2) ANSI C에서는 함수의 prototype을 선언할 때 매개변수 리스트에 타입만 써 넣고 이름을 빼먹으면 안 된다는 걸 최근에야 알게 됐다.
아니 도대체 왜..? 거기서 매개변수의 이름은 거의 잉여 옵션에 불과할 텐데.. void func(int);라고만 쓰면 틀리고 void func(int x);라고 아무 이름이라도 붙여야 된다는 것이다.
이건 먼 옛날에 C언어에서 void func(a) int a; 같은 구닥다리 문법이 쓰이던 시절의 잔재인 것 갈다.

Posted by 사무엘

2021/05/15 08:35 2021/05/15 08:35
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1887

컴퓨터그래픽에서 벡터 그래픽의 반의어로 픽셀과 비트맵을 다루는 체계를 래스터 그래픽이라고 흔히 부른다. 종이가 아니라 해상도가 상대적으로 낮은 모니터 화면이 주 무대이고, 면을 채우는 기본 단위가 scan line(주사선)이라는 관점에서 정립된 용어이다.

그리고 2D 비트맵(더 정확한 명칭은 래스터..?) 그래픽 API를 보면 어떤 플랫폼용 어떤 언어의 라이브러리이든지 점과 직선, 곡선을 그리는 함수가 있고, 사각형과 원을 그리는 함수가 있다. 이게 기본이다.
점이나 사각형이야 그리는 방식이 너무 trivial하니 제끼고, 원이나 곡선을 빠르게 그리는 원리는 기하 알고리즘의 일종으로 다뤄지기도 한다. 그 단순한 직선조차도 굵기가 2픽셀 이상이 되면 중심점을 생각해야 할 것이고, 무거운 부동소수점 연산 없이 anti-aliasing까지 하면서 그린다는 조건이 추가되면 결코 쉽지 않은 일이 된다.

그리기 기능 중에서 특정 픽셀부터 시작하는 flood fill은 무척 독특한 동작이다. 기하 알고리즘이라기보다는 스택 메모리를 동원해서 컴에게 길 찾기 재귀호출 노가다를 시키는 코딩의 영역이다. 빼곡한 미로의 내부에 있는 한 점에서 flood fill을 시켜 보면 이건 본질적으로 길 찾기와 다를 바 없다는 걸 알 수 있을 것이다.

글쎄, flood fill은 그래픽 에디터에서 사용자가 내리는 채우기 명령을 구현하는 형태로나 쓰이지, 직선과 곡선, 사각형과 원처럼 그림을 그리는 구성요소로서 프로그램이 내부적으로 사용할 일은.. 정말 아주 특수한 상황이 아니라면 없을 것이다. 도형 자체를 처음부터 내부가 채워진 형태로 그려야지, 도형의 윤곽만 그린 뒤에 도형 내부의 임의의 점을 따로 주고 채우는 건 몹시 비효율적이기 때문이다.

그래서 그래픽 라이브러리에는 다각형을 그리는 함수가 있다. 다각형의 경계선만 찍찍 그리는 것이야 LineTo만으로 얼마든지 할 수 있으므로, 이런 함수는 내부가 채워진 다각형을 그리는 것이 핵심이다. 그러니 이 함수는 다른 함수와 달리, 반드시 다각형의 꼭지점들이 담긴 배열을 전달받아야 한다.
옛날 도스 시절의 베이식은 타 언어들에 비해 그래픽 모드의 접근성이 좋았지만, 정작 다각형을 그리는 API는 없었다.

그럼 다각형을 채우는 기능은 어떤 방식으로 동작하는 걸까?
이걸 구현하기 위해서는 어떤 점이 다각형의 내부에 속하는지를 판단해야 한다. 더 나아가서 이 점에서 한쪽으로 scan line을 그어 나갈 때 어디까지가 동일하게 다각형의 내부 또는 외부인지를 판단해야 한다.

이걸 판단하는 방법은 의외로 간단하다. 그 점으로부터 아무 방향으로(예: x축 양의 방향) 한없이 직선을 그을 때, 그 선이 다각형을 구성하는 선분과 얼마나 몇 번이나 마주치는지를 판단하면 되며, 이걸 판단하는 방법도 크게 두 갈래로 나뉜다. 바로 (1) 홀짝 아니면 (2) 0여부이다.

홀짝법은 마주친 선분이 짝수 개이면 다각형의 외부이고, 홀수 개이면 내부라고 판단한다. 다시 말하지만 이 가상의 선은 정말 아무 방향으로나 그리면 된다. 다각형이 모든 방향으로 닫혀서 내부에 공간이 존재한다는 사실 자체가 이 판별법의 correctness를 보장해 준다.

0여부는.. 홀짝보다 더 절묘하다. 초기값이 0인 가중치라는 걸 두는데, 마주친 선분이 우리가 그은 가상의 선을 위에서 아래로 교차한다면 가중치에 1을 더한다. 그렇지 않고 아래에서 위로 교차한다면 1을 뺀다.
이렇게 해서 최종적으로 가중치가 양수든 음수든 0이 아닌 값이 나온 점은 다각형의 내부라고 간주하고, 0인 점은 외부라고 간주한다.

0이나 홀짝이나 그 말이 그 말 같은데.. 실제로 자기네 선분끼리 배배 꼬아서 교차하지 않는 일반적인, 평범한 오목/볼록다각형이라면 어느 판별법을 사용하든 결과에는 아무 차이가 없다.
하지만 당장 오각형 별표를 한붓그리기로 그린 궤적을 줘 보면 둘은 서로 차이를 보인다.

사용자 삽입 이미지
Windows API에서는 SetPolyFillMode라는 함수가 있어서 두 방식을 모두 사용해 볼 수 있다. 더 단순한 홀짝법이 ALTERNATE이고 기본값이다. 0여부는 WINDING... Windows 1.x 시절부터 존재해 온 오래된 고전 API여서 그런지, 매크로 상수의 앞에 접두사가 붙어 있지도 않다(PFM_* 같은?? ㅎㅎ).

오각형 별표에서 별의 중앙에 생긴 공간을 보면.. 그 옆으로 다각형 경계를 나타내는 선이 어느 방향이든 두 개가 존재한다(짝수). 그런데 이들은 방향이 둘 다 오르막 아니면 둘 다 내리막이며, 이 때문에 winding value는 nonzero가 된다. 그러니 ALTERNATE일 때는 이 공간이 비워지지만 WINDING일 때는 공간이 채워지는 것이다.

그 위의 더 복잡한 꼬인 사각형도 상황이 비슷하다. 잘 살펴보면 이 궤적도 홀수점이란 게 전혀 존재하지 않으며 한붓그리기가 가능하다.
그런데 WINDING일 때는 궤적이 꼬여서 생긴 내부의 사각형 공간 둘 중에서 좌측 하단 한 곳만 채워져 있다. 그 이유는 역시 저기서만 winding value가 nonzero이기 때문이다.

일반적으로 WINDING(0여부)이 판정하는 다각형 영역은 ALTERNATE(홀짝)의 상위 호환이다. ALTERNATE가 판정하는 영역을 100% 포함하면서 일부 영역을 추가적으로 더 판정한다는 뜻이다. 그렇다고 해서 모든 닫힌 영역을 한 치의 예외 없이 몽땅 내부라고 판정하는 건 아니다.

뭐.. 현실의 벡터 그래픽에서 이 따위 선끼리 교차하는 배배 꼬인 폴리곤을 생성하는 것은 애초부터 권장되지 않는 금지 사항이다. 가령, 속이 빈 오각별을 그리고 싶으면 저렇게 보이는 대로 삼각형 다섯 개로 풀어서 표현하라는 것이다. 윤곽선 폰트 등 벡터 그래픽 편집기들은 그렇게 폴리곤의 모양을 자동으로 수정해 주는 기능도 제공한다.
그러니 이렇게 fill mode의 차이점을 미주알고주알 관찰할 일이 현업에서는 거의 없을 것이고, 이런 건 그냥 학교에서 컴퓨터그래픽스 기초를 공부할 때 이런 방식도 있다는 걸 알기만 하고 넘어가면 될 것 같다.

하지만 그게 전부가 아니다. 다각형 채우기의 기능이 더 확장되면 다음 영역에도 도달하는데, 이때 fill mode의 차이점이 다시 드러나게 된다.

1. 여러 다각형을 한꺼번에 그리기
이건 내부에 구멍이 뚫린 다각형을 그릴 수 있다는 것에 의의가 있다. 구멍은 Polygon 함수를 연달아 호출하는 것으로는 표현할 수 없기 때문이다.

Windows에는 여러 다각형을 한꺼번에 그리는 PolyPolygon이라는 함수가 있다. 그런데 아까처럼 한 다각형에서 변들이 서로 교차하고 꼬였을 때뿐만 아니라, 변은 꼬이지 않았고 여러 다각형들의 영역이 서로 겹칠 때에도 fill mode의 차이는 유의미한 동작의 차이를 만들어 낸다.

사용자 삽입 이미지

위의 그림은.. 뭐 이론적으로는 한붓그리기가 가능하기 때문에 역시 꼬인 단일 다각형으로 궤적을 나타낼 수 있다. 하지만 앞서 예를 들었던 오각별이나 그 사각형 그림과 달리, 일부 점과 점이 겹치는 건 피할 수 없을 것이다. 무슨 말인가 하면, 저 궤적을 꼭지점 좌표의 배열로 기술했을 때, 4개의 선분과 만나는 점은 두 번 등장하는 부분이 생긴다는 것이다.

꼬인 단일 다각형이 아니라 영역이 일부 겹치는 사각형과 삼각형을 서로 떼어서 PolyPolygon으로 그린 경우.. ALTERNATE(홀짝)에서는 짝수 개의 다각형에 속하는 영역은 비우고, 홀수 개에 속하는 영역만 칠한다. 그러고 보니 동작이 뭔가 XOR스러워 보인다. 각 다각형들의 꼭지점이 기술된 방향은 어느 쪽이건 무관하다 (시계 or 반시계 방향)

그러나 WINDING(0여부)일 때는 그 특성상 방향이 같은 다각형들은 겹치더라도 영역을 모두 칠한다. 겉의 껍데기가 시계 방향이라면.. 그 안의 구멍은 반시계 방향으로.. 다른 방향으로 칠해져야 구멍이 비게 된다! 다시 말하자면, WINDING에서도 위의 그림의 왼쪽처럼 중앙이 비어진 그림을 그리고 싶다면 사각형과 삼각형의 좌표 방향이 서로 반대여야 한다.
꼬인 단일 다각형에서 fill mode의 차이점을 설명하는 프로그래밍 서적들이.. 다중 다각형까지 연계해서 동일 개념을 설명하는 경우는 내가 딱히 못 본 것 같다.

2. 직선뿐만 아니라 베지어 곡선까지 포함된 궤적의 내부를 채우기
위와 같은 구멍 감지에다가 곡선 지원까지 포함되면.. 이건 뭐 윤곽선 글꼴 래스터라이저가 번듯하게 완성된다. 물론 본격적인 폰트 엔진은 거기에다 작은 크기에 대비한 정교한 안티앨리어싱과 힌팅, 글꼴 글립 캐시, 더 나아가 복잡한 유니코드 문자 형태 분석까지 추가되는데 이것들 하나하나가 별개의 전문 영역일 정도이다.

FreeType 라이브러리는 그 중에서 제일 저수준인 그리기, 안티앨리어싱, 힌팅까지만 담당한다. 요즘 소프트웨어들은 글자 하나를 찍는 것도 겨우 8*16, 16*16 비트맵 글꼴 찍던 시절과는 차원이 다르게 더 복잡해져 있는 셈이다.
그건 그렇고.. Windows API에는 직선과 곡선이 포함된 도형을 한꺼번에 그리는 것은 윤곽선만으로 한정이다. PolyDraw라는 함수가 있다.

내부를 채우는 것은 한 함수로 지원되지 않으며, path라는 걸 써야 한다. 얘는 Windows GDI가 제공하는 강력한 벡터 그래픽 라이브러리로, 직선, 베지어 곡선, 원과 원호, 심지어 다른 트루타입 글꼴의 글립까지 몽땅 궤적으로 표현해서 한꺼번에 내부를 채울 수 있다. 구멍 처리도 물론 된다.
BeginPath (그리기) CloseFigure (그리기) EndPath 이런 식으로 말이다. 위의 1과 2를 모두 할 수 있다.

내 경험상 트루타입 폰트는 WINDING 방식으로 래스터라이징을 한다. 글꼴 글립을 그릴 때부터 제일 밖의 path는 시계 방향이고, 그 안의 구멍 윤곽을 기술하는 path는 반시계 방향이고, 구멍 안의 칠하는 영역은 또 시계 방향.. 이런 식으로 디자인을 해야 한다.

허나, 예전에 MS Office 2003 이하 버전에서 제공되던 클래식 WordArt는 이 원칙을 지키지 않고 트루타입 글꼴도 홀짝 ALTERNATE 방식으로.. 짝수 회 overlap 영역은 무조건 비웠던 것 같다.
그래서 composite glyph 형태로 표현되는 비완성형 한글 글꼴에서 글립이 겹칠 수 있는 복잡한 글자를 찍어 보면 저렇게 흰 부위 glitch가 발생하곤 했다. (아래 그림에서 ㅆ, ㅠ, ㅔ 부분 참고)

사용자 삽입 이미지

Office 2007 이상부터 제공되는 WordArt는 이 문제가 해결됐다. 그리고 아래아한글의 글맵시도 0여부 WINDING 방식으로 맞게 색칠을 하기 때문에 glitch가 발생하지 않는다.

그러고 보니.. MS Office는 지난 2007때부터 그래픽 엔진이 크게 바뀌었다. 워드아트의 글자 장식 기능도 리뉴얼 됐고 PowerPoint 같은 데서도 직통으로 사용 가능해졌는데, 정작 본가인 Word에서는 2003 이하의 클래식 워드아트가 제공됐다. 다음 버전인 Office 2010부터 Word에서도 동일하게 리뉴얼된 워드아트가 제공되기 시작했다.

Posted by 사무엘

2021/05/12 08:35 2021/05/12 08:35
, , ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1885

« Previous : 1 : 2 : 3 : 4 : 5 : ... 29 : Next »

블로그 이미지

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

- 사무엘

Archives

Authors

  1. 사무엘

Calendar

«   2022/01   »
            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:
1732571
Today:
376
Yesterday:
614