« Previous : 1 : ... 24 : 25 : 26 : 27 : 28 : 29 : 30 : 31 : Next »

사용자 삽입 이미지
위의 그림은 동일한 32비트 윈도우용 프로그램을 세 개 연달아 실행하여
1. 자신의 인스턴스 핸들
2. 어떤 지역 변수의 주소
3. 어떤 전역 변수의 주소
4. 그리고 동일한 공유 메모리(memory-mapped file)를 가리키는 주소
를 차례로 찍은 것이다.

그림을 보면 알겠지만 동일한 실험을
a. 윈도우 3.1+Win32s
b. 윈도우 9x
c. NT급 윈도우

에서 모두 해 봤다. (요즘 버전의 비주얼 C++로 그 구닥다리 Win32s에서도 동작하는 프로그램을 만들려면, 컴파일/링크 옵션을 상당히 특이하게 바꿔야 한다. ㄲㄲ)

Win32s의 한계를 절실히 느낄 수 있을 것이다.
CPU의 가상 메모리 기능을 적극 활용하여 각 프로세스마다 자신만의 주소 공간이 절대 보장되는 윈도우 NT에서는,
같은 프로그램은 아무리 동시에 여럿 실행하더라도 자기 주소가 0x400000으로 고정 불변임을 알 수 있다. 심지어 윈도우 9x조차도 그건 보장된다.

그러나 Win32s는 프로그램을 실행할 때마다 프로그램의 인스턴스 핸들이 제각각이며, 지역 변수와 전역 변수의 주소조차도 완전히 달라진다. 시스템의 모든 프로그램들이 단일 주소 공간을 공유한다는 게 바로 저런 의미인 것이다.

Win32s는 모든 메모리 주소가 0x80000000 위의 상위로 잡혀 있는 것도 매우 신기하다.
9x나 NT급 윈도우에서는 그런 주소는 사실상 커널에서나 볼 수 있기 때문이다.
16비트 운영체제에다 아주 특수한 임시방편으로 32비트를 구현한 Win32s의 동작 방식을 짐작케 한다.

또 하나 재미있는 차이를 발견할 수 있는 것은 인스턴스 핸들과 포인터와의 관계이다.
9x/NT에서는 인스턴스 내지 모듈 핸들이 곧 포인터이기 때문에, 0x400000 같은 값에 해당하는 메모리 주소를 들여다보면 EXE 파일이 통째로 로드된 흔적을 고스란히 찾을 수 있다. 즉 MZ 같은 EXE 헤더가 바로 나타난다는 뜻이다. 그리고 전역 변수의 주소는 역시 근처의 0x40????대로 잡힌 것을 볼 수 있다.

그러나 Win32s의 인스턴스 핸들은 포인터와 아무 관계가 없는 임의의 16비트 정수일 뿐이다. 이는 원래부터 포인터가 서로 아무 관계가 없던 16비트 윈도우의 인스턴스 핸들과 개념을 일치시키기 위한 조치로 보인다. 제아무리 32비트 프로그램이라 하더라도 16비트 운영체제 내부에서는 16비트 규모로 식별이 가능해야 하기 때문이다.

끝으로, 공유 메모리의 주소도 흥미로운 결과가 나와 있다. 오로지 윈도우 9x만이 세 프로그램이 가리키는 주소가 모두 일치해 있다.
이는 윈도우 9x만의 메모리 사용 방식 때문이다. 0x80000000~0xC0...에 해당하는 영역에다 모든 프로그램들이 공유하는 운영체제 시스템 DLL과 공유 메모리를 올려 놓는다. 즉, 이 영역은 윈도우 9x에서는 아무 프로그램이나 바로 접근할 수 있는 단일 주소 공간이나 마찬가지이기 때문에 어느 프로그램에서나 의미가 동일한 셈이다.

NT는 그렇지 않다. 비록 실질적으로 가리키는 물리 메모리는 동일한 위치인지 모르나 이를 가리키는 응용 프로그램의 주소는 완전히 제각각이다. 이렇게 하는 게 훨씬 더 안전하고 보안 관점에서도 유리하기 때문이다.

Win32s는 공유건 응용 프로그램의 코드건 데이터건 가리지 않고 무조건 0x80... 상위 메모리 주소가 뒤죽박죽으로 쓰이는데, 공유 메모리마저 9x와는 달리 제각각인 주소가 배당되는 건 좀 의외이다. 9x는 공유 메모리만 상위 메모리 주소가 쓰였고 NT는 보다시피 상위 메모리 주소가 전혀 등장하지 않는다. 사용자 계층과 커널 계층이 엄격하게 잘 분리되어 있음을 뜻한다.

※ 덧붙이는 말

1. 유니코드

일단 Win32s와 9x는 운영체제의 내부적으로는 유니코드 기반이 전혀 아니다. 그래도 9x는 GDI 계층 차원에서 유니코드 문자를 폰트로부터 인식하고 찍는 건 지원하며 98부터는 유니코드 IME 프로토콜까지도 지원한다. 그 반면 Win32s는 운영체제의 한계 때문에 그런 게 전혀 없다. 운영체제 차원에서 임의의 유니코드 문자를 출력할 방법이 없다는 뜻이다.
오늘날까지 살아남은 NT는 무려 1993년에 나온 3.1 버전부터 애초에 100% 유니코드 지원을 염두에 두고 16비트 wide string을 기본으로 설계했으니 과연 대인배. 물론 이렇게 하려면 메모리가 더 많이 필요하다.

9x는 TextOutW, ExtTextOutW, GetTextExtentPoint32W 같은 GDI 함수는 NT와 기능이 동일하다(비록 surrogate는 지원 안 하지만). 그리고 MessageBoxW도 지원한다. 에러 메시지 뱉고 죽는 최소한의 동작만은 유니코드 함수로도 가능하게 배려했다는 뜻이다.
이외에 리소스를 찾는 FindResourceExW, 명령 인자 옵션을 얻어오는 GetCommandLineW 같은 함수가 유니코드 버전도 간단히 구현돼 있다. 비록 문자열을 ansi 문자열로 변환해서 A 함수를 그냥 호출해 주는 수준이지만.

Win32s는 그런 거 없다. MessageBoxW도 지원하지 않으며, 오로지 WideCharToMultiByte(와 그 역함수) 처럼 문자열 변환 함수만 지원되고 나머지 W 함수는 전혀 지원되지 않는다.

2. GDI/User 계층의 32비트

NT는 순수한 32비트 운영체제인 반면 9x 계열은 아직 상당수의 코드가 16비트로 존재했다. 이런 이유로 인해 9x는 대표적으로 GDI의 좌표계가 16비트 크기로 제한되어 있었으며, NT는 GDI 함수의 실행이 실패했을 때 GetLastError() 에러값이 온 반면, 9x 계열은 그렇지 않은 경우가 많았다. 그 에러값은 32비트 코드 계층에서 설정되는 값이기 때문이다.

과거 16비트 윈도우(Win32s도 당연히 포함)에서는 어떤 GDI 오브젝트 자체와 그 오브젝트가 따로 동적으로 할당하는 메모리의 양이 모두 GDI 힙 64KB 내부에 매여 있었다면,
9x는 일단 할당 가능한 오브젝트의 개수는 64KB의 제약을 받으나 각 오브젝트가 추가로 할당하는 메모리는 ‘별도의 제약이 없는 32비트 범위’인 식으로 제한 조건이 완화된 경우가 많았다. 가령, 복잡한 path나 region 같은 경우 추가 메모리 사용량이 만만찮으리라 예상 가능하다.

제약이 완화되기는 했으나 그래도 9x는 공포의 ‘리소스 제한’이 여전했던 것이다. 리소스 퍼센티지를 아직 기억하시는 분 계시려나?
NT는 역시 애초부터 그런 개념이 없었다. 모든 게 자유로운 32비트 공간이고 지금은 64비트로 자연스럽게 확장되어 있기까지 하다. ^^;;

Posted by 사무엘

2010/05/07 07:45 2010/05/07 07:45
,
Response
No Trackback , 6 Comments
RSS :
http://moogi.new21.org/tc/rss/response/261

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

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

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

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

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

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

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

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

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

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

Posted by 사무엘

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

컴퓨터 구조를 공부하면서 배우는 기본 개념 중 하나는, ‘컴퓨터 내부에서 숫자가 표현되는 원리’이다.
부호 있는 정수는 소위 말하는 ‘2의 보수’ 형태로 표현되는데, 이것은 모든 비트가 1인 숫자는 -1이 되는 형태이다. 이런 방식을 쓰는 이유는 연산 회로를 설계할 때, 뺄셈을 덧셈의 변형만으로 매우 손쉽게 구현할 수 있는 체계이기 때문이다.

베이직 언어는 다른 언어들과는 달리 TRUE의 값이 -1인데, 그 이유가 바로 이런 컴퓨터의 구조와 관련이 있다. 베이직은 비트 연산자와 논리 연산자의 구분이 없기 때문이다.
0 아니면 1밖에 모르는 디지털 컴퓨터는 연속이나 무한 같은 개념을 표현할 수 없다. 숫자도 정수만 다루는 데 익숙하다. 그러나 현실 세계에서 발생하는 문제를 해결하려면 소숫점이 동반된 실수를 다뤄야 할 일도 매우 자주 발생한다.

소수를 표현하는 가장 간단한 방법은 소위 ‘고정소수점’이다. 가령 32비트 고정소수점의 경우, 정수와 완전히 똑같은 방법으로 숫자를 표현하되 실제로는 그 수의 의미를 정수를 16비트 크기만큼(65536) 나눈 것으로 인식하는 것이다. 즉, 수의 정밀도만 1이 아닌 1/65536으로 기계적으로 높아지는 셈.

고정소수점은 일단 덧셈· 뺄셈· 비교 연산을 정수와 완전히 동일한 방식으로 할 수 있어서 처리 속도가 매우 빠르며, 곱셈과 나눗셈을 할 때만 약간 주의해서 자릿수 정돈을 하면 되니 편하다. 실제로 일부 벡터 그래픽이나 글꼴 쪽 분야에서는 이런 고정소수점 방식이 잘 쓰이고 있다. 그러나 표현 가능한 수의 범위가 매우 심각하게 제한을 받기 때문에 범용성은 떨어진다.

자리수의 제약을 받지 않고 소수점을 좀더 자유롭게 표현하려면, 유효숫자와 자릿수를 따로 둘 필요가 있다. 그게 훨씬 더 실용적이다. 부동(floating)이라는 개념이 여기에서서 나왔다. 제일 큰 자리수의 숫자가 무엇이며 그 뒤에 0이 몇 개 붙느냐가 중요한 것이다.

이런 체계에서는 10.5에서 0.5는 제대로 표현할 수 있지만, 10000000.5에서 0.5는 손실될 가능성이 커진다. 그리고 한 숫자를 결국 두 수의 조합으로 표현해야 하므로, 각종 연산이 단순 정수보다 훨씬 더 느리고 힘들어진다.

옛날에는 이런 부동소수점 계산을 하드웨어 회로 차원에서 바로 해 주는 코(보조)프로세서가 별도로 존재했다. fdiv, fmul 같은 인스트럭션. 심지어는 제곱근이나 삼각함수 값까지 바로 구하는 명령이 있다!
하지만 시중에는 그런 게 없는 컴퓨터도 있다는 얘기이기 때문에, 1990년대에 상업용으로 쓰이던 어지간한 컴파일러들을 보면 소프트웨어적으로 부동소수점 계산을 흉내 내는(엄청 느리지만-_-) 코드를 추가할지를 지정하는 옵션도 있었다.

그런데 부동소수점을 표현하는 방식 자체가 통일돼 있어야 그 기준에 따라 코프로세서를 만들든지 말든지 할 수 있을 것이다. 그 표준 규격이 바로 IEEE754이다. 1985년에 제정되었다.

이 규격은 좀 정밀도가 떨어지지만 처리 속도가 더 빠른 32비트와, 용량이 넉넉하지만 역시 속도의 압박이 있는 64비트로 나뉜다. CPU 단위가 16비트이고 부동소수점을 소프트웨어적으로 처리하는 게 보편적이던 옛날에는, 아예 컴파일러 재량으로 소프트웨어적으로 구현한 48비트(6바이트-_-.. 파스칼에서 Real) 실수와 80비트(C/C++에서 long double) 실수도 존재하였으나 지금은 완전히 흑역사가 되었다. 요즘은 화면 픽셀 크기도 24비트는 존재하지 않으며 32비트이다. 죄다 컴퓨터가 처리하기 편한 단위로 그냥 확장된 듯하다.

제일 간단하게 말하자면, 32비트 실수에서는 1비트는 이 수의 부호를, 다음 8비트는 지수를, 다음 나머지 23비트는 유효숫자를 나타낸다. 64비트 실수에서는 그 비율이 1:11:52이다.
컴퓨터가 사용하는 부동소수점의 지수의 밑은 너무 당연한 말이지만 10이 아니라 2이다. 따라서 2의 거듭제곱의 역수가 아닌 모든 소수들은 끝자리가 잘린 ‘순환소수’가 된다! 0.25, 0.125, 0.5 같은 수가 아닌 다른 모든 수들.. 0.1, 0.2, 0.3 이런 것들은 화면에서는 적당하게 근사되어 표현되었다 할지라도 실제로는 그 수의 100% 정확한 형태로 저장되지 않은 셈이다. 부동소수점의 한계를 분명히 알고 있어야 한다.

어쨌거나.. 2^23과 2^52에다 base가 10인 로그를 씌워 보면 32비트 실수의 유효숫자 정밀도는 약 7자리, 그리고 64비트 실수의 정밀도는 약 15자리라는 걸 알 수 있다. 그리고 표현할 수 있는 자리수의 범위는 32비트는 약 38자리, 그리고 후자는 약 308자리이다(비트가 3개 더 늘어서 2^3인 8배가 더 늘었으므로). 소수점 밑으로도 그만치 내려갈 수 있다.

까놓고 말해 이런 공용체를 통해 부동소수점을 쉽게 해부할 수 있다.

union REAL {
   float fValue;
   struct {
      unsigned sign: 1; //부호
      unsigned expo: 8; //지수부
      unsigned mantissa: 23; //가수부
   };
};

공용체와 비트 필드가 존재하는 C/C++ 만만세. ㄲㄲ
단, 우리가 쓰는 인텔 CPU는 little endian이므로 sign, expo, mantissa 순이 아니라 반대로 mantissa, expo, sign으로 멤버 순서만 바꿔 주면 된다. 쉽죠?

그런데 지수부와 가수부 사이에 아무런 제약을 가하지 않을 경우 동일한 숫자를 여러 방법으로 표현할 수 있게 되어 문제가 생긴다. 4를 2의 2승, 4의 1승 이런 식으로 표현하듯이 말이다. 그래서 IEEE754는 가수부는 가장 큰 자리수의 비트값이 무조건 1인 것만 인정하게 하고 그 1은 명시적인 표기를 생략했다. 이를 ‘정규화’된 형태라고 한다.

거두절미하고, 32비트 부동소수점에서 구조체의 각 멤버로부터 부동소수점 값을 다시 얻어 오는 식은 다음과 같으므로 참고하자. 식에서 23과 24는 가수부의 자릿수 23비트와 관계가 있으며, 126은 지수부의 크기 8과 관계가 있다. 2의 8-1승보다 2 더 작은 값이다. 1<<23이 바로 생략된 최대 자리수인 셈이다.
그저 의미를 알 수 없는 블랙박스 같던 부동소수점이 좀더 친근하게 와 닿을 것이다.

pow(2.0, (double)( (int)a.expo-24-126))* ((1<<23)|a.mantissa) * (a.sign ? -1:1)

IEEE754에는 이외에도 무한대를 표현하거나 불능을 뜻하는 규격이 있다. 정수를 0으로 나누면 CPU 차원에서 바로 exception이 일어나지만 부동소수점은 에러는 안 나고 저런 특수한 숫자가 돌아온다.
또한 정규화를 해서 자리수를 강제로 늘리는 바람에 표현할 수 없게 된 일부 극히 작은 수를 별도로 표현하기 위해 내부적으로 심지어 ‘고정소수점’ 방식을 임시로 사용하는 규정조차 갖추고 있다. 마치 퀵 정렬이 작은 구간에서는 삽입 정렬을 사용하기도 하는 것처럼 말이다. 이에 대한 더 이상의 자세한 설명은 생략하겠다.

참고로,
1. IEEE754 부동소수점에는 구조적인 한계로 인해 0이 +0과 -0 이렇게 두 개 존재한다.
2. 역사상 최초의 전자식 컴퓨터로 일컬어지는(논란의 여지는 좀 있지만) 에니악은 10진법을 사용했다! 전자 신호 4비트(=16)로 10진법 숫자 하나를 표현했을 것이고 매우 비효율적으로 동작했을 것이다. 하지만 그 당시엔 이 기계로 탄도 계산도 하고 풍동 실험도 하고 심지어 일기 예보도 했었다.

Posted by 사무엘

2010/04/22 22:53 2010/04/22 22:53
, ,
Response
No Trackback , 7 Comments
RSS :
http://moogi.new21.org/tc/rss/response/251

C++에서 A라는 클래스를 만들었다. 이 클래스는 앞으로 당신이 만들 거의 모든 클래스들이 상속 받을 아주 기본적이고 공통적인 기능을 갖추고 있다. COM으로 치면 IUnknown, MFC로 치면 CObject 같은 기능을 하는데, 여기서는 그 예로 자체적인 reference counting 기능을 내장하고 있다고 치자.

class A {
 int nRefCnt;
public:
 A(): nRefCnt(1) {}
 ~A() {}
 int AddRef() { return ++nRefCnt; }
 int Release() { int nt=--nRefCnt; if(nt==0) delete this; return nt; }
};

이제 당신은 A로부터 상속 받은 여러 클래스들을 만든다.

class B: public A {
public:
 int nAddVal;
 B(): nAddVal(2) {}
};

class C: public A {
public:
 int nExitVal;
 C(): nExitVal(3) {}
};

그런데 C++에는 다중 상속이라는 게 존재한다.
어쩌다 보니, A의 자식 클래스들 중 서로 다른 클래스를 골라서 이들의 기능을 다 물려받은 클래스를 만들고 싶어진다. (욕심도 참 많다!)

class D: public B, public  C {
public:
 int nLast;
 D(): nLast(4) {}
};

여기서 문제가 생긴다.
이 경우, D는 B와 C의 기능을 물려받는 과정에서, B와 C가 공동으로 소유하는 A는 두 번 상속받게 된다.
32비트 기준으로 obj의 멤버 배열 순서는 대략 "1, 2, 1, 3, 4" 정도가 된다.

D obj;
obj.AddRef();

이 코드의 실행 결과는 어떻게 될까?
고민할 필요 없다. 이 코드는 컴파일 자체가 되지 않을 테니 말이다. =_=
D라는 클래스에는 A의 nRefCnt라는 멤버 자체가 둘 존재한다.
그렇기 때문에 B 쪽에 속하는 nRefCnt를 건드릴지, C 쪽에 속하는 nRefCnt를 건드릴지 판단할 수 없어서 컴파일러는 모호성 오류를 일으키는 것이다.
클래스 가계도는 보통 tree 구조가 되는데 이 경우 엄밀히 말하면 cycle이 존재하게 된다. 이 cycle을 일명 '죽음의 다이아몬드'라고 부른다.

obj.B::AddRef() 내지 obj.C::AddRef()라고 구문을 바꾸면 컴파일 에러 자체는 없앨 수 있다.
그러나 이것은 미봉책일 뿐이지 문제를 본질적으로 해결하는 방법은 아닐 것이다.
이건 클래스 B와 C가 아무 공통분모 없이, 우연히 AddRef라는 껍데기만 동일하고 의미는 완전히 다른 함수를 제각기 갖고 있는 것과 다를 게 없는 상황이다.
근본적으로는 D라는 클래스는 비록 B와 C의 기능을 동시에 상속 받았더라도 A는 단 한 번만 상속 받게 하는 방법이 있어야 한다. 그게 가능할까?

그래서 C++은 '가상 상속'이라는 걸 제공한다.
일반적으로 B라는 클래스가 A라는 클래스로부터 상속을 받았다면, B라는 클래스는 내부적으로 A의 몸체 뒤에 자기 몸체가 덧붙는다. 따라서 B 클래스의 오프셋과 A 클래스의 오프셋 사이의 간격은 컴파일 시간 때 딱 결정이 되어 버리며 언제나 고정 불변이다.

그런데 B가 A를 상속 받으면서 A를 '가상'으로 상속하면, B 클래스로부터 A 클래스의 오프셋은 자기가 별도의 내부 멤버로 갖고 있게 되며, 컴파일 시점이 아니라 실행 시점 때 동적으로 바뀔 수 있게 된다. 기반 클래스가 특수한 처리를 하는 게 아니라, 상속을 받고 싶어하는 자식 클래스가 상속을 특수한 방법으로 받아야 한다.
그래서 위의 네 클래스 A~D 중, 죽음의 다이아몬드를 해소하기 위해서는 B와 C가 A를 virtual로 상속 받게 하면 된다.

class B: virtual public A { ... };
class C: virtual public A { ... };

이 경우 B와 C는, 기반 클래스인 A가 자신과 메모리 상으로 굳이 연속적으로 이어져 있지 않더라도, B나 C 자신이 스스로 갖고 있는 부가 정보를 통해 기반 클래스인 A의 위치를 추적할 수 있다.

클래스 C만 갖고 생각하는 경우, 당연히 메모리 상으로는 A와 C가 바로 따를 것이고, C 내부에 있는 A의 포인터는 자기 바로 앞을 가리키고 있을 것이다.
하지만 클래스 D는 멤버가 ABCD와 같은 순으로 쫙 배열될 수 있으며, A와 C 사이에 B 같은 다른 클래스가 있을 수 있다. 그래도 B와 C가 A를 공유할 수 있게 된다. 공유를 하려고 이 지저분한 짓을 자처한 것이다.

자, 그럼 가상 기반 클래스의 구현 비용은 어느 정도 될까? C++에서

ptr->Function(a, b);

이라는 문장이 있고 Function이 virtual이 아닌 일반 클래스 멤버 함수라면, 위의 코드는 C 언어 문법으로 표현했을 때 대략

Function(ptr, a, b);

이 된다. 즉, this만 암묵적으로 추가되고 일반 함수와 완전히 똑같은 형태이다. 가장 간단하다.
하지만 Function이 가상 함수라면,

ptr->functbl->Function(ptr, a, b);

과 같은 꼴이 되고 오버헤드가 꽤 커진다. 우리 멤버가 가리키는 공용 가상 함수 테이블로 가서 거기서 함수 포인터를 참조하는 셈이다.
그렇다면 Function이 ptr이 가상으로 상속 받은 기반 클래스의 비가상 멤버 함수라면,

Function(ptr + ptr->baseptr, a, b);

정도. 가상 함수 정도는 아니지만 그래도 this 포인터의 위치 계산을 위해서 두세 개의 명령 오버헤드가 추가된다.
일단 다중 상속이라는 개념 자체가 컴파일러 문법상의 단순 형변환일 뿐이던 typecasting을 굉장히 복잡하게 만들었다는 점을 알 필요가 있다.

그럼 Function이 가상 상속에다가 가상 함수이기까지 하면 어떻게 될까?
그래도 functbl을 찾는데 오버헤드가 더해지지는 않는다. 어차피 각 클래스의 함수 테이블에는 자기가 지금까지 상속 받은 모든 클래스의 가상 함수들이 누적 기록되어 있기 때문에, 함수 호출을 위해 여러 테이블을 돌아다니지는 않는다.

참고로 A에 가상 함수가 있고 B와 C가 이를 제각기 오버라이드를 했는데 D가 B와 C를 동시에 상속 받고도 그 가상 함수를 또 오버라이드하지 않았다면, 컴파일 에러가 난다. B와 C 중 어느 장단에 맞춰 춤을 추리요? C++ 컴파일러는 그 정도 모호성은 자동으로 지적해 준다. C++ 컴파일러 만들기란 정말 힘들겠다는 생각이 들지 않는가?

가상 함수, 가상 상속, 멤버 함수 포인터... =_=;;
오늘날 프로그래밍 업계에서 다중 상속은 굉장히 지저분하고 흉악한 개념으로 간주되어 금기시되고 있다. C언어의 전처리기, 포인터와 더불어 현대의 프로그래밍 언어에서는 찾을 수 없는 면모가 되고 있다.
여러 클래스의 기능이 한꺼번에 필요하면, 무리하게 상속으로 해결하지 말고 해당 클래스 개체를 '멤버'로 가지는 쪽으로 가라는 것이다.

다중 상속이라든가 가상 상속이 골치아픈 게 결국은 데이터 멤버들의 오프셋 계산 때문이다. 하지만 가상 함수만 잔뜩 만드는 것은 그 클래스 자체가 아닌 함수 테이블의 덩치를 대신 키우는 것이고 클래스 자체는 가상 상속 같은 복잡한 테크닉을 필요로 하지 않기 때문에, 대다수 현대 객체 지향 언어들은 '인터페이스'라는 개념을 도입하여 이것으로 다중 상속의 기능을 어느 정도 대체하고 있다.
사실, C++의 각종 어려운 OOP 개념을 실제로 구현하는 데 비용이 얼마나 드는지를 잘 이해하고 있는 것만으로도 상당한 수준의 프로그래밍 내공을 쌓을 수 있다!

Posted by 사무엘

2010/04/14 17:19 2010/04/14 17:19
,
Response
No Trackback , 4 Comments
RSS :
http://moogi.new21.org/tc/rss/response/245

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Posted by 사무엘

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

C는 작고 쪼잔하고 오덕스럽게 만들어진 언어이다(이런 특성을 상당수 물려받은 C++도 포함). 문법에서도 이런 면모가 발견되는데, 가능한 한 예약어 개수를 줄이고 연산자와 기호만으로, 그리고 이 토큰이 쓰인 주변 문맥을 통해서 구문의 의미가 파악되도록 언어를 설계한 것이다.

비슷한 계열의 구조화 프로그래밍 언어인 파스칼과 비교하면 문법이 얼마나 극단적으로 다른지 알 수 있다. begin end 대신 간단히 { } 이다. function, procedure처럼 서브루틴을 나타내는 예약어가 따로 존재하지 않는다. 그냥 자료형과 ()가 함수를 나타내며, 아예 void라는 예약어가 따로 존재한다. var 같은 예약어도 없이 변수 선언이 바로 가능하다. forward 같은 예약어가 없어도 함수의 선두 선언이 가능하며, 별도의 array 예약어가 없이 배열을 선언할 수도 있다. 순수 가상 함수를 선언하는데  pure 같은 별도의 예약어를 추가한 게 아니라 그냥 함수 = 0이란 표현으로 대체한다. 이게 바로 C++의 사고방식이다.

이렇게 극도로 함축적인 문법 덕분에, 프로그래머는 일단 타이핑을 덜 해도 되니 좋다. 1970년대에는 언어도 기계 저수준 프로그래밍을 위해 한없이 쪼잔해져야만 했던 때임을 기억할 필요가 있다. 그 시절에 무슨 인텔리센스라든가 코드 자동 완성 같은 사치스러운 기능이 있었단 말인가?

하지만 이런 언어 구조 때문에 C, 특히 C++은 코드를 알아보고 구문 분석하기가 무척 까다로운 언어가 되고 말았다. 사람에게만 힘든 게 아니라 컴파일러 입장에서도 말이다. 단순히 암호 같은 포인터 참조와 연산자 남발 때문에 알아보기 어려운 차원이 결코 아니다.

전산학적으로 말하면 C/C++의 문법은 문맥 자유 문법이 아니다. 가령 C++ 언어의 global scope에서,

a b(c, d);
위의 문장은 C++의 경우 함수의 선언일까, 아니면 개체의 선언일까?

a<100> b;
그리고 위의 문장은 템플릿을 이용한 개체일까, 아니면 비교 연산일까?

즉, a~d의 타입이 무엇이냐에 따라 구문의 의미, 즉 파싱 방법이 완전 극단적으로 달라진다. 마치 보는 방식에 따라 GOOD으로도 보이고 EVIL로도 읽히는 중의적인 그림처럼 말이다.
C++의 문법은, 의미를 파악하기 위해 파싱을 하는데 각 토큰의 의미를 모르면 제대로 파싱을 할 수 없는 그런 구조인 것이다!

그렇기 때문에 C++ 코드는 IDE 차원에서 간단한 인텔리센스나 자동 완성 기능만 구현하기 위해서라도 코드를 전부 읽어서 사실상 컴파일을 해 봐야 한다. 게다가 전처리기를 거쳐서 #define 심볼까지 일일이 벗기면서 말이다. C#이나 자바는 C++과 매우 유사한 구문을 갖고 있고 똑같이 { } 블록 구조이지만, 문맥 자유 문법을 갖추고 있으며, 의미 분석이 C++보다 훨씬 더 간단하다.

파스칼은? 더 말이 필요 없다. 소스 코드를 단 한 번만 읽으면서 앞으로 되돌아갈 필요조차 없이 구문 분석 + 코드 생성이 다 되는 구조이다! 물론 같은 의미를 표현하더라도 C/C++보다 거추장스럽고 프로그래머가 불편한 게 더 많긴 하지만 말이다.

자바와 C#이 C++에 존재하는 모호성을 없앤 것 중 하나는 new 연산자이다.
생성자 함수 호출을 동반하는 개체는 무조건 new로 선언하게 되어 있기 때문에, new가 동반되지 않은 a b(c, d) 같은 구문은 일단 개체 선언은 절대 아니고 함수 선언이라고 보장할 수 있는 것이다.

C/C++의 문법을 더욱 문맥 의존적이고 지저분한 판타지로 바꾼 것 중 하나는 type casting이다. 별도의 type casting 연산자나 예약어가 존재하는 게 아니라 그냥 앞에다가 타입 이름을 써서 괄호로 싸는 걸로 형변환이 되게 만들어 버렸으니 원...  (a)+b 라는 구문에서 +는 a가 무엇이냐에 따라서 이항 연산자일 수도 있고 아닐 수도 있다. 포인터의 의미를 겸하고 있는 * 까지 가면 더욱 복잡해진다.

게다가 C++에서는 생성자 변환 스타일까지 허용되니 더욱 지저분해졌다! (type)value 뿐만 아니라 type(value)까지 된다는 소리. 이런 어정쩡한 문법 때문에, 소스 코드에서 명시적인 형변환이 일어나는 곳만 딱 찾기도 곤란하다는 점 역시 큰 문제였다.

보다못해 1990년대 중반에는 C++에 4종류의 별도의 형변환 연산자가 예약어로 추가됐다. static_, dynamic_, reinterpret_, const_로 시작하는 cast 연산자가 그것이다. 취지는 좋은데 C언어 철학과는 전혀 어울리지 않을 정도로 예약어 길이가 너무 긴 게 흠이다.

C++은 C언어의 호환성을 존중하여 설계되었지만, 그렇다고 해서 C의 strict superset으로 설계된 것도 아니다. 일부 문법은 바뀌거나 더 엄격해졌기 때문에, 어떤 C 코드는 C++ 언어 문법으로는 컴파일이 되지 않는다. C 영역과 C++ 영역을 엄밀하게 분리한 것도 아니고 그냥 어중간하게 C에다가 OOP 개념을 집어넣다 보니 문법은 더욱 복잡해지고, 특히 동일한 개념을 나타내는 문법이 여럿 존재하는 등( (int)a, int(a)라든가, 포인터와 참조자 중복처럼 ㅋㅋ), 참을 수 없는 지저분함에 환멸을 느끼는 프로그머도 존재할 정도이다.

그래도 오늘날까지 컴퓨터와 직통으로 네이티브 대화가 가능한 가장 강력하고 효율적인 언어라는 장점, 오로지 그거 하나 때문에 C/C++은 메이저급 언어로 군림 중이다. 더 깔끔하고 수학적으로 엄밀한 프로그래밍 언어를 좋아하는 사람이라면 이런 현실을 결코 달갑게 여기지 않겠지만 말이다.

Posted by 사무엘

2010/04/10 19:20 2010/04/10 19:20
,
Response
No Trackback , 5 Comments
RSS :
http://moogi.new21.org/tc/rss/response/241

C/C++ 언어는 언어 차원에서 자체 제공하는 문자열 타입이란 게 없다.
문자열은 단순히 문자의 배열 내지 이를 가리키는 포인터로 취급되며, 코드 번호 0인 문자가 문자열의 끝을 나타내는 매우 원시적이고 단순한 방법을 사용하고 있다. 그 이름도 유명한 null-terminated string 기법이다. (이하 편의상 NTS로)

깔끔하고 쓰기 편한 문자열 처리를 제대로 구현하는 데 드는 비용을 감안한다면, 이것은 그렇게 나쁜 방법은 아니다. string은 int보다야 처리하기가 훨씬 무거운 건 사실이다. 더구나 C/C++은 정말 1바이트라도 더 아끼고 한 클럭이라도 더 줄여야 하는 시스템 프로그래밍 용도로 개발된 언어인 것이다.

NTS 방식으로 문자열을 다루지 않는 언어도 있다. 이런 언어는 문자열의 길이는 별도의 장소에 보관하며 스트링의 내부에 0번 문자가 있을 수도 있다. 그런데 이런 언어로 프로그램을 짜더라도, NTS를 받는 운영체제 API에다 문자열을 넘겨줄 때는 뒤에다 0번 문자를 손수 추가하여 문자열을 변환하곤 한다. 운영체제 커널이야말로 C 언어 기반인 경우가 태반이기 때문이다.

하지만 다른 모든 곳에는 NTS 문자열 포인터만 받더라도, 그래픽 API는 비록 C언어 스타일이라 할지라도 이례적으로 문자열 포인터뿐만 아니라 문자열의 길이를 별도의 인자로 따로 받는 경우가 많다. 윈도우 API도 그 대표적인 예인데, 그 이유는 명백하다. 그래픽 처리의 특성상 NTS 문자열을 일부 몇 글자만 찍어야 하는 경우도 빈번하게 발생하기 때문이다.

NTS는 간단하고 효율적인 대신, 문자열의 끝을 언어나 프레임워크 차원에서 보장을 하지 않는다는 특성 때문에 오늘날 버퍼 초과 같은 보안 문제의 온상이 되어 있다. 가령, C 표준 함수 중에 strcpy는 잠재적으로 굉장히 위험한 함수이거니와, 파일도 아니고 키보드 입력을 받는데도 버퍼 크기 한계 지정도 없이 설계되어 있는 gets는 정말 도저히 그대로 쓸 수 없는 지경이 되어 있다.

이 문제를 해결하기 위해 윈도우 SDK에는 꽤 오래 전부터 StringCb* 함수들을 도입했다. write 버퍼의 포인터뿐만 아니라 버퍼 크기도 별도로 지정하여 복사가 그 영역 밖으로는 되지 않게, 즉 문자열이 잘리도록 조치를 취한 것이다. 이것들은 그냥 static 링크되는 코드이지 운영체제 커널 DLL에 있지는 않다는 점에서 lstrcpy 같은 함수와는 위상이 다르다. 사실 저 함수 자체가 기존 커널 l* 함수보다 더 안전한 솔루션을 제공하기 위해 추가된 것이다.

비주얼 C++ 2005부터는 보안 문제를 더욱 적극적으로 대처하는 함수군이 생겼다. 아예 _s 접미사가 붙은 strcpy_s 류이다. 이것도 하는 일은 기존 strcpy보다 더욱 안전하게 문자열을 복사하는 것이지만, 다음과 같은 면에서 StringCb*와는 동작 방식이 약간 다르며, 쓰임이 더 엄격하다는 것을 알 필요가 있다.

첫째, StringCb*는 버퍼 크기가 걸리면 문자열을 그냥 자르고 null-terminate까지 알아서 시켜 주는 반면, *_s는 런타임 에러를 발생시킨다. 그러므로 전자는 단순히 로그를 찍는 것과 같이 문자열이 꼭 다 복사되지는 않아도 되고 버퍼 초과 에러만 예방하고 싶을 때 쓰면 되며, 후자는 어떤 경우에도 버퍼가 초과하는 일 자체가 없이 문자열이 100% 정확하게 복사되는 게 보장되어야 할 때 쓰면 된다.

둘째, 특히 디버그 버전의 *_s 함수는 지정해 준 버퍼 영역을 다 검사한다. 비록 지금 한 글자밖에 복사할 게 없더라도 사용자가 100글자를 지정했다면 그 메모리가 다 안전한지 일일이 검사한다는 뜻이다. 그렇기 때문에
char a[8];
strcpy_s(a, 12, "ABC"); //12>8

는 비록 전혀 해롭지 않은 코드임에도 불구하고 에러를 일으킨다. 디버그 빌드에서는 말이다.

strcpy 같은 간단한 함수뿐만 아니라 scanf 함수도 쓰임이 강화되었다. 가령,
char s[64];
scanf_s("%s", s, 64);

이런 식으로 %s도 버퍼뿐만 아니라 버퍼의 크기까지 덧붙이도록 동작이 수정되었다는 뜻이다.

*_s 함수는 일부 템플릿 오버로드 버전도 존재하기 때문에, 기존 코드에다 *_s 함수를 넣는 리팩터링 작업을 할 때 일일이 배열 사이즈를 집어넣어야 하는 수고를 상당수 덜어 준다. 가령, 쓰기 버퍼가 포인터가 아니라 static 배열인 경우,

template<int N>
.... strcpy_s(char (&dst)[N], const char *src)
{
 return strcpy_s(dst, N, src);
}

이런 템플릿 오버로드가 이미 구비되어 있는 덕분에, strcpy를 strcpy_s로 바꾸기만 해도 알아서 배열의 크기가 전달된다. 물론 컴파일 타임에 말이다.
물론, 쓰기 버퍼가 임의의 포인터인 경우에는 이런 방법을 쓸 수 없고 사용자가 버퍼 크기를 수동으로 전달해 줘야 할 것이다.

본인도 어지간하면 이런 안전한 함수를 쓰고 싶은데, strchr/strstr 같은 함수의 결과값에다가 또 strcpy를 해야 할 때는 남은 버퍼 크기를 지정해 주는 게 좀 난감하다.

덧.
1. C언어에서 정적 배열의 원소 개수를 구할 때는 통상
#define ARRAYSIZE(x)  sizeof(x)/sizeof(x[0])
과 같은 형태로 정의하며, 이렇게만 써 놔도 이 식은 최적화 과정에서 모두 컴파일 타임 때 고정된 값이 계산되어 들어간다. 하지만 ARRAYSIZE 매크로도 템플릿을 이용한 매크로로 바꾸면 나눗셈 연산도 없고 더구나 배열이 아닌 일반 포인터를 넘겨주면 에러까지 나는(더욱 type-safe하기까지 한) 버전을 만들 수 있다.

템플릿으로 컴파일 타임 때 정말 별 걸 다 할 수 있다. ^^;; strcpy_s의 템플릿 오버로드 버전을 보니까 문득 이게 생각이 났다.

2. 아울러 어떤 구조체 멤버가 메모리 상으로 몇째 오프셋을 나타내는지 가리키는 매크로도 NULL 포인터로부터 ->로 참조한 멤버 값의 주소를 가리키는 방식으로 만들 수 있다. NULL 포인터는 -> 연산자를 적용하기만 하면 바로 에러가 날 것 같지만 주소 연산자 &는 메모리 위치에 상관없이 컴파일 타임 때 값이 계산되는 연산자이기 때문에 그런 일은 발생하지 않는다.

3. C/C++에서 0이라는 수치는 숫자와 포인터에 모두 아무런 형변환 없이 적용 가능한 녀석이어서 문제이다. true, false는 이미 진작부터 예약어가 생겼는데 C++도 nil이나 null 같은 예약어가 있어야 하지 않나 싶다. 본인은 전에도 이런 언급을 한 적이 있을 것이다.

Posted by 사무엘

2010/03/13 14:56 2010/03/13 14:56
Response
No Trackback , 2 Comments
RSS :
http://moogi.new21.org/tc/rss/response/210

비주얼 스튜디오로 C# 프로젝트를 하나 만든 후, 마법사가 생성해 준 기본 폼 애플리케이션을 debug와 release로 제각기 모두 빌드하여 실행해 본다.
그 후 이 프로젝트 디렉터리의 크기를 측정해 보라. 본인은 200KB가 채 나오지 않았다.

그런데 C/C++ 프로젝트를 만들고(MFC는 쓰지도 않고), 그냥 간단한 창만 하나 띄우는 Win32 프로그램을 debug와 release로 모두 빌드해서 실행한 후 디렉터리 크기를 재 보라.
프로젝트가 차지하는 용량은 무려 20MB가 넘는다. (비주얼 스튜디오 2008 기준)

그렇다. C/C++은 프로젝트를 만들고 빌드를 좀 하면, 잡다하게 생기는 중간(intermediate) 파일이 엄청 많다. 게다가 용량도 상당히 많이 잡아먹는다.

<날개셋> 한글 입력기 프로젝트도 32비트 디버그/릴리스, 그리고 64비트까지 빌드하다 보니 디렉터리 전체 크기가 무려 800MB에 달해 있다. 하지만 그 중 실제로 빌드에 쓰이는 소스나 데이터 파일의 합은 20~30MB대는 절대 안 넘을 것이다. -_-;;

OBJ 파일이 생기는 것이야 C/C++ 자체가 링크를 염두에 두고 만들어진 언어이니 어쩔 수 없고 그건 어차피 그렇게 크지도 않다. ..... 라고 말하려고 했는데, 오브젝트 파일도 각종 디버깅 내지 고급 최적화와 관련된 메타정보가 첨가되다 보면 단순히 소스 코드의 기계어 번역이라고 볼 수 없을 정도로 덩치가 은근히 커지긴 한다.

OBJ 말고도 디스크 용량을 상당히 차지하는 주범은 잘 알다시피 pre-compiled header이다(*.PCH) 겨우 몇몇 개의 헤더 정도나 인클루드하면 되는 정올 답안 수준의 프로그램이 아니라 특정 운영체제/플랫폼이나 거대한 라이브러리의 프로그래밍 요소를 다 인클루드하는 프로그램이라면, 그렇게 고정불변이고 덩치가 많은 요소들은 미리 컴파일을 좀 시켜 놔야지 프로그램의 빌드 시간을 줄일 수 있다.

본인이 비주얼 C++을 처음으로 쓴 게 4.2 시절부터이다. 그때엔 MFC 심볼들을 다 빌드해 놓은 pch 파일도 이미 3~5MB 정도 했던 것 같다. 하지만 지금은 그것보다 덩치가 훨씬 더 커져 있고 한 빌드 configuration당 10MB는 훌쩍 넘어간다. 프로젝트 하나 만들 때마다 50~100MB씩은 잡아야 한다. 오로지 C/C++ 언어 프로젝트만이 이런 삽질이 필요하다.

윈도우 SDK나 MFC처럼 매 프로젝트마다 일일이 빌드가 필요하지 않은 것들은 공용 PCH라는 개념으로 공유만 좀 하게 해 놔도 이런 파일의 크기를 상당 부분 줄일 수 있을 텐데 너무 낭비라는 생각이 든다. 하지만 요즘은 하드디스크 용량이 워낙 많다 보니, 빌드 시간을 네트워큭 분산 기술을 줄이려는 연구는 해도 PCH 파일 크기를 줄이려는 연구는 거의 행해지지 않는 것 같다.

이외에도 인텔리센스 데이터베이스인 NCB 파일도 은근히 크고, 매 빌드 때마다 생기는 심볼 디버그 데이터베이스인 PDB 파일도 무시 못 한다.
왜 이런 일이 생기는 것일까? 대략 다음과 같은 관점에서 살펴볼 수 있다.

첫째, C/C++은 굉장히 작은 언어이고, 프로그래밍에 필요한 요소들을 전적으로 소스와 동일한 텍스트 포맷인 헤더 파일의 파싱(느림!!)에 의존하여 읽어들이는 형태이기 때문이다. 라이브러리 링크는 별도의 계층으로 따로 존재하지, 즉시 읽어들일 수 있는 바이너리 유닛/패키지라든가(파스칼, 자바, C# 등) 언어가 자체 내장하고 있는 요소가 없다. 원천적으로 이식성을 택했지, 속도를 배려하지는 않은 느린 프로세스를 사용하는 셈이다.

둘째, C/C++은 전처리기라든가 링크 과정으로 인해 빌드가 더욱 느리고, 언어의 해석이 더디기 때문이다. 모든 토큰은 그냥 토큰이 아니라 전처리기를 재귀적으로 거치면서 다 까 봐야 실체가 드러난다. ^^;;; 헤더 파일의 글자 하나를 고치면 이 여파가 수백, 수천 개의 소스 파일에 동시에 파급되고 프로그램의 의미가 전혀 다르게 바뀔 수 있다. 이것은 장점도 있지만 똑똑한 개발 환경이나 빠른 빌드 환경을 만드는 데는 불리한 구조이다.
그러니, 소스 코드를 조금이라도 빨리 분석하기 위해서는 소스코드 자체뿐만 아니라 온갖 메타정보들을 ‘별도의 파일’로 보관할 수밖에 없다. NCB처럼 말이다.

셋째, C/C++은 그 어느 에뮬레이터나 가상 기계 계층이 없이, CPU 차원에서 기계가 직통으로 알아듣는 프로그램을 만들 수 있다. 잘 최적화된 프로그램은 사람이 원래 짠 소스 코드와는 전혀 다른 형태가 될 수도 있다. 그러니 이런 코드의 디버깅은 어려울 수밖에 없다. 변수의 내용을 확인하거나 한 줄씩 실행하는 것까지는 못 하더라도 프로그램이 뻗었을 때 스택 덤프라도 보려면 빌드된 프로그램과 소스 코드 사이의 관계를 설명하는 최소한의 정보라도 있어야 한다. 소스 코드와 형태가 전혀 딴판인 코드를 생성하는 컴파일러일수록 그런 정보는 더욱 세부적이고 양이 많아질 수밖에 없다.

한 마디로 C/C++이 정말 강력하고 포괄적이고 대인배 같은 언어이다 보니 주변에 붙는 군더더기도 많은 모양이다. ^^ 코드 생성이 여러 단계를 거치면서 매우 번거롭고 어려운 대신, 한번 만들어 진 코드는 그 어느 언어보다도 강력하다.

Posted by 사무엘

2010/02/22 21:40 2010/02/22 21:40
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/194

C# 코딩 (2)

1.
자바는 잘 알다시피 파일 하나와 클래스 하나가 완전히 일대일 대응하며, 파일 이름이 클래스 이름과 일치까지 해야 한다(여러 클래스를 선언하더라도 한 클래스의 내부에서 선언해야 함). 자바는 여러 소스 코드를 한데 모아서, 다시 말해 링크해서 exe를 만든다는 개념이 없고 그냥 소스 코드가 그대로 바이트 코드로 컴파일된 클래스 파일이 되고 거기에 있는 static void Main 함수를 실행하는 구조이다. 그런 만큼 아예 그런 형태를 언어 차원에서 강제함으로써 관리 면에서 얻는 편의도 분명 있을 것이다.

그런데 C#은 그렇지는 않다. C++과 마찬가지로, 파일과 클래스가 일대일 대응하지 않아도 된다. 한 파일에 여러 클래스가 담길 수도 있고, partial 예약어를 쓰면 반대로 한 클래스가 여러 파일에 나눠 담길 수도 있다!
(MSDN에 설명돼 있는 것처럼, 여러 사람이 한 클래스에 대해 공동 작업을 할 때나 클래스의 일부분이 다른 출처로부터 자동으로 생성된다거나 할 때 무척 유용하겠다.)

게다가 C#은 비록 닷넷 전용이긴 해도 여러 소스로부터 한 exe를 만들 수 있는 언어이다. 그렇다 보니 자바에서는 걱정할 필요가 없던 문제가 하나 생기는 게 있는데, 바로 여러 클래스들이 자기만의 static void Main 함수를 정의할 수 있다는 점이다. 이 경우 컴파일러는 에러를 발생시키며, Main 함수를 하나만 남기든가, 어느 클래스의 Main 함수를 실행시킬 지 별도의 옵션으로 지정해 달라는 메시지를 남긴다.

Main 함수가 없거나 둘 이상 존재한다는 에러는 링크 에러가 아니고 런타임 에러는 더욱 아니며, 컴파일 에러라는 게 흥미롭다. C#도 자바와 마찬가지로 링크라는 개념은 없다.

2.
C#에는 C/C++과 마찬가지로 const라는 예약어도 있고 이에 덧붙여 readonly라는 예약어도 있다. const는 컴파일 시점에서 값이 완전히 고정되어 있는 primitive 타입의 상수나 문자열만 지정할 수 있다. 그리고 C++과는 달리 const와 static을 동시에 지정할 수 없다. 어떤 멤버가 const이기만 하면 static은 당연히 자동으로 지정되기 때문이다.

그 반면 readonly는 const보다 더 동적인 값을 선언할 때나 생성자 함수에 한 번 정한 후에 그 뒤부터 고칠 수 없도록 지정할 수 있어 편리하다. primitive 타입이 아니라 new로 할당하는 개체/배열이라든가, 상수이긴 한데 값이 실행 시점에서 가변적으로 정해질 수 있는 값--함수 실행 결과처럼--이라면 readonly로 지정하면 된다.

C++은 이 둘의 경계가 모호했다. 굳이 구분하자면 일반 const 멤버는 생성자 함수에서 동적인 값으로 초기화가 가능했던 반면, static const는 컴파일 때 정적인 값으로만 초기화가 가능했다.
C#은 배열은 자바와 마찬가지로 무조건 new로 할당하기 때문에, 값이 전혀 동적이지 않는 상수 테이블 같은 것도 배열이라면 const가 아닌 readonly로 할당해야 한다.

한편, 자바는 이 점에서 C#과는 다른 재미있는 차이를 보인다. 내 기억이 맞다면 const가 없고 아마 final이 const 역할도 하며, 오버라이드 가능하지 않은 고정 함수임을 나타낼 때도 쓰이는 예약어이다. 즉, C++과 C#은 virtual이라고 별도로 지정한 함수만 가상 함수인 반면, 자바는 별도로 지정하지 않은 모든 함수가 기본적으로 가상 함수인 것이다.

그나저나, this 포인터를 const로 바꿈으로써 이 멤버 함수가 실행 결과가 read-only임을 나타내는 const는, 자바와 C#에서 어떻게 표현하는지 모르겠다.

3.
뒷북일 수도 있지만 한 마디.
유니코드가 제정된 후에 발명된 언어인 자바와 C#은, 해당 언어가 규정하는 문자 집합이 유니코드이다. 그래서 char 형의 기본 크기가 놀랍게도 2바이트이며 문자열 리터럴도 기본이 유니코드 UTF16 형태이다. 게다가 변수를 비롯한 각종 명칭 이름을 한글로 지을 수 있다. ^^ 사실, 비주얼 스튜디오에서는 C# 소스 코드 자체가 기본적으로는 BOM까지 붙어 있는 UTF8 인코딩으로 저장된다.

하지만 C/C++은 그렇지 않다. 1970년대부터 존재한 저수준 시스템 언어인 만큼, 21세기에도 C/C++ 코드는 ANSI 인코딩이다. char 형은 규정상 무조건 1바이트여야 하고 절대로 바뀔 수 없으며, ""는 기본적으로 char 문자열이고 wide 문자열은 L""이라고 접두어를 붙여서 별도로 지정해 줘야 한다.

옛날의 일부 C언어 컴파일러는 키보드로 바로 칠 수 없는 일부 기호를 다른 기호의 나열로 대체하는 시퀀스까지 존재했었다. 그렇게도 열악한 환경에서부터 쓰였던 언어와 유니코드 시대에 등장한 언어는 그 배경이 극과 극일 수밖에 없다.

문자열 타입은 그렇다 치더라도 C/C++이 현대 언어들처럼 유니코드 명칭을 받아들이는 일도 있을 수 없다. 그러려면 그렇잖아도 링크라는 계층이 존재하는 언어인데 오브젝트 파일의 포맷이 바뀌어야 하고, 심지어 운영체제의 API 구조조차 바뀌어야 할지도 모른다. 윈도우 API 중에 GetProcAddress 함수의 둘째 인자가 괜히 PCSTR인 게 아니다(PCTSTR이 아닌 일반 string). 보수적이고 이식성을 생명처럼 여기는 이쪽 바닥에서는 상상조차 할 수 없는 일인 것이다.

이처럼, 똑같이 클래스가 있고 상속이 존재하는 소위 객체 지향 언어라 하더라도 C++, C#, 자바 같은 언어들의 설계 목적과 주 용도, 빌드 단계, 문법 구조, 고안된 당시 배경을 살펴보는 것은 각 언어들의 철학을 이해하는 데 큰 도움이 될 것이다.

Posted by 사무엘

2010/02/15 18:42 2010/02/15 18:42
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/185

C# 코딩

예전에 짜 놓은 C++ 코드를 C#으로 포팅할 일이 있어서 모처럼 비주얼 스튜디오로 C# 프로젝트를 만들어 봤다. 그렇게 거창한 건 아니고, 그냥 콘솔에서 간단하게 입출력만 주고받는 150~200줄 남짓의 정올 답안 정도 되는 규모의 프로그램이다.

public void 이런 식으로 멤버 함수를 선언하는 것이나, 클래스 안에다가 함수 몸체를 다 써 넣는 것은 자바를 닮았다.
전처리기 같은 게 없고 C++보다 소스 코드 파싱이 훨씬 더 용이한 것 역시 자바와 일치한다. 사실 이건 요즘 객체 지향 언어들의 보편적인 추세이기도 하니까.

이 언어 역시 자바처럼 생산성 하나는 정말 좋겠다는 생각이 들었다. 파싱이 쉽기 때문에 IDE가 굉장히 똑똑해질 수 있다. 소스 코드 자동 리팩터링이라든가 인텔리센스 자동 완성, 코딩 컨벤션 교정 같은 것을 월등히 더 지능적으로 구현할 수 있다는 뜻.
실제로 C++ 쓰다가 C#을 쓰면 정말 그 편리함에 감탄하곤 한다.
환상적인 빌드 속도도 큰 매력이며, precompiled header 나부랭이도 없다. delete 때문에 골머리를 썩을 필요도 없다.
네이티브 바이너리를 못 만들어서 낭패이긴 하지만 말이다.

자바와 C#에는 C++에서 ::와 ->에 해당하는 연산자가 없으며 이들의 기능을 .이 모두 맡고 있다. 일단 포인터라는 게 존재하지 않으니 ->가 필요 없고, . 가 개체의 멤버 참조뿐만 아니라 scope와 namespace 식별까지 모두 맡고 있는 것이다. 그렇게 해도 문법상으로 모호해지지는 않는다.

C에는 다차원 배열이 존재하지 않으며 오직 배열의 배열 내지 다차원 포인터만이 그 개념을 대신하고 있는 반면, C#은 자유롭게 다차원 배열을 선언할 수 있다. 무슨 얘기냐 하면 실행 시간에 임의의 값으로 x*y 크기의 동적 배열을 만들 수 있다는 뜻. (C/C++은 언어 차원에서 이 간단한 일도 바로 가능하지 않다.)

C#은 콤마 연산자는 존재하지 않는 듯하다. 그리고 int 같은 기본 타입을 reference로 전달할 수 있는데, 이때는 반드시 ref를 추가해야 한다. 함수를 선언할 때 ref를 붙이는 건 마치 파스칼에서 var을 붙이는 것과 비슷한데, 이뿐만이 아니라 그런 함수를 호출할 때도 func(ref a) 이런 식으로 ref를 붙여야 하는 게 무척 신기하게 느껴졌다.

앞으로 간단한 벡터 그래픽 데모 프로그램을 짤 일이 있을 것 같은데 이것도 간단하게 C#으로 짜면 딱일 것 같다. 복잡한 최적화 루틴 같은 거 필요 없고, 그냥 결과물이 마음에 들 때까지 빌드를 엄청 자주 해야 할 텐데 C++보다 생산성이 월등히 더 뛰어날 수 있기 때문이다.
하지만 그러려면 Win32 API와는 완전히 다른 그래픽 체계를 또 공부해야 되네. -_-;;

Posted by 사무엘

2010/02/12 22:20 2010/02/12 22:20
Response
No Trackback , 9 Comments
RSS :
http://moogi.new21.org/tc/rss/response/183

« Previous : 1 : ... 24 : 25 : 26 : 27 : 28 : 29 : 30 : 31 : 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:
2676528
Today:
1096
Yesterday:
2124