<날개셋> 한글 입력기는 잘 알다시피 16년 전에 개발된 1.0과 지금의 8.6이 요구하는 운영체제 사양(그리고 사실상 하드웨어 사양도)에 차이가 전혀 없는 좀 사기급의 프로그램이다. 32비트 에디션은 Windows 95/NT4 이상에서도 돌아간다. Win95쯤은 안드로이드 스마트폰 내부에서 가상 머신으로도 돌리는 지경이 됐는데도 말이다. 뭐, 내 프로그램은 게임처럼 딱히 최신 사양빨을 타는 분야의 프로그램이 아니며, 한글이 무슨 한자처럼 처리하는 데 메모리가 엄청 많이 든다거나 아랍· 태국 문자처럼 내부 메커니즘이 복잡한 것도 아니기 때문이다.

Windows는 API 함수들이 유니코드를 표방하는 2바이트 문자열을 취급하는 버전(W 함수)과 비유니코드 일명 'ANSI 인코딩'을 표방하는 1바이트 문자열을 취급하는 버전(A 함수)으로 나뉘어 있다. 맥이나 리눅스 같은 타 운영체제에서는 찾을 수 없는 독특한 형태이다. 물론 문자 집합이라는 건 굳이 인코딩 단위에 얽매여 있지는 않으니, 1바이트라는 단위는 그대로 놔 두고 UTF-8만 사용해도 유니코드 지원은 가능했다. 하지만 Windows는 호환성 때문인지 문자 집합과 함께 인코딩까지 완전히 바꿔 버리는 방식을 채택했다. 그래서 wchar_t도 4가 아닌 2바이트이며, UTF-16을 유난히 좋아한다.

Windows NT는 W가 기본이고 A도 호환성 차원에서 지원하지만 Windows 9x는 메모리 부족 문제로 인해 A만 지원하고 W는 아예 제공하지 않았다. 그러니 일반적으로는 Windows 9x를 지원하려다 보면 유니코드를 지원할 수 없어서 깨진 문자 크리 때문에 프로그램의 국제화에 애로사항이 꽃폈으며, 반대로 W 함수만 사용하면 가정에 NT 계열보다 더 많이 보급돼 있던 9x 계열 운영체제를 지원할 수 없었다.

이 딜레마를 해소하는 방법은 일단 프로그램은 W 함수 기반으로 개발한 뒤, 9x에서는 특별히 W 함수 진입로에서 함수 argument를 변환하고 나서 A 함수를 호출하는 일종의 훅/thunk DLL을 구동하는 것이었다. <날개셋> 한글 입력기는 이 테크닉을 사용한다.
훅 DLL의 소스 코드는 동작 방식의 특성상, import table상의 함수 이름 문자열과 거기에 대응하는 훅킹 함수 포인터를 명시한 테이블을 갖고 있다. 또한 기존 Windows API 함수와 프로토타입이 동일하지만, 하는 일에는 살짝 차이가 있는 함수도 즐겨 사용한다.
이런 걸 구현할 때는 C/C++ 언어에 존재하는 다음과 같은 기능들이 유용하게 쓰였다.

1.
함수 훅킹 테이블을 만들 때 #define과 더불어 #(문자열화)와 ##(토큰 연결)라는 전처리기 연산자를 즐겨 썼다.
_FUNC(SetWindowTextW) 하나로 { "SetWindowTextW", (FARPROC)My_SetWindowTextW } 요걸 표현할 수 있으니 전처리기 연산자를 써서 매크로를 정의하는 게 완전 딱이지 않은가?
C언어는 전처리기의 단항 연산자는 # 1개로, 이항 연산자는 # 2개로 표현해서 나름 직관성을 추구했다. 그리고 안 그래도 전처리기 연산자는 C/C++의 고유한 연산자와는 섞여서는 안 되는데 굳이 # 말고 다른 기호를 끌어다 쓰지 않아서 형태 구분이 잘 되게 했다.

그런데 여기서 문제가 하나 있다.
문자열화 연산자는 매크로 전개를 한 놈을 문자열로 바꾸는지, 아니면 언제나 주어진 인자를 문자 그대로 문자열로 바꾸는지를 본인은 엄밀하게 생각을 하지 않고 지냈다. #define ToString(a) #a라고 정의해 주면, ToString(SetWindowText)은 "SetWindowText"로 바뀌는지, 혹은 "SetWindowTextW"나 "SetWindowTextA"로 바뀌는지 궁금했다.

이에 대한 정답을 먼저 말하자면, # 연산자는 그 자체로는 매크로 전개를 전혀 하지 않는다. 그렇기 때문에 저 문제의 정답은 "SetWindowText"이다.
만약 W/A가 붙은 놈을 얻고 싶으면 매크로를 한 단계 더 거쳐 줘야 한다. #define ToString_Expanded(a) ToString(a)를 선언한 뒤, ToString_Expanded(SetWindowText)라고 명령을 내리면 그제서야 "SetWindowTextW"(또는 A)가 얻어진다.

물론 딱히 매크로가 없는 인자를 넘기면 ToString_Expanded는 그냥 ToString과 동일한 결과가 나온다. 이런 차이가 있다는 걸 근래에 알게 됐다.

C/C++ 코드에는 검증과 디버깅을 위해 assert 부류의 매크로를 볼 수 있는데, C 언어 표준 매크로 상수와 연산자들은 상당수가 얘를 구현하기 위해 만들어진 게 아닐까 싶을 정도이다.
상식적으로 생각해 봐도, 실행 파일 내부에 "result > 0이라는 수식의 assertion이 실패했습니다. 아무개.cpp n째 줄입니다." 정도의 검증 명령이 삽입되려면 딱 봐도 __FILE__, __LINE__이 들어가야 했을 것이고 검증 대상 수식은 # 연산자에 의해 문자열로 바뀌었을 거라는 걸 알 수 있다.

파일명과 줄번호는 바이너리 형태의 디버그 심벌에도 포함되긴 하지만, result > 0처럼 대놓고 코드를 구성하는 문자열은 # 연산자 없이는 답이 없다. 이런 사기급의 전처리 기능은 C/C++ 외의 다른 언어에서는 유례를 거의 찾을 수 없지 싶다.

2.
또한 decltype이라는 연산자가 있는 줄을 난생 처음 알았다. 연산자이긴 하지만 되돌리는 게 어떤 값이 아니라 타입 그 자체이다. typeid처럼 RTTI와 관계 있는 기능도 아니며, 컴파일 타임 때 결정되는 고정 타입이다. 그래서

auto x=3.4f;
decltype(3.4f) x = 3.4f;
float x=3.4f;

는 의미가 모두 동일하다. auto와도 어떤 관계인지 바로 알 수 있을 것이다.
sizeof는 값 또는 타입을 모두 받아들여서 값(크기. 고정된 정수)을 되돌리는 반면, decltype은 값을 받아서 타입을 되돌린다는 차이가 있다. 또한 sizeof와 decltype 모두 그 값을 실제로 실행(evaluate)하지는 않는다.

auto는 타입과 동시에 변수값 초기화를 할 때 번거로운 타이핑을 줄여 준다. decltype은 값을 동반하지 않고 타입 자체만을 명시할 때 매우 유용하다. 템플릿 인자를 명시하거나 형변환을 할 때, 길고 복잡한 namespace나 함수 포인터의 프로토타입을 쓰는 수고를 덜어 준다. typedef를 하자니 번거로운 이름을 떠올려야 하는데.. 그럴 필요도 없어진다. 가령,

CAPIPtr<int (*)(int flags, WPARAM wParam)> pfnAbout(hNgsLib, "ngsAbout");

라고 쓸 것을

CAPIPtr<decltype(&::ngsAbout)> pfnAbout(hNgsLib, "ngsAbout");

로 간편하게 대체 가능하다. 함수의 이름만으로 그 함수의 포인터의 프로토타입을 간단히 명시할 수 있으니 얼마나 편리한가? API 훅킹 라이브러리를 만들 때도 이런 문법이 매우 유용할 수밖에 없다. 훅킹 대상인 Wndows API들이야 헤더 파일에 프로토타입이 다 선언돼 있으므로 그걸 decltype의 피연산자로 주면 되기 때문이다..

또한, 과거에는 클래스에서 함수 포인터 형변환 연산자 함수를 선언할 때는 C++ 문법의 한계 때문에 반드시 그 함수 프로토타입을 typedef부터 해야 했다. 하지만 decltype은 여기서도 그런 번거로움을 응당 없애 준다. 아래 코드를 보면 차이를 알 수 있다.

class CMyTable {
    static int _Func();
public:
    //과거
    typedef int (*PFN)();
    operator PFN() { return _Func; }

    //현재
    operator decltype(&CMyTable::_Func)() { return _Func; }
};

decltype 연산자는 Visual C++ 2010부터 지원됐다. 함수 포인터에다가 람다를 바로 대입하는 건 2010은 아니고 2012부터 지원되기 시작했다. 물론 캡처가 없는 람다에 한해서. 람다는 함수 포인터보다 더 추상적인 놈이기 때문에 calling convention은 컴파일러가 알아서 다 해결해 준다.

C++은 잘 알다시피 A *B와 A B(), (A)+B 같은 문장이 A와 B의 정체가 무엇인지에 따라(타입? 값?) 파싱 방식이 완전히 달라진다. 템플릿이 추가된 뒤부터는 <와 >조차도 이항 연산자 vs 타입 명시용의 여닫는 괄호처럼 해석이 달라질 수 있게 되었고, 21세기에 와서는 템플릿 인자를 이중으로 닫을 때 굳이 > > 안 하고 >>로 써도 되게 문법이 바뀌었다. 저게 제대로 돌아가려면 값과 타입의 구분이 더욱 절실히 필요하다.

이런 특성 때문에 템플릿의 컴파일 편의를 위해 typename이라는 힌트 키워드가 도입되었으며, auto와 decltype도 동일한 용도는 아니지만 비슷한 맥락에서 type과 관련된 기술을 돕기 위해 등장한 게 아닌가 싶다.

3.
유니코드 API 훅킹 DLL을 만든다면, SetWindowTextW라면 WCHAR 문자열 형태로 전달된 인자를 char 문자열로 바꾼 뒤 A 함수에다 전달하고, GetWindowTextW라면 먼저 내부적으로 char 버퍼를 준비해서 A 함수를 호출한 뒤, 그걸 WCHAR로 변환해서 사용자에게 되돌리는 형태로 전달한다.

물론 용례가 무궁무진한 메시지를 주고받는 함수라든가 GetOpenFileName처럼 입· 출력 겸용 복잡한 구조체를 운용하는 함수, SystemParametersInfo처럼 PVOID 하나에 온갖 종류의 데이터를 주고받는 함수라면 훅킹 함수를 만들기가 아주 까다로워진다. 하지만 그 함수가 제공하는 모든 기능에다 일일이 변환 기능을 넣을 필요는 없다. 다양한 플래그와 기능들 중에서 내 프로그램이 실제로 사용하는 것에 대해서만 변환을 하면 된다.

그런데 훅킹 함수 중에는 의외로 아무 변환 없이 인자를 그대로 A 함수로 넘기기만 하고 리턴값도 아무 보정 없이 그대로 되돌리는 것도 있다. 훅킹 함수 단계에서 딱히 할 게 없다고 말이다.

그 대표적인 예로는 리소스를 리소스 ID가 아니라 메모리 포인터 차원에서 저수준으로 읽어들이는 DialogBoxIndirect와 LoadMenuIndirect가 있다.
얘들이 인자로 받아들이는 DLGTEMPLATE와 MENUTEMPLATE 구조체는 내부에 PCTSTR 같은 게 없으며, 애초에 A/W 구분이 없다. 왜냐하면 저 구조체는 메모리가 아니라 디스크에 저장되는 리소스 데이터 포맷을 기술하기 때문이다. Windows 9x용이든 NT계열용이든 실행 파일이야 서로 완전히 동일한 포맷이며 리소스들은 모두 유니코드 형태로 저장된다. 그러니 인자가 동일한데 저 두 함수도 원론적으로는 굳이 W/A 구분을 할 필요가 없다.

그럼에도 불구하고 이런 함수에도 굳이 A/W 구분이 존재하는 이유는 얘들이 내부적으로 대화상자와 메뉴 윈도우를 생성할 때 사용하는 CreateWindowEx 함수가 A/W 구분이 존재하며, 9x에서는 W 버전이 존재하지 않기 때문이다. 비록 리소스 데이터 상으로는 원래의 언어 텍스트가 들어있지만, 운영체제가 관리하는 윈도우의 텍스트 버퍼는 ANSI 기반이니 그걸 운영체제의 표준 기능만으로 제대로 표시할 방법도 없다.

그렇다면.. Windows 9x에서는 DialogBoxIndirectW나 LoadMenuIndirectW가 호출 됐을 때,
SetLastError(ERROR_CALL_NOT_IMPLEMENTED); return FALSE / NULL; 을 하지 말고..
return DialogBoxIndirectA( ... ) / LoadMenuIndirectA( ... ); 를 해도 되지 않았나 하는 의문이 남는다. 직통으로 A로 포워딩하는 거 말이다.
그럼 9x에서는 현 ANSI 인코딩으로 표현되지 않는 문자들은 비록 깨져서 출력되겠지만 최소한 메뉴나 대화상자가 뜨고 동작은 하지 않겠는가?

하지만 그건 별 의미가 없다고 생각돼서 조치를 취하지 않은 것 같다. GetOpenFileNameW, CreateFileW, CreateWindowExW, GetMessageW, SendMessageW 등등.. Windows 프로그램의 근간을 이루는 함수들이 유니코드 버전은 몽땅 동작하지 않는데 저런 것만 살려 놔서 뭘 하겠나? Windows 9x에서는 최소한의 유니코드 문자를 찍는 GDI 함수만이 제 기능을 하며, MessageBoxW는 인자들을 char 형태로 변환해서 예외적으로 지원해 주고 있다. 최소한의 에러 메시지를 찍고 종료하는 기능만은 유니코드 API 직통으로 동작하게 말이다. =_=;;

Posted by 사무엘

2017/01/02 08:25 2017/01/02 08:25
, , ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1312

오늘날 Windows에서 실행되는 모든 프로그램들.. exe, dll 따위는 잘 알다시피 portable executable이라는 형식으로 만들어져 있다. 하지만 이 파일 포맷도.. 처음 만들어지던 당시에 여전히 컴퓨터에서 현역이던 도스와 최소한의 호환성을 유지할 필요가 있었기 때문에, 맨 앞에 MZ로 시작하는 16비트 도스 헤더를 여전히 갖추고 있다.

호환성이란 게 딴 게 아니고, 도스에서 Windows용 프로그램이 실행됐을 때 컴퓨터가 다운되는 게 아니라 "이 프로그램은 도스용이 아닙니다" 같은 짤막한 에러 메시지라도 뜨게 하는 것 말이다.

옛날에 Win32s가 제대로 설치되지 않은 상태에서 32비트 프로그램을 Windows 3.1에서 실행했더니.. "상위 버전에서 실행해 주십시오 / Win32s를 다시 설치해 주십시오" 이런 말이 메시지 박스 형태로 뜨는 게 아니라 황당하게 This program cannot be run in DOS mode라고.. 지금 시스템이 아예 Windows가 아닌 듯한 자비심 없는 메시지가 도스창에 떴다. 20여 년 전에 그 인상이 무척 강렬했었다. 요즘은 32비트 OS에서 64비트 exe의 실행을 시도해도 에러 메시지가 그 정도로 막나가는 형태는 아니다.

Windows용 프로그램들은 빌드할 때 그렇게 도스에서 잘못 실행됐을 때를 대비해 짤막하게 대신 실행해 줄 도스용 일명 "stub" 프로그램을 링크 옵션으로 지정할 수 있다. 이름하여 /STUB. 이걸 지정하지 않으면 아까 같은 저런 짤막한 에러 메시지 한 줄만 찍는 기본 stub 프로그램이 들어간다.
16비트 시절에 Visual C++ 1.5x를 보면 그 예제 stub 프로그램 자체가 winstub.exe라고 있었다. 하지만 그 이후부터는 디폴트 stub 프로그램은 그냥 링커 내부에 내장되어 버렸는지 그런 게 따로 있지는 않다.

프로그램을 특수하게 빌드하면 그런 stub을 아예 전혀 집어넣지 않는 것도 가능하다. 맨 앞에 MZ, 그리고 0x3C 오프셋에 PE 헤더가 있는 지점만 들어있으면 되고 나머지 칸은 몽땅 0으로 채움. 심지어 PE 헤더가 0x3C 오프셋보다도 전에, 도스 EXE 헤더가 있어야 할 지점에서 바로 시작하는 것도 가능하다.

미래에 마소에서 빌드하는 EXE/DLL들은 번거로운 This program cannot be ... 메시지를 떼어내고 이렇게 만들어져 나올지도 모른다. 물론 이런 프로그램은 Windows 환경에서 실행하는 건 문제 없지만 만에 하나 어느 레트로 변태 덕후가 그걸 굳이 도스에서 실행해 보면 컴퓨터가 어찌 되는지 책임 못 지는 상태가 될 것이다.

반대로 기본 stub 대신에 꽤 규모 있는 16비트 프로그램을 집어넣어서 동일 EXE가 도스에서도 그럭저럭 기능을 하고 Windows에서도 GUI를 띄우며 제대로 실행되는 프로그램을 만든 경우가 있다. Windows 9x 시절엔 레지스트리 편집기가 그러했다. 이건 Windows에서 보기 드문 하이브리드 universal binary 형태의 프로그램인 것 같다.
16비트 프로그램이 자기 자신 EXE를 열어서 PE 헤더를 파싱해서 리소스 같은 걸 읽어들이는 코드가 같이 빌드되었다면.. 도스 파트가 나중에 합쳐진 Windows 파트와 더불어 한 리소스를 공유하는 형태로 실행될 테니 이 역시 무척 흥미로울 것이다.

이 시점에서 문득 궁금해졌다.
링커가 얹어 주는 기본 stub 프로그램은 명령어가 겨우 몇 바이트밖에 되지 않는다. 얘들은 무슨 의미를 갖고 있는지, 혹시 옛날 16비트 NE 시대와 지금의 PE 시대에 stub 프로그램에 차이가 있는지..?
그래서 오랜만에 도스 API와 8086 어셈블리 명령어 레퍼런스까지 찾아서 stub 프로그램을 분석해 봤다.

stub 프로그램의 코드는 이게 전부이다.

(1) 0E        PUSH CS
(2) 1F        POP DS
(3) BA 0E 00  MOV DX,000E
(4) B4 09     MOV AH,09
(5) CD 21     INT 21
(6) B8 01 4C  MOV AX,4C01
(7) CD 21     INT 21
"문자열"


(1), (2) 맨 앞의 PUSH와 POP은 데이터 세그먼트를 코드 세그먼트의 값과 맞추는(DS=CS) 일종의 초기화이다. 스택에다가 CS 값을 넣은 뒤 그걸 DS로 도로 가져오는 거니까.
지금 이 프로그램은 화면에다 찍을 에러 메시지도 기계어 코드와 정확하게 같은 영역에 있으므로 저건 수긍이 가는 조치이다.

(3) 그 다음으로 DX 레지스터에다가 16진수로 0xE, 즉 14를 기록한다. 저 stub 프로그램은 길이가 정확하게 14바이트이다. 이 값은 프로그램의 시작 지점을 기준(0)으로 해서 그로부터 14바이트 뒤에 있는 문자열을 가리킨다.

(4) AX 레지스터의 high byte에다가 9를 기록한다.

(5) 이렇게 기록된 AX와 DX 레지스터 값을 토대로 0x21 인터럽트를 날려서 도스 API를 호출한다. 도스 API 중 9는 DX가 가리키는 주소에 있는 문자열을 화면, 정확히는 표준 출력에다가 찍는 기능을 수행한다.
그런데 굉장히 기괴한 점이 있는데.. 얘가 받아들이는 문자열은 null-terminated가 아니라 $-terminated여야 한다!

믿어지지 않으면 아무 Windows용 EXE/DLL이나 헥사 에디터로 열어서 앞부분의 에러 메시지 텍스트가 무슨 문자로 끝나는지를 확인해 보시기 바란다.
왜 그렇게 설계되었는지 모르겠다. 파일이나 디렉터리 이름을 받는 도스 API들은 당연히 null-terminated 문자열인데 말이다.

(6) 그 다음, AX 레지스터에다가 0x4C (high)와 0x1 (low)을 기록하고..

(7) 또 도스 API를 호출한다. 0x4C는 프로그램을 종료하는 기능을 하며, 종료와 동시에 low byte에 있는 1이라는 값을 에러코드로 되돌린다. 정상 종료는 0인데 1은 뭔가 오류와 함께 종료되었음을 나타낸다.
사실, 도스 API 레퍼런스를 보면 AH 값으로 0도 프로그램을 종료시키는 역할을 하는 듯하다(도스 1.0때부터 최초). 하지만 모종의 이유로 인해 그건 오늘날은 사용이 별로 권장되지 않으며 0x4C가 원칙이라 한다(도스 2.0에서부터 추가됨).

이렇게 분석 끝. 정말 간결 단순명료하다.
참고로 도스 EXE에서 헤더를 제끼고 기계어 코드가 시작되는 부분은 0x8~0x9 오프셋에 있는 unsigned short값에다가 16을 곱한 오프셋부터이다. 가령, 거기에 04 00 이렇게 적혀 있으면 0x40 오프셋부터 디스어셈블링을 해 나가면 된다. EXE는 헤더에 고정 길이 구조체뿐만 아니라 가변 길이인 '재배치 섹션'이 나오고 그 뒤부터 코드가 시작되기 때문이다.

그럼 과거 16비트 Windows에서 쓰이던 stub은 어떻게 돼 있었을까?
거의 차이가 없긴 한데, 문자열이 들어있는 위치와 얘의 주소를 전하는 방법이 달랐다.

(1) E8 53 00  CALL 0056
"문자열"
20 20 20 20 .. padding 후
(2) 5A        POP DX
(3) 0E        PUSH CS
(4) 1F        POP DS
(5) B4 09     MOV AH,09
(6) CD 21     INT 21
(7) B8 01 4C  MOV AX,4C01
(8) CD 21     INT 21


(1) 맨 먼저 JMP도 아니고 웬 CALL 인스트럭션이 나온다. 기계어로 표기할 때는 인자값이 0x53이어서 3바이트짜리 자기 자신 인스트럭션 이후에 0x53바이트 뒤로 가라는 뜻이 되는데, 영단어로 바꿔서 표기할 때는 자기 자신 원래 위치 기준으로 0x56바이트 뒤가 된다. 이 위치는 그냥 바로 다음 (2) 명령이 있는 곳과 같다.

(2) 함수 호출을 했는데 RET를 하는 게 아니라 스택을 pop하여 DX 레지스터에다 가져온다. 그렇다. 아까 그 call에 대한 복귀 주소에 문자열이 담겨 있으니, 아까 같은 하드코딩이 아닌 요런 방식으로 문자열 주소를 얹었다.

(3) (4) 이제부터는 아까처럼 DS = CS 해 주고,

(5)~(8) 아까와 동일. 문자열을 찍은 뒤 프로그램을 종료한다.

이런 초간단 초미니 프로그램은 exe가 아니라 com 형태로도 만들지 말라는 법이 없어 보인다. com은 그 어떤 헤더나 시그니처도 없이 첫 바이트부터 바로 기계어 코드와 데이터를 써 주면 되는.. 정말 원시적이기 그지없는 바이너리 덤프일 뿐이기 때문이다. 빌드 날짜, 버전, 요구하는 아키텍처나 운영체제 등등 그 어떤 부가정보도 존재하지 않는다.

요즘 프로그래밍 언어들이 기본 제공하는 런타임들의 오버헤드가 너무 크다 보니, 이에 대항하여 세상에서 제일 작은 "Hello world" 프로그램 이런 것에 집착하는 덕후들이 있다. Windows 프로그램의 경우 프로그램을 특수하게 빌드하여 CRT 라이브러리는 당연히 떼어내고, 코드와 데이터도 한 섹션에다 우려넣고, 거기에다 후처리까지 해서 단 몇백 바이트만으로 MessageBoxA(NULL, NULL, "Hello, world!", 0) 하나만 호출하는 프로그램을 만든 예가 있다.

그러나 이런 것들도 com 앞에서는 몽땅 버로우 타야 한다. 얘는 아예 파일 포맷 자체가 없으니까. 이 이상 더 줄일 수가 없다. com 형태로 만든 Hello world 프로그램은 겨우 20몇 바이트가 전부이다.
무슨 명령어를 내렸는지 기억은 안 나지만 컴퓨터를 재시작시키는 com 파일이 있었는데, 얘는 크기가 겨우 2바이트에 불과했다.

(1) BA 0C 01  MOV DX,010C
(2) B4 09     MOV AH,09
(3) CD 21     INT 21
(4) B8 01 4C  MOV AX,4C01
(5) CD 21     INT 21
그 뒤에 "Hello, world!$" 같은 문자열. 따옴표는 제외하고.


com은 exe처럼 코드/데이터 세그먼트 DS=CS 따윈 전혀 신경 쓸 필요 없이, 바로 본론부터 들어가면 된다. 그 대신 com은 16비트 단일 세그먼트 안에서 코드와 데이터 크기 한계가 모두 64K라는 치명적인 한계를 갖는다. 메모리 모델로 치면 그 이름도 유명한 tiny 모델 되겠다. 애초에 exe가 16비트 CPU에서 저 한계를 극복하고, 또 멀티태스킹에 대비하여 재배치도 가능하게 하려고 만들어진 포맷이기도 하다.

아, 아주 중요한 사항이 있다. com에서는 첫 256바이트, 즉 0x100 미만의 메모리 주소는 시스템용으로 예약되어 있어서 사용할 수 없다. 내 코드와 데이터는 0x100부터 시작한다. 그렇기 때문에 저 프로그램의 코드 크기는 12바이트이고, 문자열은 0xC 오프셋부터 시작하긴 하는데 거기에다가 0x100을 더해서 DX에다가는 0x10C를 써 줘야 한다.

Windows PE에다 비유하자면 0x100이 고정된 base address값인 셈이다. 그리고 DX의 값은 그냥 VA이지 RVA가 아니다.
과거에 굴러다니던 exe/com 상호 변환 유틸리티들이 하던 주된 작업 중 하나도 이런 오프셋 재계산이었다. 그리고 com에서 exe라면 모를까 더 넓은 곳에서 좁은 곳으로 맞추는 exe -> com은 아무 exe에 대해서나 가능한 게 물론 아니었다. (단일 세그먼트 안에서만 놀아야..) 과거 도스에 exe2bin이라는 외부 명령어가 있었는데 걔가 사실상 exe2com의 역할을 했다.

아무튼, 저 바이너리 코드와 문자열을 헥사 에디터를 이용해서 입력한 뒤, 파일을 hello.com이라고 명명하여 저장한다. 이걸 도스박스 같은 가상화 프로그램에서 도스 부팅하여 실행하면 신기하게도 Hello, world!가 출력될 것이다.
고급 언어를 사용하지 않고 컴파일러 나부랭이도 전혀 동원하지 않고 가장 원초적인 방법으로 나름 네이티브 실행 파일을 만든 것이다. 사용 가능한 코드와 데이터 용량이 심각하게 작다는 것과, 요즘 64비트 Windows에서는 직통으로 실행조차 할 수 없다는 게 문제이긴 하지만. (네이티브 코드라는 의미가 없다~!)

이런 식으로 컴퓨터에 간단히 명령을 내리고 램 상주 프로그램이나 바이러스 같은 것도 만들기 위해 옛날에는 debug.com이라는 도스 유틸리티가 요긴하게 쓰였다. 간단한 어셈블러/디스어셈블러 겸 헥사 에디터로서 가성비가 뛰어났기 때문이다. edlin 에디터의 바이너리 버전인 것 같다.

오늘날 어셈블리어라는 건 극소수 드라이버/컴파일러 개발자 내지 악성 코드· 보안· 역공학 같은 걸 연구하는 사람들이나 들여다보는 어려운 물건으로 전락한 지 오래다. 하지만 이것도 알면 디버깅이나 코드 분석에 굉장한 도움이 될 듯하다.
디스어셈블리 자체는 주어진 규칙대로 바이트 시퀀스를 몇 바이트씩 떼어서 명령어로 분해해 주는 비교적 간단한 작업일 뿐이다. 파서(parser)가 아니라 스캐너(scanner) 수준의 작업만 하면 된다.

하지만 디스어셈블리가 골치 아프고 귀찮은 이유는 코드의 첫 실행 지점을 정확하게 잡아서 분해를 시작해야 하며, 그래도 어느 게 코드이고 어느 게 데이터인지가 프로그램 실행 문맥에 의해 시시각각 달라지고 무진장 헷갈리기 때문이다. 데이터는 백 날 디스어셈블링 해 봤자 아무 의미가 없고, 오히려 코드의 분석에 방해만 된다. 이런 역공학을 어렵게 하기 위해서 디스어셈블러를 엿먹이는 테크닉도 보안 분야에는 발달해 있다.
하긴, 코드와 데이터가 그렇게 경계 구분 없이 자유자재로 변할 수 있는 게 "폰 노이만 모델 기반의 튜링 기계"가 누리는 극한의 자유이긴 하다.

Posted by 사무엘

2016/12/17 08:34 2016/12/17 08:34
, , ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1306

Visual Studio 201x, MSDN 이야기

1. 도움말 시스템

Visual C++ (지금의 Visual Studio)이 개발된 이래로 IDE가 제공하는 도움말 및 API 레퍼런스 시스템은 다음과 같이 변모해 왔다.

  • 1세대 1.x~2.x: 그냥 평범한 WinHelp 기반 hlp
  • 2세대 4.x, 5: 리치 텍스트(RTF) 기반의 자체적인 도움말 시스템이 IDE 내부에 통합되어 제공. 같은 컴퓨터 사양에서 RTF 기반 엔진은 이후에 등장한 IE+HTML 기반 엔진보다 텍스트 표시와 스크롤 속도가 훨씬 더 빨랐다.
  • 3세대 6: RTF 대신 HTML 기반의 외부 도움말로 갈아탐. MSDN이라는 명칭 정립.
  • 4세대 200x (.NET ~ 2008): HTML 기반이지만 CHM 말고 다른 컨테이너를 사용하는 Document Explorer. 도움말을 IDE 내부에 구동할 수도 있고 외부에 구동할 수도 있음. 융통성이 생겼다.
  • 5세대 201x: Help Viewer 도입. 버전도 1.0부터 리셋 재시작.

하긴, 비주얼 C++의 프로젝트 파일 포맷도 이와 거의 비슷한 단계를 거치며 바뀌어 왔다. vcp(1세대), mdp(2세대), 3세대(dsw/dsp), 4세대(sln/vcproj), 5세대(sln/vcxproj)의 순. 단, 비주얼 C++ 5는 2세대 도움말 기반이지만 프로젝트 파일은 예외적으로 3세대 6.0과 동일한 dsw/dsp기반이다.

본인은 지금의 일명 5세대 도움말 시스템을 별로 좋아하지 않았다.
일단 5세대 시대를 처음으로 시작한 Visual Studio 2010은 후대 버전은 안 그런데 얘만 유독 무겁고 시동 속도가 무척 느렸다.
그리고 같이 내장된 Help Viewer 1은 '색인' 탭으로 가면 심한 랙이 걸려서 몹시 불편했다. 재래식 4세대 도움말에 비해 기능 차이는 별로 없는데 느리고 무거워지기만 해서 학을 뗐다.

그나마 2012부터는 IDE가 가벼워지고 도움말의 랙도 없어진 듯하다. 그 대신 2010에는 없던 다른 사이드 이펙트가 생겼다.
첫 구동되어서 Help Viewer 스플래시 화면이 뜰 때 마우스 포인터가 움직이지 않을 정도로 컴퓨터가 잠시 stun(멈칫)된다. 구닥다리 내 컴에서만 그런 줄 알았는데 회사의 초고성능 최신식 컴퓨터에서도 동일한 현상이 발생한다.

먼 옛날의 불안정한 유리몸이던 Windows 9x도 아니고 엄연히 7~10급의 최신 OS에서 하드웨어를 도대체 어떻게 건드렸길래 마우스 포인터조차 움직이지 않는 상태가 되나?

잘 알다시피 요즘 Visual Studio IDE는 평범한 Win32 API로 GUI를 만드는 게 아니라 닷넷 + Windows Presentation Foundation 기반으로 특수하게 하드웨어 가속도 받으면서 아주 뽀대나는 방식으로 그래픽을 출력한다.
글자를 찍는 계층도 뭐가 바뀌었는지, 텍스트 에디터에는 트루타입 글꼴만 지정되지 FixedSys 같은 비트맵 글꼴을 사용할 수 없게 바뀌었다. '굴림'은 트루타입이니 사용은 가능하지만 embedded 비트맵이 대신 찍히는 크기에서도 ClearType이 적용되어 색깔이 살짝 바뀌어 찍히며, 같은 글자끼리도 폭이 좀 들쭉날쭉하게 찍힌다.

이렇듯, 재래식 GDI API로 글자를 찍었다면 절대로 나타나지 않을 사이드 이펙트들이 좀 보인다.
그런 특수한 그래픽/GUI를 사용하기 위해서 마치 게임 실행 전처럼 하드웨어 초기화가 일어나고, 그때 마우스 포인터가 살짝 멈추는가 하는 별별 생각이 든다.

2. GDI API 설명은 어디에?

요즘(2010년대) Visual Studio의 MSDN 레퍼런스엔 왜 GDI API들이 누락돼 있는지 궁금하다. BitBlt, SetPixel 같은 것들. desktop app development에 해당하는 몇백 MB짜리 도움말을 분명히 설치했는데도 로컬 도움말에 포함되지 않아서 저것들 설명은 느린 인터넷 외부 링크로 대체된다.

VS 2010에서는 GDI 관련 API들이 색인으로는 접근 가능하지만 목차에서는 존재하지 않아서 접근불가였다. 그리고 MFC 레퍼런스도 단순한 API wrapper의 경우(가령 CDC::MoveTo) See also 란에 자신의 원래 API 함수에 대한 링크(가령 MoveToEx)가 있는데, 요건 내부 링크가 아니라 인터넷 MSDN 사이트의 외부 링크로 바뀌어 있었다.

즉, 그때부터 GDI API의 설명은 제외될 준비를 하고 있었던 듯하다. 그 뒤로 2012인가 2013 이후부터는 그것들이 색인에서도 제외되고 완전히 없어졌다. 2015도 마찬가지인 걸 보니 GDI의 누락은 단순 지엽적인 실수가 아니라 의도적인 계획인 것으로 보인다.

kernel32, user32, advapi32 등 나머지 API들은 다 남아 있는데 왜 GDI만 없앴는지, 얘는 정말로 완전히 deprecate 시킬 작정인지 알 길이 없다. Windows NT 3.1 초창기 때부터 20년이 넘게 운영체제의 중추를 구성해 온 놈인데 그걸 호락호락 없애는 게 가능할까? 게다가 BeginPaint, GetDC처럼 GDI를 다루지만 실제로는 USER 계층에 속해 있는 기초 필수 API조차 언급이 누락된 것은 좀 문제라고 여겨진다.

이런 것 때문에 본인은 Visual Studio는 옛날 Document Explorer 기반이던 200x도 여전히 한 카피 설치해 놓고 지낸다.
옛날에는 또 Visual C++ 2005의 MSDN만 TSF API 레퍼런스도 없고 뭔가 나사가 빠진 듯이 컨텐츠가 왕창 부실해서 내가 놀랐던 기억이 있다. 2003이나 2008은 안 그랬고 걔만 좀 이상했었다.

3. 프로젝트에 소속되지 않은 소스 코드도 심층 분석

Visual C++. 2013인지 2015인지 언제부턴가 프로젝트에 등재되지 않은 임의의 C/C++ 소스 코드를 열었을 때도 이 파일을 임시로 파싱해서 인텔리센스가 동작하기 시작했다. 이거 짱 유용한 기능이다.
전통적으로 프로젝트 소속이 아닌 파일은 문맥을 전혀 알 수 없으며 빌드 대상도 아니기 때문에 IDE에서의 대접이 박했다. 정말 기계적인(문맥 독립적이고 명백한) 신택스 컬러링과 자동 들여쓰기 외에는 자동 완성이나 인텔리센스 따위는 전혀 제공되지 않았다. 전혀 기대를 안 하고 있었는데 이제는 걔들도 miscellaneous file이라는 범주에 넣어서 친절하게 분석해 준다.

4. Spy++

Visual C++에는 프로그램 개발에 유용하게 쓰일 만한 아기자기한 유틸리티들이 같이 포함돼 있다.
'GUID 생성기'라든가 '에러 코드 조회'는 아주 작고 간단하면서도 절대로 빠질 일이 없는 고정 멤버이다.
옛날에는 'OLE/COM 객체 뷰어'라든가 'ActiveX 컨트롤 테스트 컨테이너'처럼 대화상자가 아닌 가변 크기 창을 가진 유틸리티도 있었는데 OLE 내지 ActiveX 쪽 기술이 인기와 약발이 다해서 그런지 6.0인가 닷넷 이후부터는 빠졌다.

그 반면, 기능이 제법 참신하면서 1990년대부터 지금까지 거의 20년 동안 변함없이 Visual C++과 함께 제공되어 온 장수 유틸리티는 단연 Spy++이다.
얘는 제공하는 기능이 크게 변한 건 없었다. 다만 아이콘이 초록색 옷차림의 첩보요원(4.x..!), 분홍색 옷차림(6.0~200x), 검정색 옷차림(2010~현재)으로 몇 차례 바뀌었으며, 운영체제의 최신 메시지가 추가되고 도움말이 hlp에서 chm으로 바뀌는 등 외형만이 최소한의 유지보수를 받아 왔다.

아, 훅킹을 사용한다는 특성상 2000년대 중반엔 64비트 에디션이 따로 추가되기도 했다. 하지만 GUI 껍데기는 x86용 하나만 놔두고 64비트 프로그램에 대해서는 내부적으로 64비트 서버 프로그램을 실행해서 얘와 통신을 하는 식으로 프로그램을 개발하면 더 깔끔했을 텐데 하는 아쉬움이 남는다. 그러면 사용자는 겉보기로 한 프로그램에서 32비트와 64비트 구분 없이 창을 마음대로 들여다보고 훅킹질을 할 수 있을 테니 말이다.

실제로 <날개셋> 입력 패드도 그런 식으로 동작하며, 당장 Visual C++ IDE도 내부적으로 64비트 IPC 서버를 따로 운용하기 때문에 IDE 자체는 32비트이지만 64비트 프로그램도 아무 제약 없이 디버깅이 가능하다. 하지만 안 그래도 훅킹을 하느라 시스템 성능을 잡아먹는 프로그램인데.. 성능 문제 때문에 깔끔하게 64비트 에디션을 따로 빌드한 것일 수도 있으니 Spy++ 개발자의 취향은 존중해 주도록 하겠다.

Spy++는 워낙 역사가 긴 프로그램이기 때문에 초창기 버전은 창/프로세스들의 계층 구조를 전용 트리 컨트롤이 아니라 리스트박스를 정교하게 서브클래싱해서 표현했다. 쉽게 말해 과거 Windows 3.1의 파일 관리자가 디렉터리 계층 구조를 표현한 방식과 비슷하다. 사실은 리스트박스에서 owner draw + user data로 계층 구조를 표현하고 [+/-] 버튼을 눌렀을 때 하부 아이템을 표시하거나 숨기는 건 1990년대 초반에 프로그래밍 잡지에서 즐겨 다뤄진 Windows 프로그래밍 테크닉이기도 했다.

그러다가 VC++ 2005인가 2008 사이쯤에서 Spy++은 운영체제의 트리 컨트롤을 사용하는 걸로 리팩터링이 됐다. 사용자의 입장에서는 기능상의 변화가 없지만 내부적으로는 창을 운용하는 방식이 완전히 바뀐 것이기 때문에 이건 내부적으로 굉장히 큰 공사였으리라 여겨진다.

그런데 VC++ 2010과 함께 제공된 Spy++는 일부 단축키들이 동작하지 않는 버그가 있었다. 전부 먹통인 것도 아니고 창 찾기 Alt+F3, 목록 새로 고침 F5, 속성 표시 Alt+Enter 같은 게 동작하지 않아서 프로그램을 다루기가 불편했다. 이 버그는 잠깐 있었다가 다시 2012 이후에 제공되는 Spy++부터는 고쳐졌다.

Posted by 사무엘

2016/12/03 08:31 2016/12/03 08:31
, ,
Response
No Trackback , 2 Comments
RSS :
http://moogi.new21.org/tc/rss/response/1301

1.
잘 알다시피 C언어는 원래 '이식성 있는 어셈블리'를 표방할 정도로 고수준 언어의 탈을 쓴 뭐랄까.. 안에서 돌아가는 모든 내부 과정이 있는 그대로 투명하게 노출되고, 프로그램이 메모리 내부에서 다루는 모든 물건들은 비트와 바이트 단위로 접근 가능한 식으로.. 모든 것을 프로그래머 재량에 맡기는 가볍고 이상야릇한 언어라는 성격이 강했다.

그래서 static/global을 제외하면 변수값의 초기화도 몽땅 수동으로 해야 하고, 배열 첨자 체크가 없고 심지어 문자열 타입도 없고.. 뭐 그랬다.
그 대신 공용체와 비트필드 같은 변태스러운 물건은.. C 말고 도대체 다른 어떤 언어에서 찾을 수 있겠는가? 단적인 예로, 부동소수점을 부호, 지수, 가수부별로 쪼개서 내부 구조가 어떻게 돌아가는지 보여주는 프로그램을 C 말고 다른 언어로 만드는 건 직관적이지 못하고 꽤 귀찮은 일이 될 것이다.

내가 직접 코드를 작성하지 않았는데 C가 언어 차원에서 자동으로 해 주는 일이라고는 환경변수 세팅이라든가 main 함수에 전달되는 argument의 파싱 같은 정말 최소한의 초기화밖에 없다시피했다.

하지만 C++은 언어 차원에서 몰래 하는 일이 더 있다. 우리가 빌드하는 프로그램에 코드가 추가되기 때문에 그 존재감과 오버헤드에 대해서 최소한의 인지는 하고 있어야 하는 것들이 종종 있다.
생성자와 소멸자, 임시 R-value 오브젝트, 암시적인 형변환 같은 건 그야말로 기본 중의 기본이고.. 가상 함수가 호출되는 원리도 아주 흔한 예다. C로 표현하자면 pData->vptr->pfnFuncXXX(pData, ...) 과 같은 급의 다단계 포인터 참조 오버헤드가 발생한다. 이런 건 C++ 한다는 사람이 아무리 초짜라도 절대로 몰라서는 안 된다.

가상 상속 정도면 가상 함수보다는 훨씬 볼 일이 없는 물건이다. 컴파일 타임 때 미리 계산된 오프셋으로 기반 클래스를 참조하는 게 아니라 기반 클래스의 위치 자체를 포인터를 통해 런타임 때 얻어 온다고 생각하면 된다.

더 어려운 걸로 내려가자면 pointer-to-member가 구현된 원리가 있는데, 이것도 forward 선언된 클래스 + 다중 상속이라는 변수를 만나면 내부 구현이 더럽게 복잡해지며 컴파일러간의 바이너리 호환성도 깨진다. C++에서 한번 홍역을 치른 뒤에 다른 언어에서는 별로 도입할 생각을 안 하고 있다.

global scope에 속한 객체들이 생성자와 소멸자가 호출되는 타이밍, 순서와 원리도 알아두면 좋다. 이식성을 위해서는 global 객체를 만들지 말고, 번거롭지만 차라리 포인터만 만들어 놓고 new와 delete를 프로그램이 수동으로 하는 게 권장되고 있다.

Exception이라는 것도 아주 요상한 물건이고...
끝으로, 언어 차원에서 지원되기 시작한 RTTI(런타임 시점에서의 타입 정보 인식) 기능도 있다. 하지만 이건 제대로 쓰이지는 않는 것 같다. dynamic_cast, typeid 같은 연산자 말이다. 가상 함수가 존재하는 모든 클래스들에는 자동으로 언어 차원에서의 타입 식별 정보가 추가된다.

얘는 구현 오버헤드가 만만찮으며, 언어의 기능에 의존하지 않고 자체적으로 RTTI를 구현한 레거시 코드도 많기 때문에 결국 컴파일러 옵션이 지정되었을 때만 지원되는 기능이 되었다. Visual C++의 경우 /GR 옵션이다.
개발 역사가 오래 됐고 다중 플랫폼을 지원하는 어지간한 프로젝트들은 이 기능을 사용하지 않는다. 마치 문자열 클래스만큼이나 파편화와 중복 구현이 난립해 있다.
사실, RTTI가 제대로 지원되려면 가상 함수가 존재하는 모든 오브젝트들이 공통으로 상속하는 베이스 클래스라는 개념도 있어서 그 베이스 클래스에서 타입 식별과 관련된 멤버들을 제공해야 하지 않나 싶다.

C++은 언어 차원에서의 개입을 최소화한다는 철학을 가진 언어에서 출발했는데 점점 기능이 비대해지고 언어 차원에서의 개입이 늘고 있다.

2.
포인터는 CPU가 메모리 위치를 식별할 때 사용하는 숫자로, 일반적으로는 machine word와 다를 바 없는 아주 가볍고(= 함수 인자로 값을 그대로 넘겨줄 수 있는) 단순한 자료형이다.
여느 자료형의 포인터는 정수와 reinterpret_cast로 형변환이 가능하다. 함수의 포인터는 + - 산술 연산이 되지 않지만 그래도 역시 정수와 교환이 된다.

하지만 포인터가 machine word 하나와 딱 대응하지 않을 때도 있다.
과거 16비트 시절에는 64KB보다 더 큰 영역의 메모리에 접근하기 위해 세그먼트 번호를 추가로 묶은 far pointer라는 게 있었으며 far은 예약어였다. 뭐 그래 봤자 이 포인터는 32비트 long 정수 하나에 대응했으니, Windows 프로그래밍에서는 L이라는 접두어로 원거리 포인터를 표현했다. LPSTR, LPVOID, LPCWSTR 등.

32/64비트로 오면서 그런 구분이 없어졌기 때문에 접두어 L은 불필요한 잉여가 되었다. 본인 역시 PSTR, PVOID, PCWSTR이라고만 쓴다.
단, PVOID는 winnt.h에 typedef로 정의돼 있는데 const void *는 왜 PCVOID라고 정의돼 있지 않고 여전히 LPCVOID만 있는지는 본인이 알 길이 없다. 믿어지지 않으면 한번 검색해 보시기 바란다. 정말 없다.

그리고 다음으로 machine word 하나와 딱 대응하지 않는 대표적인 기괴한 포인터는 아까도 잠깐 언급됐던 C++의 멤버 포인터이다. 다중 상속은 포인터간의 형변환이 일어났을 때 단순 언어적인 semantic뿐만 아니라 주소값 자체가 바뀔 수도 있는 상황을 만들었으며, pointer-to-member는 이를 보정하는 정보를 담느라 크기가 언제나 machine word 하나에 딱 들어가는 게 보장되지 않게 만들었다.

그래서 멤버 포인터는 신기하게도 reinterpret_cast나 C-style 캐스트로도 결코 숫자로 형변환이 되지 않는다. 숫자 하나가 아니라 구조체 같은 완전 생뚱맞은 자료형으로 취급된다. 크기와 내부 구현이 어떻게 가변적으로 달라질지 모르기 때문에 이것만은 C의 철학과는 정반대로 내부 구현과 접근을 프로그래머로부터 싹 감추고 숨겨 버렸다. 이거 굉장한 이질감이 느껴지지 않으신가?

자주 발생하는 일은 아니지만 구조체에서 어떤 멤버가 구조체의 시작 지점으로부터 정확하게 몇 바이트째 오프셋에 있는지 알고 싶을 때가 있다. 당연한 말이지만 이건 컴파일 타임 때 값이 결정되는 상수이다.
이럴 때 흔히 사용하는 방법은 &((STRUCTURE *)0)->member이다. 이렇게 해도 동작은 잘 하지만 그래도 더 깔끔한 방법이 있었으면 좋겠다는 생각이 든다.

개인적으로는 &STRUCTURE::member가 제일 직관적이고 깔끔한 형태라고 생각한다. 이건 pointer-to-member에 대입 가능한 멤버 주소를 얻을 때 사용하는 문법이다.
member가 static 데이터 멤버라면 저 값은 그놈 자신의 주소가 될 것이고, non-static이라면 메모리 주소가 아니라 자신의 오프셋이 된다. 비록 pointer-to-member(데이터 멤버)가 단순 오프셋의 superset으로서 그 이상의 추상적인 자료형이긴 하지만, 결국은 내부적으로도 오프셋을 갖고 있는 꼴이기 때문에 int형으로 reinterpret_cast도 됐으면 하는 생각이 든다. &((STRUCTURE *)0)->member을 안 써도 되게 말이다.

요즘 C++이 캡처가 없는 람다에 한해서 람다를 함수 포인터로 캐스트하는 것도 지원하듯이, 저것도 같은 맥락에서 정수형과 호환됐으면 하는 아쉬움이 남는다.

Posted by 사무엘

2016/11/22 08:33 2016/11/22 08:33
,
Response
No Trackback , 2 Comments
RSS :
http://moogi.new21.org/tc/rss/response/1297

1.
예전에도 한번 이런 비유를 꺼낸 적이 있었는데.. 라면을 소프트웨어 플랫폼에다 비유하자면 봉지 라면은 PC, 사발면은 태블릿, 컵라면은 스마트폰 정도에 대응하는 것 같다. 그래서 한 플랫폼에서 잘나가던 라면이 다른 플랫폼으로 종종 포팅되곤 한다(카카오톡 PC 버전, 오피스 안드로이드 버전처럼). 비록 둘이 맛이 완전히 동일하지는 않지만 말이다.

식당에서 주문해서 먹는 라면은 집 밖의 거대한 다른 가게에 들어가서(서버 접속) 먹는 것이니 서버 사이드 웹 애플리케이션일 것이며..
분식점 같은 식당 납품을 목적으로 라면 제조사가 면이나 스프만을 대량으로 따로 파는 건 '엔진' 같은 미들웨어 컴포넌트 내지 라이브러리에 대응한다고 볼 수 있겠다.

2.
스마트폰은 컴퓨터와 달리.. (1) 특별한 일이 없는 한 24시간 켜져 있고, (2) 열받고 뜨거워질지언정 그래도 팬 돌아가는 소리가 안 나고, (3) 보조 기억장치가 있지만 하드디스크 돌아가는 것 같은 소리는 전혀 없다.
그래서 (2)와 (3)을 종합하면 스마트폰은 아주 조용하다. 게다가 얇기까지 하다.
어찌 보면 세상에 어떻게 이런 컴퓨터가 존재 가능해졌는지 신기한 노릇이 아닐 수 없다. 그것도 화면은 옛날 구닥다리 액정 같은 단색이 아니라, 고해상도 천연색 그래픽을 찍어 낸다. CPU뿐만 아니라 디스플레이나 메모리까지 총체적으로 왕창 발전했기 때문에 스마트폰이 만들어질 수 있었다.

옛날에는 뭔가 영상이 표시되는 기계 자체가 굉장히 미래 하이테크의 상징이었다. 집 현관을 표시해 주는 인터폰이나 자동차 내비 같은 거 말이다.
텔레비전이나 컴퓨터 모니터는 아날로그 신호에 둥그런 브라운관 형태로나마 진작부터 천연색을 표현할 수 있었다. 하지만 들고 다닐 수 있는 소형 텔레비전이나 인터폰, CCTV 같은 건 원가 때문인지 무엇 때문인지, 의외로 흑백 버전이 2000년대까지 쓰였다. 본인은 몇 차례 이사를 다니며 집을 옮긴 적이 있지만, 컬러 화면이 나오는 인터폰 실물을 태어나서 지금까지 한 번도 구경을 못 해 봤다.

그런데 어느 샌가 갑자기 CCTV의 화질이 급격히 향상되고 차량들이 개나 소나 내비에 블랙박스까지 달고 다니면서 블랙박스에 찍힌 사고 영상만 모아서 보여 주는 TV 프로가 큰 인기를 모을 정도가 됐다. 사진과 동영상을 즉각 생성해서 남들 보는 사이버 공간에 용량과 트래픽 걱정 없이 올리는 게 너무 금방, 쉽게 가능해졌다. 이건 1980년대의 SF물들이 제대로 상상하지 못한 너무 엄청난 변화임이 틀림없다.

그리고 컴퓨터 자체도.. 이젠 스마트폰 내부에서 가상 머신을 돌려서 도스는 말할 것도 없고 과거의 Windows 9x를 구동할 수도 있게 됐다. 머리만 비교하면 스마트폰의 CPU가 일반 데스크톱 PC의 CPU와도 성능이 호각이 됐으며, 단지 PC에 비해 부족한 건 입력 장치와 하드디스크 정도밖에 없다고 한다. 발열이나 전원의 한계는 차치하고라도 말이다.

모바일 플랫폼이 등장하면서 PC에서 x86 계열 CPU + Windows 계열 운영체제를 총칭하는 '윈텔' 독점 구도도 상당 부분 흔들리게 됐다. 완전히 새로운 형태의 시장 수요를 창출해 냈으니까. x86은 30년을 넘게 거슬러 올라가는 유구한 하위 호환성을 자랑하지만, 그 때문에 저전력 모바일에서 빠릿빠릿 움직이는 용도로는 상당히 부적합한 CPU가 돼 버려서 말이다. Windows도 마찬가지다.

다만, 단순히 이미 만들어진 정보들을 받아 보기만 하는 인터넷 단말기 이상으로, 뭔가 글쓰기나 코딩 같은 생산적인 활동을 하기에는 스마트폰은 문자 입력이 너무 불편한 게 흠이다. 구닥다리 타자기의 인터페이스를 답습하고 있지만 그래도 문자 입력 분야에서 키보드만 한 가성비를 제공하는 물건은 아직까지 없다.

예전에 그나마 전화기 버튼이라도 있던 시절에는 3*4 배열이라는 틀은 고정돼 있었는데..
요즘 스마트폰은 화면의 절대적인 크기나 종횡비까지 전부 그냥 흰 도화지 수준인 거 같다. 인간에게 가장 적합한 글쇠 scheme은 어떤 형태일까? 블루 오션이다 보니 먼저 연구해서 표준 틀을 정착시키는 사람이 그냥 장땡이 돼서 혼자 다 해먹을 수 있을 것 같은 생각이 드는데.. 난 잘 모르겠다. 난 한글 입력 쪽은 글쇠배열이 아니라 일단은 근본 메커니즘 연구가 주 관심 분야인지라..

글쇠 수가 너무 많으면 안 그래도 작은 화면에 너무 작은 글쇠 버튼을 잘못 찍어서 오타를 내기 쉽고, 반대로 글쇠 수가 너무 적으면 타수가 늘어나고 이것저것 모드를 바꾸는 빈도가 잦아져서 그것대로 또 입력이 불편해진다.
구글 단모음을 한동안 써 보다가 불편해서 다시 나랏글로 돌아왔다. ㅎ, ㅔ 같은 자모를 한 번에 바로 입력할 수 있어서 편한 것보다, 오타가 나서 불편한 게 더 크게 느껴졌다. 개인적으로는 나랏글을 거의 2004년부터 10년 넘게 쓰기도 했고 말이다.

3.
스마트폰이 폭발적인 인기를 끌면서 오늘날과 같은 사진· 동영상 업로드 문화를 만들어 낸 건 두 말할 나위 없이 '디지털 카메라' 기능까지 전화기 안에 쏙 들어간 덕분에 가능했다.
오늘날 폰의 카메라가 단순 화소수와 색감만 따지자면 어지간한 보급형 디카의 성능을 다 따라잡고도 남는다. 하지만 폰 카메라가 전용 디지털 카메라를 결코 따라잡지 못하는 게 크게 둘 있는데, (1) 줌과 (2) 부팅 속도이다.

근본적으로 카메라의 형태로 적합하게 설계되지 않은 그 얇은 몸체에다 두꺼운 다기능 렌즈까지 우겨넣는 건 아무래도 무리다. 그렇기 때문에 폰 카메라는 줌 기능이 전문적인 카메라의 적수가 될 수 없다. 시야각도 한계를 받기 때문에 이걸 극복하려면 별도의 파노라마 합성 앱 같은 것의 도움을 받아야 한다.

또한 디지털 카메라는 사진을 찍을 때에만 잠시 켰다가 끄는 걸 스마트폰보다 훨씬 더 간편하게 할 수 있기 때문에 밖에서 사진을 몇백 장씩 산발적으로 찍을 일이 있을 때 전력 소모 부담이 훨씬 덜하다. 부팅도 아예 범용 컴퓨터인 스마트폰보다야 비교할 수 없이 더 빨리 되며, 전원을 켜자마자 거의 곧장 촬영 ready 상태가 된다. 그 반면 스마트폰은 이런 특성을 전혀 갖고 있지 못하다.

하긴, 피처폰이 스마트폰으로 바뀌고 스마트폰에 온갖 복잡 다양한 기능들이 추가될수록 사용자가 알게 모르게 치르는 대가로는 배터리 시간이라든가 폰의 물리적 내구성 같은 게 있다. 이와 비슷한 맥락에서 스마트폰도 켠 직후에 수 초 이내로 바로 쓸 수 있는 게 아니라, PC에 준하는 급의 부팅이 필요하고 엄청난 양의 초기화와 캐싱, pre-fetching을 해 줘야 쓸 수 있는 물건이 되고 있다. 예전에 PDA나 공학용 계산기가 그렇게 부팅 시간이 긴 물건은 아니었으니 말이다. 부팅이 존재하고 악성 코드 걱정을 해야 하는 기기는 다른 전자 기기와는 성격이 근본적으로 다르며 훨씬 더 능동적인 물건이다.

한때는 이런 작은 화면에 찍히는 글자는 초간단 비트맵 글꼴 기반인 게 당연시되었는데 그게 힌팅까지 적용된 미려한 윤곽선 글꼴로 바뀌었다는 것 하나만으로도 소프트웨어적으로는 예전에 비해 그야말로 엄청난 부담이 추가된 거나 다름없다. 윤곽선 글꼴은 캐싱 없이는 도저히 쓸 물건이 못 되며, 캐싱이라는 건 굉장한 양의 메모리를 요구하기 때문이다.

오늘날 컴퓨터 프로그램들이 같은 일을 해도 예전보다 메모리와 CPU를 훨씬 더 많이 요구하는 이유는 유지 관리 차원에서의 범용성과 추상성을 높인 대신에 오버헤드가 더 커지고 성능 희생을 감수한 게 매우 크게 작용한다(가상 머신, 가상 함수, 등등등등). 스마트폰의 전력 소비나 부팅 속도도 그런 맥락에서 살펴볼 수 있을 듯하다.

Posted by 사무엘

2016/11/05 08:37 2016/11/05 08:37
, , , ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1290

컴퓨터 프로그램에서 실행 제어라 하면 조건과 분기, 반복, 예외 처리 같은 것들이 있는데.. 절차형 프로그래밍 언어에서는 이런 게 예약어와 블록 구조 같은 걸로 표현되고, 단순 함수 호출이나 연산은 위에서부터 아래로 순차적으로 수행되는 편이다.

그런데 C 언어는 타 언어었으면 예약어를 써서 구현되었을 실행 흐름 제어도 다 함수로 구현되는 경우가 종종 있다. 몇 가지 예를 들면 이렇다. 코드 정적 분석 프로그램 같은 걸 만든다면 이런 건 함수 차원에서 예외적으로 다뤄져야 한다.
C가 저수준이라는 소리를 괜히 듣는 게 아닌 듯하다.

1. signal

시스템 차원에서 인터럽트가 발생했을 때 실행될 콜백 함수를 지정한다. Ctrl+C나 Ctrl+Break가 눌리는 것도 이런 상황에 포함되나, Windows의 경우 C 표준을 준수하느라 함수는 동일해도 Ctrl 키 인터럽트는 다른 인터럽트와는 꽤 다른 방식으로 따로 처리된다. (Windows API에 SetConsoleCtrlHandler이라는 함수가 있음) 사실, Windows는 자체적인 예외 처리 함수 지정 메커니즘도 제공한다.
현대의 언어라면 다 try ... catch로 처리했을 사항들이다. SIG* 상수들은 catch 구문에다 별도의 값이나 타입으로 전달되고 말이다.

2. setjmp/longjmp

C 언어에 이런 함수도 존재한다는 걸 처음 알았을 때 굉장히 놀랐었다. goto는 한 함수 안에서만 분기가 가능하지만 얘는 아예 함수의 경계를 초월하여 이전의 setjmp 실행 직후 상황으로 분기를 시켜 주기 때문이다. 이 함수는 다음과 같이 사용하면 된다. 개념적으로 운영체제의 '시스템 복원'을 생각해도 쉽게 이해할 수 있다.

#include <setjmp.h>
jmp_buf jb;

void Func(int n)
{
    printf("%d\n", n);
    if(n==5) longjmp(jb, 0); else Func(n+1);
}

int main()
{
    if(setjmp(jb)) {
        puts("recursion interrupted.");
    }
    else {
        puts("OK, try");
        Func(0);
    }
    return 0;
}

jmp_buf라는 버퍼 자료형을 선언한다. 얘는 배열에 대한 typedef이며, 함수의 인자로 전달될 때는 자동으로 포인터처럼 취급된다. 그렇기 때문에 setjmp, longjmp의 인자로 전달할 때 &를 붙일 필요가 없으며, 그리고 안 붙이더라도 언제나 내부 컨텐츠는 call by reference처럼 취급된다. jb는 매번 함수의 인자로 전달할 게 아니라면 그 특성상 전역변수로 선언해 놓는 게 속 편하다.

그럼, setjmp를 호출하여 되돌아가고 싶은 지점에 대한 스냅샷을 만든다. 스냅샷을 만든 직후에는 setjmp의 리턴값이 0인 것으로 간주된다. 그래서 위의 코드에서는 "OK, try"가 먼저 출력되고 Func가 호출된다.

나중에 Func가 굉장히 복잡하게 실행된 뒤에 이것들을 몽땅 한 큐에 종료해야겠다 싶으면 longjmp를 호출한다. 그러면 얘는 아까 setjmp를 호출한 곳에서 함수가 0이 아닌 값이 리턴된 상황으로 모든 컨텍스트가 '원상복귀' 된다. 그래서 "recursion interrupted"가 출력되고 실행이 끝난다.

구체적인 리턴값은 longjmp의 인자에다가 줄 수 있다. 다만, 여기에다가 0을 지정하면 setjmp가 처음 호출되어 0이 리턴된 것과 구분이 되지 않기 때문에 setjmp의 리턴값이 1인 것으로 값이 일부러 보정된다.

위의 코드는 예외 처리 구문을 사용한 다음 코드와 실행 결과가 완전히 동일하다. 이번에도 try, catch가 답이다. 언어 차원에서 예약어를 동원해서 구현했을 기능이 그냥 함수로 처리되어 있다는 얘기가 바로 이런 의미이다.

void Func(int n)
{
    printf("%d\n", n);
    if(n==5) throw 1; else Func(n+1);
}

int main()
{
    try {
        puts("OK, try");
        Func(0);
    }
    catch(int e) {
        puts("recursion interrupted.");
    }
}

setjmp/longjmp는 언어 차원에서 제공되는 기능이 아니다 보니, 저렇게 함수들을 이탈할 때 C++ 객체들의 소멸자 함수 처리가 제대로 되지 않는다는 한계도 있다. 가변 인자만큼이나 C와 C++의 기능이 서로 충돌하는 지점이다.
그래도 얘는 시스템 프로그래밍 차원에서 고유한 용도가 있다 보니, 이들 함수가 현대의 컴파일러에서 deprecate됐다거나, 뭔가 기능이 보강된 *_s 버전이 생겼다거나 하지는 않다.

3. fork

새로운 실행 주체를 생성하는 함수라는 점에서 Windows의 CreateProcess나 CreateThread와 얼추 비슷하다. 그러나 생성하는 방식은 완전히 다르다.
Windows에서는 프로세스를 생성할 때 파일명을 주며, 그 프로세스는 완전히 처음부터 다시 실행된다. 그리고 스레드는 콜백 함수를 지정해서 생성하며, 그 콜백 함수의 실행이 끝나면 스레드 역시 종료되어 사라진다.

그러나 fork는 지금 나 자신과 메모리 구조와 스택 프레임, 내부 상태 문맥 같은 게 완~전히 동일한 프로세스가 하나 또 실행된다. 그래서 fork를 처음 호출한 기존 프로세스는 fork의 리턴값이 nonzero인 것으로 간주되어 실행이 계속되며, 새로 생성된 프로세스는 리턴값이 0인 것처럼 간주되어 실행이 계속된다. 굉장히 신기한 결과인데, 함수의 디자인 방식이 setjmp와 미묘하게 비슷하다고 볼 수도 있는지는 잘 모르겠다.

//공통 처리 진행 후,
if(fork()==0) {
    //분기된 자식 프로세스 문맥. 하지만 공통 부분에서 만들어 뒀던 변수들에 접근 가능함.
}
else {
    //'공통'을 실행하던 부모 프로세스 문맥
}

(뭐, 정확히는 실행이 성공하면 양수가 돌아오고, 실패하면 음수가 돌아오니 이건 마치 GetMessage의 리턴값만큼이나 주의할 필요는 있다.)

Windows API에는 저렇게 모든 실행 문맥을 그대로 복제해서 자신의 분신 프로세스를 만드는 함수가 존재하지 않는다. 프로그램들 내부에 포인터들까지 있다는 점까지 감안하면 정말 주소 공간이 문자 단위로 정확하게 일치해야 할 텐데, 그걸 그대로 복제하는 건 성능 오버헤드가 크지 않겠나 하는 생각도 든다.

fork는 프로세스를 생성하는 놈이다 보니 CreateProcess와 마찬가지로 비동기적으로 실행된다. 앞서 소개한 signal도 인터럽트 함수가 이론적으로 비동기적으로 실행될 수 있다. set/longjmp는 하는 일은 기괴해도 그래도 프로세스/스레드를 넘나드는 물건은 아니니 대조적이다.

그래서 signal 핸들러나 fork를 사용하는 코드에서는 주의해야 할 점이 있는데, 버퍼를 사용하는 고수준 IO 함수를 사용해서는 안 된다. 쉽게 말해 Hello, world를 찍을 때도 간단하게 printf나 puts를 쓰지 말고 write(1, "Hello", 5)라고 좀 번거로운 방법을 써야 한다. 비동기적인 환경에서 여러 실행 단위가 고수준 IO에 동시에 접근하면 출력이 꼬일 수 있기 때문이다.

먼 옛날 대학 시절, 시스템 프로그래밍 숙제를 하던 시절에 어떤 수강생이 뭘 잘못 건드렸는지 자식 프로세스를 무한 생성하는 삽질을 했고, 이 때문에 학과 서버가 몽땅 다운되어서 수강생들이 과제를 할 수가 없어지는 사태가 벌어졌다. 이 정도면 그 학생뿐만 아니라 계정별로 자원 할당 한계 관리를 제대로 하지 않은 서버 관리자에게도 책임이 있지 않나 생각이 든다만, 어쨌든 이것 때문에 과제의 듀(제출 기한)까지 불가피하게 연장된 적이 있었다.

이것 때문에 빡친 모 친구의 메신저 대화명은 "포크 삽질하는 놈 포크로 찍어 버린다 -_-"였던 것을 본인은 지금도 기억하고 있다.

Posted by 사무엘

2016/07/21 08:39 2016/07/21 08:39
Response
No Trackback , 2 Comments
RSS :
http://moogi.new21.org/tc/rss/response/1252

등산 이야기만 몇 콤보로 계속되는 와중에 오랜만에 또 프로그래밍 얘기를 좀 하겠다.

본인은 예전에 열차나 건물(대표적으로 영화관)에서 좌석 배당 알고리즘이 어떻게 될까 궁금해하면서 이와 관련된 썰을 푼 적이 있다. 그리고 이와 비슷한 맥락에서, 점을 최대한 균등하게 순서대로 뿌리는 ordered 디더링의 가중치, 다시 말해 흑백 음영 단계 테이블은 어떻게 만들어지는 것일까 하는 의문을 제기했다. 그 당시엔 의문 제기만 하고 더 구체적인 해답을 얻지는 못했다.

그래픽 카드가 천연색을 표현할 수 있게 되면서 이제 컴퓨터에서 선택의 여지가 없는 '생존형'(?) 디더링의 필요성은 전무해졌다. 비디오보다는 아주 열악한 네트워크 환경에서 그래픽의 용량을 극도로 줄일 필요가 있을 때에나 특수한 용도로 제한적으로 쓰이는 듯하다. 색상뿐만 아니라 해상도도 왕창 올라가면서 이제는 글꼴의 힌팅조차 존재감이 많이 위태로워졌을 정도이니 세상이 참 많이도 변했다.

하지만 ordered 디더링이라는 건 점을 평면이나 공간에 최대한 골고루 질서정연하게 뿌리는 순서를 구하는 문제이다 보니, 계산 알고리즘의 관점에서는 실용적인 필요성과는 별개로 굉장히 흥미로운 문제인 것 같다.

사용자 삽입 이미지
(이제는 이런 무늬 패턴을 볼 일 자체가 거의 없어졌다..)

흑과 백이 정확하게 반반씩 있는 50% 경우를 생각해 보면, 당연한 말이지만 흑과 백은 대각선으로 엇갈린 형태로 존재한다. 수평선이나 대각선 형태가 아니다. ▤나 ▥가 아니라 ▩에 가까운 것이다.

그러므로 아주 간단한 2*2 크기의 음영이라면
(1 4)
(3 2)

가 된다. 수평선인 (1 2)(3 4)나 수직선인 (1 4)(2 3)이 아니라, (1 4)(3 2)라는 것이다.
그러니 태극기의 괘는 패턴이 (3 5)(4 6)이기 때문에 수직선에 가깝다. 그리고 이거 무슨 승용차에서 운전사가 있을 때와 없을 때, 좌석의 위치별로 상석에서 말석 순서 테이블과 비슷하다는 느낌도 든다.. -_-;;

시작점인 1은 언제나 좌측 상단으로 고정해서 생각해도 일반성을 잃지 않는다. 그럼 다음 2의 위치는 1에서 가장 멀리 떨어진 대각선이므로 역시 자동으로 결정된다.
그럼 (1 4)(3 2) 대신 (1 3)(4 2)는 불가능한 방향이 아니긴 하지만, 관례적으로 2 다음에 위쪽이 아니라 왼쪽에다가 3을 찍는 걸 선호하는 듯하다.

자, 그럼 얘를 조금 더 키워서 4*4 음영은 어떻게 될까?

(1 ? 4 ?) - (1 ? 4 ?) - (1 13 4 16)
(? * ? *) - (? 5 ? 8) - (9 5 12  8)
(3 * 2 ?) - (3 ? 2 ?) - (3 15 2 14)
(? * ? *) - (? 7 ? 6) - (11 7 10 6)

테이블의 크기가 딱 두 배로 커지면 새로운 숫자들은 언제나 기존 테이블의 틈바구니에 삽입된다. 그래야 균형이 유지될 수 있다.
각각의 틈바구니에 대해서 원래 칸의 대각선 아래 (+1, +1), 그리고 바로 아래 (0, +1), 바로 옆 (+1, 0)의 형태로 (5~8), (9~12), (13~16)이 매겨진다. 그랬더니 무슨 짝수 마방진 같은 복잡난감한 퍼즐이 채워졌다.

컴퓨터그래픽에서 실용적으로 가장 많이 쓰이는 음영은 8*8 크기이다. 모노크롬/16색 시절에 단색 패턴 채우기 함수들은 전부 8*8 패턴을 사용했다. 그러므로 얘는 음영을 64단계까지 표현할 수 있다.

8*8 패턴은 역시 4*4 패턴의 틈바구니에 삽입된다. 16 다음에 17이 들어가는 위치는 어디일까? 1과 2 사이에 5가 삽입되었던 것처럼 1과 5의 사이에 17이 삽입된다. 그리고 패턴 크기의 절반인 4픽셀 단위로 n, n+1, n+2, n+3이 (x,y), (x+4,y+4), (x,y+4), (x+4,y)의 순으로 번호가 매겨지는 건 변함없다.

거의 난수표 수준의 복잡한 테이블이 완성됐다. 규칙성이 뭔가 감이 오시는지? 그래픽 라이브러리들은 마치 삼각함수 테이블만큼이나 미리 계산된 디더링 테이블을 내장하고 있다.
그런데 이런 식으로 16*16 256단계 음영 테이블은 어떻게 만들 수 있을까?
각 구간을 순서대로 각개격파하는 게 아니기 때문에 분할 정복이나 재귀호출은 아닌 것 같다.

이런 숫자를 생성하는 코드를 작성하기 위해, 먼저 다음과 같은 변수들을 클래스나 전역변수 형태로 정의하자.

int mtrix[N][N]; int cs, ce;
static const POINT PTR[4] = {
    {0,0}, {1,1}, {0,1}, {1,0}
};

void Draw(int y, int x, int delta)
{
    for(int i=0;i<4;i++)
        mtrix[y+PTR[i].y*delta][x+PTR[i].x*delta]=ce++;
}

Draw는 특정 지점에서 n 간격으로 (0,0), (n,n), (0,n), (n,0)의 순으로 ce부터 ce+3까지 번호를 매겨 주는 역할을 한다.
이를 이용하면 2*2의 경우는 Draw(0, 0, 1)을 통해 간단히 만들 수 있다.

void Case2()
{
    cs=2; ce=1; memset(mtrix, 0, sizeof(mtrix));
    Draw(0, 0, 1);
}

앞서 살펴보았던 4*4는 이런 형태가 되고..

void Case4()
{
    cs=4; ce=1; memset(mtrix, 0, sizeof(mtrix));
    for(int a=0;a<4;a++)
        Draw( PTR[a].y, PTR[a].x, 2 );
}

더 복잡한 8*8은 Draw를 어떤 순서대로 호출해야 할지 따져보면 결국 규칙성이 도출된다.
그렇다. 2중 for문이 만들어지며, 16*16은 3중 for문이 될 뿐이다.

void Case8()
{
    cs=8; ce=1; memset(mtrix, 0, sizeof(mtrix));
    for(int a=0; a<4; a++)
        for(int b=0; b<4; b++)
            Draw(PTR[a].y + PTR[b].y*2, PTR[a].x + PTR[b].x*2, 4);
}

void Case16()
{
    cs=16; ce=1; memset(mtrix, 0, sizeof(mtrix));
    for(int a=0; a<4; a++)
        for(int b=0; b<4; b++)
            for(int c=0; c<4; c++)
                Draw(PTR[a].y + (PTR[b].y<<1) + (PTR[c].y<<2),
                    PTR[a].x + (PTR[b].x<<1) + (PTR[c].x<<2), 8);
}

사용자 삽입 이미지

바로 이것이 우리가 원하는 정답이었다. 식을 도출하고 보니 규칙은 허무할 정도로 너무 간단하다. n중 for문을 재귀호출이나 사용자 스택 형태로 정리하는 건 일도 아닐 테고.
이 정도면 평면이 아니라 3차원 공간을 점으로 촘촘하게 채우는 것도 생각할 수 있다. PTR 테이블은 (0,0,0), (1,1,1)부터 시작해서 정육면체의 꼭지점을 순회하는 순서가 되므로 크기가 8이 될 것이다.

그리고 참고로 8*8 음영 행렬은 아래의 코드를 실행해서 생성할 수도 있다.

int db[8][8];
for (int y = 0; y < 8; y++)
    for (int x = 0; x < 8; x++) {
        int q = x ^ y;
        int p = ((x & 4) >> 2) + ((x & 2) << 1) + ((x & 1) << 4);
        q = ((q & 4) >> 1) + ((q & 2) << 2) + ((q & 1) << 5);
        db[y][x] = p + q + 1;
    }

내가 처음에 for문을 써서 작성한 코드는 함수로 치면 일종의 매개변수 함수이다. (t에 대해서 x(t)는 얼마, y(t)는 얼마)
그런데 저건 그 매개변수 함수를 y=f(t) 형태로 깔끔하게 정리한 것과 같다. 식이 뭘 의미하는지 감이 오시는가?

이런 걸 보면 난 xor이라는 비트 연산에 대해 뭔가 경이로움, 무서움을 느낀다.
덧셈이야 "니가 아무리 비비 꼬아서 행해지더라도 까짓거 덧셈일 뿐이지. 결과는 다 예측 가능해" 같은 생각이 드는 반면, xor에다가 비트 shift 몇 번 하고 나면 도저히 예측 불가능한 난수 생성 알고리즘이 나오고 암호화/해시 알고리즘이 만들어지기 때문이다. 지극히 컴퓨터스러운 연산이기 때문에 속도도 왕창 빠르고 말이다.

2002년에 우리나라에서 열렸던 국제 정보 올림피아드에서도 'xor 압축'이라는 제출형 문제가 나온 적이 있다. 임의의 비트맵 이미지가 주어졌을 때, 이걸 사각형 영역의 xor 연산만으로 생성하는 순서를 구하되, 연산 수행을 최소화하라는 게 목표이다.

한 점에 대해서 가로/세로로 인접한 점 3개를 추가로 조사하여 흑백 개수가 홀수 개로 차이가 나는 점을 일종의 '모서리'로 간주하여 각 모서리들에 대해 plane sweeping하듯이 xor을 시키면 그럭저럭 괜찮은 정답이 나온다. 단, 이것이 이론적인 최적해와 동일하다는 것은 보장되지 않는다. 그렇기 때문에 문제가 제출형으로 출제된 것이다.

재미있는 것은 모서리 판정도 xor로 하면 간단하게 해결된다는 것이다.
(pt[x][y]==1)^(pt[x+1][y]==1)^(pt[x][y+1]==1)^(pt[x+1][y+1]==1) 같은 식. 이유는 조금만 생각해 보면 알 수 있다.

난 Bisqwit이라는 필명을 쓰는 이스라엘의 무슨 괴수 그래픽 프로그래머의 코딩 동영상에서 저 코드가 흘러가는 걸 발견하고 가져왔다. 흐음..;; Creating a raytracer for DOS, in 16 VGA colors 뭐 이런 걸 올려서 시청자들을 경악시키는 분이긴 한데, 물론 레알 16비트 도스용 Turbo C나 QuickBasic 컴파일러로 저런 걸 돌린다는 소리는 아니다. 그건 알파고 AI를 개인용 데스크톱 컴퓨터로 돌리는 것만큼이나 불가능한 일이니 너무 쫄지 않아도 된다. (VGA 16색인 건 맞지만 메모리와 속도는 그 옛날 기계 기준이 결코 아님.)

엑셀에다가 저 16*16 음영 테이블을 입력한 뒤, 수식을 이용해서 숫자 n을 입력하면 그에 해당하는 음영이 생성되게 워크시트를 만들어 보니 재미있다. 이번에도 흥미로운 덕질을 했다.

 

사용자 삽입 이미지

Posted by 사무엘

2016/06/26 08:33 2016/06/26 08:33
, , ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1242

1. C++의 new/delete 연산자

C++의 new와 delete 연산자에 대해서는 먼 옛날에 한번 글을 쓴 적이 있고,  연산자 오버로딩에 대해서 글을 쓸 때도 다룬 적이 있다.
new/delete 연산자는 메모리를 할당하고 해제하는 부분만 따로 떼어내서 operator new / opertor delete라는 함수를 내부적으로 호출하는 형태이며, 이건 클래스별로 오버로딩도 가능하다. 그리고 객체 하나에 대해서만 소멸자를 호출하는 일명 스칼라 new/delete와, 메모리 내부에 객체가 몇 개 있는지를 따로 관리하는 벡터 new[]/delete[]가 구분되어 있다는 점이 흥미롭다.

new는 메모리 할당이 실패할 경우 한 1990년대까지는 NULL을 되돌렸지만 요즘은 예외를 되돌리는 게 malloc과는 다른 점이라고 한다. 하긴, 요즘 세상에 메모리 할당 결과를 무슨 파일 열기처럼 일일이 NULL 체크하는 건 굉장히 남사스럽긴 하다.

1980년대의 완전 초창기, 한 터보 C++ 1.0 시절에는 벡터 delete의 경우, 원소 개수를 수동으로 써 주기까지 해야 했다고 한다. pt = new X[3] 다음에는 delete[3] pt처럼. 안 그래도 가비지 컬렉터도 없는데, 이건 너무 불편한 정도를 넘어 객체지향 언어의 기본적인 본분(?)조차 안 갖춰진 막장 행태로 여겨진지라 곧 시정됐다. 객체의 개수 정도는 언어 차원에서 메모리 내부에다 자동으로 관리하도록 말이다.

그런데 스칼라이건 벡터이건 메모리를 n바이트 할당하거나 해제하는 동작 자체는 서로 아무 차이가 없는데 operator new/delete와 operator new[]/delete[]가 따로 존재하는 이유는 난 여전히 잘 모르겠다.

new char[100]을 하면 operator new(100)이 호출되고, 생성자와 소멸자가 있는 new TwentyFour_byte_object[4]를 호출하면 x86 기준으로 24*4+4인 operator new[](100)이 호출된다.
operator new[]라고 해서 딱히 내가 할당해 준 메모리에 저장되는 객체의 개수나 크기를 알 수 있는 것도 아니다. 단지, new[]의 경우 내가 되돌려 준 메모리 바로 그 지점에 객체가 바로 저장되지는 않는다는 차이가 존재할 뿐이다. 맨 앞에는 오브젝트의 개수 4가 저장되기 때문.

즉 다시 말해 벡터 new[]는 operator new[]가 되돌린 포인터 값과, new operator[]를 호출한 호스트 쪽에서 받는 포인터 값에 미묘하게 차이가 생기며 서로 일치하지 않게 된다. 마치 다중 상속으로 인해서 this 포인터가 보정되는 것처럼 말이다.
그래도 스칼라/벡터 처리는 operator new/delete가 전혀 신경 쓸 필요가 없는 영역이며, 여전히 new/delete operator가 자동으로 하는 일일 뿐인데 그것 때문에 메모리 할당 계층 자체가 둘로 구분되어야 할 필요가 있는지는 여전히 개인적으로 의문이다.

그리고 하나 더.
operator new/delete는 오버로딩이 가능하다고 아까 얘기했었다.
global scope에서 오버로딩을 해서 오브젝트 전체의 메모리 할당 방식을 바꿀 수 있으며, new의 경우 추가적인 인자를 집어넣어서 placement new 같은 걸 만들 수도 있다. "메모리 할당에 대한 답은 정해져 있으니 너는 저 자리에다가 생성자만 호출해 주면 된다"처럼.. (근데 new와는 달리 delete는 왜 그게 가능하지 않은지 모르겠다만..)

global scope의 경우, Visual C++에서는 operator new/delete 하나만 오버로딩을 해도 new[], delete[] 같은 배열 선언까지도 메모리 할당과 해제는 저 new/delete 함수로 자동으로 넘어간다. 물론 new[]/delete[]까지 오버로딩을 하면 스칼라와 벡터의 메모리 요청 방식이 제각기 따로 놀게 된다.

그러나 클래스는 operator new/delete 하나만 오버로딩을 하면 그 개체의 배열에 대한 할당과 해제는 그 함수로 가지 않고 global 차원의 operator new[]/delete[]로 넘어간다.
이것도 표준에 규정된 동작 방식인지는 잘 모르겠다. 결정적으로 xcode에서는 global도 클래스일 때와 동일하게 동작하여 스칼라와 벡터 사이의 유도리가 동작하지 않았다.
메모리 할당이라는 기본적인 주제를 갖고도 C++은 내부 사연이 무척 복잡하다는 걸 알 수 있다.

2. trigraph

아래와 같은 코드는 보기와는 달리 컴파일되는 올바른 C/C++ 코드이다. 그리고 Foo()를 호출하면 화면에는 What| 이라는 문자열이 찍힌다.

void Foo()
??<
    printf( "What??!\n" );
??>

그 이유는 C/C++엔 trigraph라는 문자열 치환 규칙이 '일단' 표준으로 정의돼 있기 때문이다.
아스키 코드에서 Z 뒤에 나오는 4개의 글자 [ \ ] ^ 와, z 뒤에 나오는 4개의 글자 { | } ~, 그리고 #까지 총 9개의 글자는 ?? 로 시작하는 탈출문자를 통해 등가로 입력 가능하다.
이런 치환은 전처리기 차원에서 수행되는데, #define 매크로 치환과는 달리 일반 영역과 문자열 리터럴 안을 가리지 않고 무조건 수행된다. 그래서 문자열 리터럴 안에서 연속된 ?? 자체를 표현하려면 일부 ?를 \? 로 구분해 줘야 한다.

이런 게 들어간 이유엔 물론 까마득히 먼 역사적인 배경이 있다. 천공 카드던가 뭐던가, 저 문자를 한 글자 형태로 입력할 수 없는 프로그래밍 환경에서도 C언어를 지원하게 하기 위해서이다. 1950~70년대 컴퓨팅 환경을 겪은 적이 없는 본인 같은 사람으로서는 전혀 이해할 수 없는 환경이지만 말이다.
C(와 이거 호환성을 계승한 C++도)는 그만치 오래 된 옛날 레거시 언어인 것이다. 그리고 C는 그렇게도 암호 같은 기호 연산자들을 많이 제공하는 언어이지만 $ @처럼 전혀 사용하지 않는 문자도 여전히 있다.

오늘날 PC 기반 프로그래밍 환경에서 저런 trigraph는 전혀 필요 없어진 지 오래다. 그래서 Visual C++도 2008까지는 저걸 기본 지원했지만 2010부터는 '기본 지원하지는 않게' 바뀌었다. 이제 저 코드는 기본 옵션으로는 컴파일되지 않는다. /Zc:trigraphs 옵션을 추가로 지정해 줘야 한다.

C/C++ 코드를 가볍게 구문 분석해서 함수 블록 영역이나 변수 같은 걸 표시하는 IDE 엔진들은 대부분이 trigraph까지 고려해서 만들어지지는 않았다. 그러니 trigraph는 IDE가 사용하는 가벼운 컴파일러들을 교란시키고 혼동시킨다. 한편으로 이 테크닉은 소스 코드를 의도적으로 괴상하게 바꾸는 게 목표인 IOCCC 같은 데서는 오늘날까지 유용하게 쓰인다. 함수 선언을 void foo(a) int a; { } 이렇게 하는 게 옛날 원래의 K&R 스타일이었다고 하는데 그것만큼이나 trigraph도 옛날 유물이다.

차기 C/C++ 표준에서는 trigraph를 제거하자는 의견이 표준 위원회에서 제안되었다. 그런데 여기에 IBM이 적극적인 반대표를 던진 일화는 유명하다. 도대체 얼마나 케케묵은 옛날 코드들에 파묻혀 있으면 '지금은 곤란하다' 상태인지 궁금할 따름이다. 하지만 IBM 혼자서 대세를 거스르는 게 가능할지 역시 의문이다.

3. Visual C++ 2015의 CRT 리팩터링

도스 내지 16비트 시절에는 C/C++ 라이브러리를 DLL로 공유한다는 개념이 딱히 없었던 것 같다. 다음과 같은 이유에서다.

  • 도스의 경우, 근본적으로 DLL이나 덧실행 같은 걸 쉽게 운용할 수 있는 운영체제가 아니며,
  • 메모리 모델이 small부터 large, huge까지 다양하게 존재해서 코드를 한 기준으로 맞추기가 힘들고,
  • 옛날에는 C/C++ 라이브러리가 딱히 공유해야 할 정도로 덩치가 크지 않았음.
  • 예전 글에서 살펴 보았듯이, 16비트 Windows 시절엔 DLL이 각 프로세스마다 자신만의 고유한 기억장소를 갖고 있지도 않았음. 그러니 범시스템적인 DLL을 만드는 게 더욱 까다롭고 열악했다.

모든 프로세스들이 단일 주소 공간에서 돌아가긴 했겠지만, small/tiny 같은 64K 나부랭이 메모리 모델이 아닌 이상, sprintf 하나 호출을 위해서 코드/세그먼트 레지스터 값을 DLL 문맥으로 재설정을 해야 했을 것이고 그게 일종의 썽킹 오버헤드와 별 차이가 없었지 싶다. 마치 콜백 함수를 호출할 때처럼 말이다. 이러느니 그냥 해당 코드를 static link 하고 만다.

그 반면 32비트 운영체제인 Windows NT는 처음부터 CRT DLL을 갖춘 상태로 설계되었고, 그 개념이 Visual C++을 거쳐 Windows 9x에도 전래되었다. 1세대는 crtdll, msvcrt10/20/40이 난립하던 시절이고 2세대는 Visual C++ 4.2부터 6까지 사용되던 msvcrt, 그리고 3세대는 닷넷 이후로 msvcr71부터 msvcr120 (VC++ 2013)이다. 2005와 2008 (msvcr80과 90)은 잠시 매니페스트를 사용하기도 했으나 2010부터는 그 정책이 철회됐다.

그런데 매니페스트를 안 쓰다 보니 Visual C++의 버전이 올라갈 때마다 운영체제의 시스템 디렉터리는 온갖 msvcr??? DLL로 범람하는 폐단이 생겼고, 이에 대한 조치를 취해야 했다. C/C++ 라이브러리라는 게 생각보다 자주 바뀌면서 내부 바이너리 차원에서의 호환성이 종종 깨지곤 했다. 이런 변화는 함수 이름만 달랑 내놓으면 되는 C보다는 C++ 라이브러리 쪽이 더 심했다.

그 결과 Visual C++ 2015와 Windows 10에서는 앞으로 변할 일이 없는 인터페이스 부분과, 내부 바이너리 계층을 따로 분리하여 CRT DLL을 전면 리팩터링을 했다. 본인은 아직 이들 운영체제와 개발툴을 써 보지 않아서 자세한 건 모르겠는데 더 구체적인 내역을 살펴봐야겠다.

사실 C++ 라이브러리는 대부분의 인터페이스가 템플릿 형태이기 때문에 코드들이 전부 해당 바이너리에 static 링크된다. 하지만 그래도 모든 코드가 static인 건 아니다. 메모리 할당 내지 특정 타입에 대한 템플릿 specialization은 여전히 DLL 링크가 가능하다.
C++ 라이브러리가 어떤 식으로 DLL 링크되는지는 마치 함수 타입 decoration 방식만큼이나 그야말로 표준이 없고 구현체마다 제각각인 춘추전국시대의 영역이지 싶다.

4. Windows의 고해상도 DPI 관련 API

요즘이야 컴퓨터 화면의 해상도가 PC와 모바일을 가리지 않고 워낙 높아져서 프로그램의 UI 요소나 각종 아이콘, 그래픽도 크기 조절에 유연하게 대처 가능하게 만드는 게 필수 조건이 됐다. 폰트의 경우 저해상도에 최적화된 힌팅이 필요 없어질 거라는 전망까지 나온 지 오래다.
그러나 태초에 컴퓨터, 특히 IBM 호환 PC라는 건 텍스트 모드만 있다가 그래픽 모드라는 게 나중에 추가됐다. 그것도 그래픽 모드는 320*200이라는 막장급의 낮은 해상도에 4색짜리인 CGA에서 첫 시작을 했다.

시작은 심히 미약했다. 이런 저해상도 저성능 컴퓨터에서는 쑤제 도트 노가다로 최적화된 그래픽이나 비트맵 글꼴이 속도와 메모리 면에서 모두 우월했기 때문에 그게 세상을 평정했다.
그러나 컴퓨터 화면이 커지고 해상도가 크게 올라가면서 단순히 픽셀보다 더 고차원적인 단위를 도입할 필요가 생겼다. 물론 예나 지금이나 메뉴와 아이콘, 프로그램 제목 표시줄의 글자 크기는 제어판에서 간단히 고칠 수 있었지만 영향을 받는 건 오로지 그것뿐. 대화상자 같은 다른 요소들의 크기는 변하지 않았다.

그 고차원적인 단위를 일명 시스템 DPI라고 부른다.
평소에야 이 단위는 언제나 관례적으로 100%로 맞춰져 있었으며, 이게 125나 150% 같은 큰 값으로 맞춰져 있으면 응용 프로그램은 창이나 글자의 크기도 원칙적으로는 이에 비례해서 키워서 출력해야 한다.

대화상자는 픽셀이 아니라 내부적으로 DLU라는 추상적인 단위를 사용해서 컨트롤들을 배치하며 이 단위는 시스템 DPI를 이미 반영하여 산정된다. 하지만 CreateWindowEx를 써서 픽셀 단위로 컨트롤을 수동으로 생성하는 코드들이 이런 시스템 DPI를 고려하지 않고 동작한다면 프로그램의 외형이 많이 이상하게 찍히게 된다.

여기까지가 Windows 95부터 8까지 오랫동안 지속된 프로그래밍 트렌드이다. 시스템 DPI는 단순히 메뉴와 아이콘의 글자 크기와는 달리 운영체제 전체에 끼치는 여파가 매우 크다. 이건 값을 변경하려면 운영체제를 재시작하거나 최소한 모든 프로그램을 종료하고 현 사용자가 로그인을 다시 해야 했다.

시스템 DPI라는 개념 자체에 대한 대비가 안 된 프로그램도 널렸는데, 응용 프로그램들이 시스템 DPI의 실시간 변화에까지 대비하고 있기를 바라는 건 좀 무리였기 때문이다. 시스템 메트릭이 싹 바뀌기 때문에 이미 만들어져 있는 윈도우들이 다 재배치돼야 할 것이고 후유증이 너무 크다.

그런데 지난 Windows 8.1은 이 시스템 DPI에 대해서 또 어마어마한 손질을 가했다.
간단히 결론부터 말하자면 사용자가 재부팅 없이도 DPI를 막 변경할 수 있게 했다. 실행 중에 DPI가 변경되면 WM_DPICHANGED라는 새로운 메시지가 온다. 그리고 응용 프로그램은 자신이 실시간 DPI 변경에 대응 가능한지 여부를 운영체제에 별도의 API 내지 매니페스트 정보를 통해 지정 가능하게 했다.

DPI 변경에 대응 가능하지 않은 레거시 프로그램들은 시스템 DPI가 바뀌었는지 알지도 못하고 virtualize된 샌드박스 속에서 지낸다. DPI가 150%로 바뀌면서 사용자의 화면에 보이는 창 크기가 100에서 150으로 늘었지만, 응용 프로그램은 여전히 자신의 최대 크기가 100인줄로 안다. 그래서 100*100 크기로 그림을 찍으면 그건 운영체제에 의해 1.5배 비트맵 차원에서 크게 확대되어 출력된다.

그 프로그램은 처음부터 시스템이 150% DPI인 것을 알았으면 그에 맞춰 실행되었을 수도 있다. 그러나 실행 중의 DPI 변경까지 예상하지는 못하며, 그런 API가 도입되기 전에 개발되었기 때문에 운영체제가 그래픽 카드의 성능을 활용하여 그런 보정을 해 주는 것이다.
물론 이렇게 확대된 결과는 계단 현상만 뿌옇게 보정된 채 출력되기 때문에 화질이 좋지 못하다. 응용 프로그램이 고해상도 DPI 변화를 인식하여 직접 150*150으로 최적화된 그림을 다시 그리는 게 바람직하다.

그리고 시스템 DPI는 제어판 설정의 변경을 통해서만 바뀌는 게 아니다.
Windows 8.1부터는 모니터별로 시스템 DPI를 다르게 지정할 수 있다. 그래서 100%(96dpi)짜리 모니터에서 돌아가고 있던 프로그램 창을 125%(120dpi)짜리 커다란 모니터로 옮기면 거기서는 동일 프로그램이 그 DPI에 맞춰서 동작해야 한다. 물론 DPI가 바뀌었다는 메시지는 운영체제가 보내 준다.

이렇듯, 응용 프로그램은 처음에는 (1) 고해상도 DPI를 인식할 것만이 요구되었다가 나중에는 (2) 실행 중에 DPI가 변경되는 것에도 대비가 되어야 하는 것으로 요구 조건이 추가되었다.
옛날에는 시스템 전체의 화면 해상도나 색상수를 재부팅 없이 실시간으로 바꾸는 것도 보통일이 아니었는데 이제는 DPI의 변경도 그 범주에 속하게 되었다.

재부팅이 필요하다는 이유 때문에 그런지 Windows Vista는 전무후무하게 DPI의 변경에 마치 시스템의 시각 변경처럼 '관리자 권한' 딱지가 붙어 있기도 했는데 이것도 참 격세지감이다.

Posted by 사무엘

2016/06/02 08:32 2016/06/02 08:32
, , ,
Response
No Trackback , 2 Comments
RSS :
http://moogi.new21.org/tc/rss/response/1233

Windows에서 C/C++ 언어로 EXE를 만들 때는 시작점으로 WinMain이라는 함수가 쓰인다.
얘는 먼 옛날 16비트 시절과, 지금의 32/64비트 사이에 바뀐 게 거의 없다. HINSTANCE hInst, HINSTANCE hPrevInst, PSTR pszCmdLine, int nCmdShow 라는 네 종류의 인자 중에서 32비트로 오면서 바뀐 것은 hPrevInst이 언제나 NULL이라는 것밖에 없다. 그것도 과거에는 복잡하던 게 더 간결해진 변화이기 때문에 실질적으로 신경 쓸 필요가 없다.

옛날 16비트 시절에 HINSTANCE는 파일 차원에서 동일한 프로그램이 중복 실행되었을 때 각 실행 문맥을 구분하는 일종의 메모리 번호표였다. 한 프로그램이 완전히 처음 실행될 때는 hPrevInst가 NULL인데 두 번째 실행되면, 먼저 실행된 프로그램이 받았던 hInstance가 다음 인스턴스의 WinMain 함수에서 hPrevInst로 전달되고..
세 번째 중첩 실행되면 아까 그 두 번째 프로그램의 신규 핸들이 거기의 hPrevInst로 전달되는 형태였다. 단일 방향 연결 리스트의 head 노드 같은 느낌이다.
자기 자신 말고는 주변에 무엇이 있는지 일부러 특수한 API를 써서 조회를 하지 않으면 도무지 알 수 없는 32비트 이상 보호 모드에서는 정말 상상하기 힘든 관행이다.

EXE는 그렇고 그럼 DLL은 어떨까? DllMain이라는 기본적인 형태는 동일하지만 16비트 시절에는 아무래도 멀티스레드 같은 건 존재하지 않았으니까 DLL_PROCESS_(ATTACH/DETACH)만 있었고, 나중에 DLL_THREAD_*가 추가된 정도일까?

사실은 그렇지 않다.
옛날에는 BOOL DllMain(HINSTANCE hInst, DWORD fdwReason, PVOID pReserved)라는 형태의 함수 자체가 없었다.
그 대신 완전히 다른 int FAR PASCAL LibMain(HANDLE hInst, WORD wDataSeg, WORD wHeapSize, LPSTR lpszCmdLine) 라는 함수가 있었으며, DLL이 처음 로드되었을 때에 이게 한 번만 호출되곤 했다.

16비트 시절에 DLL은 프로세스 독립성이 보장되지 않았다.
지금이야 B.DLL을 사용하는 A.EXE가 두 번 중첩 실행되면 두 인스턴스에 대해서 B.DLL이 제각각 로드되어 DLL_PROCESS_ATTACH가 오지만..
옛날에는 A.EXE가 중첩 실행되었더라도 B.DLL에서 LibMain은 첫 로딩될 때 한 번만 실행되었다. 그리고 자신이 A의 두 번째 인스턴스에 의해 중첩 로드되었다는 사실을 알 길이 없었다. A가 B.DLL에 별도로 정의되어 있는 초기화 함수 같은 것을 호출하지 않는다면 말이다.

LibMain 함수의 인자를 살펴보면, 첫 인자는 자기 자신을 식별하는 인스턴스 핸들이다.
하지만 16비트 시절에는 DLL은 중첩 로딩이 되지 않고 자신의 전역변수 값이 모든 프로그램에서 공유되었다. 그렇기 때문에 저 값은 EXE의 WinMain에서 전달되는 인스턴스 핸들과는 달리 딱히 변별성은 없었을 것이다. 시스템 전체를 통틀어 같은 값이 들어왔으리라 생각된다.

그 다음 wDataSeg와 wHeapSize는 딱 보기만 해도 16비트스러운 암울한 값이다. 이게 어떤 의미를 갖고 이것으로 무엇을 할 수 있는지 잘 모르겠다.
데이터 세그먼트(DS) 레지스터 값은 뭐 어쩌라는 건지 잘 모르겠지만 어쨌든 실행할 때마다 다른 값이 들어올 수는 있어 보인다. 그 반면 wHeapSize는 이 DLL을 빌드할 때 def 파일에다가 지정해 줬던 로컬 힙의 크기이다. 즉, 이 DLL이 지금 형태 그대로 존재하는 한 언제나 고정된 값이 넘어온다.

마지막으로 lpszCmdLine은 더욱 기괴하다. EXE도 아니고 DLL을 어떻게 인자를 줘서 로딩한단 말인가? LoadLibrary 함수에 인자를 전달하는 기능이 있지도 않은데 말이다. 호스트 EXE에 전달된 인자를 되돌리는 것도 아닌 듯하다. 실제로 거의 대부분의 경우 이 인자의 값은 어차피 그냥 NULL이라고 한다.

16비트 DLL의 첫 관문인 LibMain은 기괴한 점이 여기저기서 발견된다.
DLL에 배당되어 인자로 전달된 데이터 세그먼트는 앞으로 빈번하게 사용되는 것을 염두에 두고 메모리 상의 주소가 바뀌지 않게 lock이 걸린다고 한다. 운영체제는 아니고 컴파일러가 lock을 거는 코드를 기본적으로 추가해 넣는 듯하다.
그래서 옛날 소스 코드를 보니, 이유는 알 수 없지만 LibMain에 보통 이런 코드가 들어갔다고 한다.

if (wHeapSize > 0) UnlockData (0);

즉, 아직은 lock을 걸지 말고 도로 재배치 가능한 상태로 놔 두겠다는 뜻이다. 그리고 LockData/UnlockData는 Windows 3.1의 windows.h에 이렇게 매크로로 정의돼 있다.

#define LockData(dummy)     LockSegment((UINT)-1)
#define UnlockData(dummy)   UnlockSegment((UINT)-1)

옛날에는 (Un)LockSegment라는 함수가 있었다. 그리고 Windows 3.x보다도 더 옛날에는 (Un)LockData라는 함수도 별도로 있었는데, 용례가 간소화돼서 Data의 기능이 Segment로 흡수된 듯하다. (가상 메모리라는 게 없던 Windows 2.x 리얼 모드 시절의 잔재라고 함.) 그러니 Data는 레거시 호환을 위해 매크로로 바뀌고, 인자 역시 쓰이지 않는 dummy로 바뀐 것이다.
평소에는 특정 세그먼트 lock/unlock을 하는데, (UINT)-1을 주면 모든 영역을 그렇게 하는 것 같다. 어떤 경우든 wDataSeg의 값이 직접 쓰이지는 않는다.

LibMain은 초기화가 성공하면 1을 되돌리고 그렇지 않으면 0을 되돌려서 DLL의 로딩을 취소하게 돼 있었다. 이것은 오늘날의 DllMain과 동일한 점이다.
그럼 16비트 시절에는 시작 다음으로 DLL의 종료 시점을 감지하려면 어떻게 해야 했을까? EXE와는 달리 DLL은 main 함수의 종료가 곧 프로그램의 종료는 아니니까 말이다.
또한 16비트 시스템의 특성상 비록 매 프로세스의 종료 시점을 감지하는 건 불가능하겠지만, 그래도 아까 중복 실행되었던 A가 최후의 인스턴스까지 모두 종료되어서 B.DLL이 메모리에서 사라져야 하는 시점이 언젠가는 올 테니 말이다.

이것도 방법이 굉장히 기괴했다. DLL이 메모리에서 제거되기 전에 운영체제는 해당 DLL에서 'WEP'라는 이름을 가진 함수를 export 테이블에서 찾아서 그걸 호출해 줬다.

//16비트 시절에 _export는 오늘날의 __declspec(dllexport) 와 비슷한 단어임.
int FAR PASCAL _export WEP (int nExitCode);

이 함수 역시 성공하면 nonzero를 되돌리게 돼 있지만, 어차피 프로그램이 일방적으로 종료되는 상황에서 함수의 인자나 리턴값은 무시되다시피할 뿐 거의 의미가 없었다.
하다못해 오늘날 DllMain의 DLL_PROCESS_DETACH처럼 자신이 FreeLibrary에 의해 해제되는지, 프로세스의 종료에 의해 일괄 해제되는지라도 알 수 있으면 좋을 텐데 그 시절에 그런 정보를 바랄 수는 없었다.
참고로 WEP는 그냥 Windows Exit Procedure의 약자였다. -_-;;

이렇듯, 형태가 거의 바뀐 게 없는 WinMain과는 달리, DLL의 입구 함수는 16비트 시절과 지금이 달라도 너무 달라서 문화 충격이 느껴질 정도이다. 예전에도 16비트 Windows 프로그래밍에 대해서 글을 종종 쓰고 DLL에 대해서도 언급한 적이 있었는데 이런 내역에 대해서 정리한 적은 없었기 때문에 또 글을 남기게 됐다. 옛날에는 이렇게 불편한 환경에서 도대체 프로그램을 어떻게 만들었나 싶다.

LibMain과 WEP를 DllMain으로 통합한 것은 백 번 잘한 조치였다.
16/32비트 이식성을 염두에 둔 코드라면 DllMain에다가 LibMain과 WEP를 호출하고, 반대로 LibMain과 WEP에서 적절하게 서로 다른 인자를 줘서 DllMain을 호출하는 계층도 생각할 수 있으며, 과거에는 이런 관행이 실제로 존재했다고 한다. 마치 윈도우 프로시저와 대화상자 프로시저의 형태를 통합한 계층을 따로 만들어 썼듯이 말이다.

Posted by 사무엘

2016/05/27 08:38 2016/05/27 08:38
, ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1231

1.
코코아, Win32 API, MFC 같은 플랫폼 종속적인 API를 전혀 쓰지 않고 순수하게 표준 C/C++ 라이브러리 함수만으로 백 엔드 엔진만 만들었다 해도 Windows + Visual C++로 작성한 코드가 안드로이드 내지 맥 같은 다른 플랫폼에서는 곧장 컴파일 되지 않거나, 빌드된 프로그램이 의도한 대로 동작하지 않을 수 있다.

개인적으로는 회사에서 wchar_t 때문에 굉장한 불편을 겪었다. 잘 알다시피 Windows에서는 이게 2바이트이지만 다른 플랫폼에서는 4바이트이다. 플랫폼을 불문하고 2바이트 문자 단위로 동작하는 strcpy, strcat, strlen, printf, atoi 등등은 직접 구현이라도 해야 하는지..? 특히 파일로 읽고 쓰려면 말이다.

C++ string 클래스야 typedef std::basic_string<unsigned short> string16; 부터 먼저 만들어 놓고 썼다지만 그렇게 처음부터 객체를 만드는 게 아니라 raw memory를 다루는 상황에서는 해결책이 되지 못한다.

이런 게 원초적인 애로사항이고 또, 소스 코드 내부에서 유니코드 문자열 상수를 표현하는 방식도 또 다른 난관이다.
언어가 제공하는 L"" 문법은 wchar_t형 기반이다. 그러니 wchar_t 말고 명시적으로 unsigned short 배열에다가는 문자열 상수를 쓸 수 없고 "가"를 { 0xac00, }로 표현하는 식의 삽질을 해야 한다.

거기에다 비주얼 C++은 C++ 소스 코드나 명령 프롬프트가 UTF8과 전혀 친화적이지 않다는 다른 문제점도 있어 더욱 불편하다. 유니코드가 등장하면서 플랫폼별로 문자열을 다루는 방식이 너무 심하게 파편화됐다는 생각이 든다.
문자열을 저장하고 메모리를 관리하는 방식이 난립하는 것 말고(string class!) 문자열을 구성하는 문자를 표현하는 방식 그 자체부터가 말이다.

2.
하루는 Visual C++에서 표준 C 함수만 사용해서 만들어 준 코드를 안드로이드 내지 맥 OS 플랫폼으로 넘겨 줬더니 컴파일 에러가 났다. wcslen 함수가 선언되지 않았다고 꼬장을 부리는데 도무지 원인을 알 수 없었다. strlen은 인식되는데 wcslen은 왜 인식이 안 되는 거지?

그런데 알고 보니 wcslen은 strlen과는 달리 string.h가 아니라 wchar.h에 선언되어 있었다.
Visual C++은 string과 wchar에 wcslen을 모두 선언해 줬지만 타 플랫폼은 그렇지 않았다. 흐음~ 나의 불찰이다.

malloc/free 함수는 stdlib.h에도 있고 malloc.h에도 있다.
memset/memcpy는 string.h에도 있고 memory.h에도 있다.
그런 예가 몇 가지 있는 건 알고 있었지만 wcs* 함수는 Visual C++에만 string/wchar 겸용으로 선언돼 있었던 듯하다.
C 인클루드 헤더는 한 함수가 오로지 한 헤더에만 유일하게 존재하지는 않기도 하다는 것이 흥미롭게 느껴진다.

3.
요즘 표준 라이브러리들의 헤더 파일을 보면 함수의 인자마다 타입과 이름만 있는 게 아니라 소스 코드 정적 분석을 위한 Annotation 정보가 같이 들어있다. 같은 포인터라 해도 이건 읽기 전용, 쓰기 전용.. 쓴다면 어떤 조건으로 얼마만지 써지는지(옆의 인자만큼~) 같은 거.

그래서 함수 하나만 봐도 선언도 정말 덕지덕지 길어졌다. 이 정보들이 처음부터 있지는 않았을 텐데, 그 수많은 API들의 선언에다 일일이 다 기입하는 건 완전 중노동이었을 것 같다.
한때는 정적 분석 기능은 개발툴의 유료 최상급(엔터프라이즈 같은) 에디션에서나 접근 가능한 고급 기능이었는데, 이것도 죄다 무료로 풀리는 듯하다. 유료 GUI 툴킷이 통째로 MFC에 들어갔듯이 말이다.

4.
요즘은 CPU 아키텍처야 x86 아니면 ARM만 살아 남아서 그런지, 이식성을 논할 때 비트 순서, 일명 endian-ness 얘기는 별로 안 나오는 것 같다. 우리 주변에서 흔히 볼 수 있는 x86은 요지부동 리틀 엔디언인 반면, 옛날에 매킨토시의 밑천이던 PowerPC는 빅 엔디언이었다. 트루타입 폰트 포맷이 빅 엔디언 기반인 건 이런 애플의 영향력이 닿아서 그랬던 걸까?

먼 옛날 대학 시절에 터미널에 원격 접속해서 거기서 C 컴파일러를 돌려 봤던 게 본인으로서는 빅 엔디언 컴퓨터를 직접 구경한 처음이자 마지막 경험이다. 큰 자릿수가 앞부분부터 저장되다니 굉장히 신기했다. 이건 앞으로 수동 변속기 차량이라든가 IA64 (Itanium) 컴퓨터만큼이나 앞으로 또 접할 일이 없는 초희귀템으로 남을 것 같다.

최신 CPU인 ARM은 하드웨어 차원에서 endian-ness를 모두 지원하기 때문에 아무 쪽으로든 취사 선택이 가능하다고 한다. 사람으로 치면 완벽한 양손잡이이고, 철도에다 비유하자면 좌측/우측통행 전용 복선 철도가 아니라 어느 쪽으로든 운용 가능한 단선병렬과 비슷한 격이다.
결국은 다 지원하는 것으로 가는구나. 한글 코드에서 조합형/완성형 논쟁, CPU 미시구조에서 CISC/RISC 논쟁, 리눅스에서 그놈/KDE 셸 논쟁도 다 비슷한 방식으로 종결됐듯이 말이다.

비트 순서 같은 하드웨어 특성을 타는 요소 말고 소프트웨어 플랫폼과 언어 차원에서.. 사소하지만 코드의 이식성을 은근히 저해하는 요소는 내 경험상 몇 가지 있었다. 그러니 GUI가 없고 특정 운영체제의 API를 사용하지 않았다고 해서 무작정 이식이 잘 될 거라고 기대할 수는 없다.

5.
당장 떠오르는 건, 64비트 상수를 나타내는 % 문자가 파편화돼 있다(%I64d, %lld). 그리고 long이 Windows에서는 64비트 플랫폼에서도 여전히 32비트이지만 타 플랫폼은 그렇지 않다. 그러니 이식성을 생각한다면, long은 파일 오프셋 계산에 영향을 주는 곳에서는 절대로 구조체 멤버로 쓰이거나 sizeof의 대상이 돼서는 안 된다. (앞서 논했던 char_t도 마찬가지이고!) 그런 데서는 정말 닥치고 int32, uint64처럼 비트수를 명시한 typedef를 쓰는 게 안전하다.

C#이나 Java, D는 아무래도 1990년대 중후반에 PC에서 32비트 CPU 정도는 확실하게 정착한 뒤에 등장한 최신 언어이다 보니, 32/64비트 플랫폼을 불문하고 long이 처음부터 일관되게 64비트였다. 하지만 C/C++은 그보다 훨씬 전부터 컴퓨터 하드웨어의 발전의 격변기와 동고동락했던 언어이다 보니, 저런 깔끔함을 기대할 수는 없는 노릇이 돼 있다.

그리고, fopen에다 주는 옵션에서 r/w/a (+)만 있고 b/t 모드가 지정되지 않았을 때..
Windows는 binary 모드로 동작하는 반면 맥에서는(타 플랫폼은 확인 안 해 봄) 디폴트가 text였다. 멀쩡한 코드가 완전 엉뚱하게 동작하고 파일이 쓰라는 대로 써지지 않고 읽으라는 대로 읽히지 않아서 한창 문제를 추적했더니.. 결국은 이런 데에서 차이가 있었다. 이것도 표준 규격이 정의돼 있지 않나 보다.

말이 나왔으니 말인데, Visual C++은 fopen조차 쓰지 말고 fopen_s를 쓰라고 권한다. printf_s, qsort_s 같은 *_s 물건은 안전하고 편리하긴 하지만 언제까지나 이식 불가능한 Visual C++만의 전유물로 남을지 궁금하다..

strdup와 _wcsdup는 표준처럼 생겼지만 진짜 표준인지 아닌지 알쏭달쏭한 놈이다. 앞에 괜히 밑줄이 있는 게 아니다. _wtoi 이런 것도 Windows를 벗어나면 컴파일되지 않을 가능성이 높은 지뢰이니 strtol, wcstol을 쓰는 게 안전하다.
strtok의 경우 Visual C++은 토큰 컨텍스트를 따로 받는 _s 버전을 추가한 반면, 타 플랫폼은 strtok, wcstok 함수 자체가 그렇게 고쳐진 것도 있다. 이런 것들도 너무 골치아프다.

6.
끝으로, 이건 이식성하고는 큰 관계가 없는 얘기다만..
형변환 연산자인 static_cast는 코드 생성 차원에서 하는 일이 전혀 없거나(base class* → derived_class*, enum → int), 뻔한 값 보정(float → int, char → int), 또는 다중 상속일 때는 컴파일 타임 때 결정된 고정된 상수만치 this 포인터 보정 정도만(derived_class_B* → base_class) 하는 걸로 으레 생각했다.

그런데 다중 상속을 다룰 때 꼭 그런 일만 하는 건 아니다. 포인터가 처음부터 NULL이었다면, 거기서 또 얼마를 뺄 게 아니라 cast된 포인터도 그냥 NULL을 주는 예외 처리를 해야 한다. 과연 생각해 보니 그렇다. 아래 코드를 생각해 보자.

struct A { int a,b; };

struct B { int c,d; };

struct C: public A, public B { int e,f; };

void foo(B *pm) { printf("Received %p\n", pm); }

int main()
{
    C m, *pm=&m;
    printf("Passing %p\n", pm); foo(pm);
    pm=NULL; printf("Passing %p\n", pm); foo(pm);
    return 0;
}

단일 상속과는 달리, 다중 상속에서 passing의 값과 received의 값이 서로 달라질 수 있다고 아는 것은 하나를 아는 것이다.
그러나 NULL일 때는 다중 상속이더라도 언제나 NULL이 유지된다는 것이 함정이다. 우와.. 지금까지 한 번도 그런 경우를 생각한 적이 없었는데.. 꽤 충격적이다. 간단하지만 다중 상속의 보이지 않는 오버헤드를 보여주는 요소 중 하나이다.

Posted by 사무엘

2016/05/13 08:29 2016/05/13 08:29
, ,
Response
No Trackback , 5 Comments
RSS :
http://moogi.new21.org/tc/rss/response/1226

« Previous : 1 : ... 7 : 8 : 9 : 10 : 11 : 12 : 13 : 14 : 15 : ... 23 : Next »

블로그 이미지

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

- 사무엘

Archives

Authors

  1. 사무엘

Calendar

«   2024/04   »
  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        

Site Stats

Total hits:
2679861
Today:
1945
Yesterday:
2484