DllMain 이야기

Windows 운영체제에 실행 바이너리로는 잘 알다시피 EXE와 DLL이 있다.
EXE에 시작 지점이 WinMain이 있듯, DLL에는 DllMain이라는 시작 지점이 있어서 간단한 자신의 시작과 종료 같은 이벤트 통지를 받아서 초기화/마무리 작업을 할 수 있다. 이건 static library에는 없는 개념이다.

이 함수는 export table의 이름이나 ordinal로 탐색하는 게 아니라, PE 실행 파일 헤더 차원에서 주어져 있는 entry point의 주소로 진입하는 것이기 때문에 이름이 딱히 export되어 있지 않아도 된다.
물론 요즘 DLL은 코드 셔틀뿐만 아니라 리소스 셔틀의 역할도 많이 하기 때문에(특히 다국어 지원용!) 데이터만 추출한다는 플래그를 주고 LoadLibraryEx를 호출했거나 DLL 자체가 리소스 전용으로만 만들어졌다면 그런 DLL은 DllMain 함수가 없거나, 있더라도  실행되지 않는다.

실행 파일은 자신이 말 그대로 실행 주체이기 때문에 WinMain 함수의 리턴이 곧 해당 프로세스의 종료를 뜻한다. 그러나 DLL은 어떤 함수 호출이 있을 때에만 그때 그때 실행되는 형태이기 때문에 DllMain도 이벤트만 받고는 금세 리턴된다는 차이가 있다. 즉, 이런 점에서 DllMain은 WinMain보다는 윈도우 프로시저와 더 비슷한 형태이다.

DLL이라는 단어 자체가 대문자 이니셜이긴 하지만, 일단 C/C++ 컴파일러가 링크 때 참조하는 명칭은 DLLMain이 아니라 DllMain이다. L은 소문자로 쓰니 스펠링을 혼동하지 않도록 주의하자.
함수의 인자로는 자기 자신의 인스턴스(모듈) 핸들, 그리고 호출 이벤트(DWORD fdwReason)와 부가 인자(PVOID)가 들어오며, 리턴값은 BOOL이다.

이벤트는 4가지 종류가 있다.
일단, DLL이 처음 로딩되어 어떤 프로세스에 붙었을 때(DLL_PROCESS_ATTACH), 그리고 반대로 종료될 때(*_DETACH) 두 가지 경우가 당연히 포함된다. 이것은 LoadLibrary 내지 FreeLibrary 같은 런타임 동작으로 인한 결과일 수도 있고, 아니면 실행 파일의 import 테이블 차원에서 로딩되거나 해당 프로세스가 종료되어 딸린 DLL들이 모두 일괄 종료되는 경우일 수도 있다.

그 다음으로 DLL들은 특별히 그 프로세스 안에서 스레드가 새로 생성되거나 종료되었을 때도 통지를 꼬박꼬박 받는다. DLL_THREAD_ATTACH와 *_DETACH. 이건 응당 16비트 시절에는 없다가 나중에 새로 생긴 정보일 것이다. 그리고 당연한 말이지만 그 생성되거나 소멸되는 스레드의 실행 문맥으로 함수가 호출되므로 GetCurrentThreadId 같은 함수로 지금 스레드 ID 따위를 쉽게 조회해 볼 수 있다.

다만, 이 통지는 이 DLL이 로딩되기 전부터 이미 존재하던 스레드에 대해서는 오지 않으며, 스레드가 하나씩 차례대로 종료되는 게 아니라 메인 스레드가 실행이 끝나서 프로세스의 스레드들이 죄다 한꺼번에 종료될 때는 스레드별로 detach 통지가 오지 않는다. 그러므로 실행 중인 모든 스레드를 한데 조회하는 건 다른 API를 써서 해야 하며, 스레드별로 만들어 뒀던 리소스를 한꺼번에 해제하는 건--가령, 스레드별로 TLS 슬롯들이 가리키는 추가 할당 메모리-- *_PROCESS_DETACH 에다가도 마련해 둬야 한다.

DllMain의 추가 인자는 사실상 *_PROCESS_DETACH일 때에만 쓰인다.
이 DLL이 FreeLibrary로 인해서 레퍼런스 카운트가 0으로 떨어져서 동적으로 해제되는 것이면 추가 인자에는 0이 들어오고, 그렇지 않고 호스트 프로세스가 종료되면서 자명한 이유로 인해 같이 해제되는 것이면 1이 들어온다.
그렇기 때문에 *_PROECSS_DETACH일 때 어떤 값이 들어오느냐에 따라 혹시 모듈의 reference count leak가 있지는 않았는지를 체크할 수 있다.

운영체제가 기본 제공하는 시스템 DLL을 쓰는 게 아닌 이상, 내 EXE에서 쓰는 내 custom DLL은 대부분 동적으로 읽어들이고 해제하는 게 일반적이다. (delay-load 포함) 그런데 한번 읽었던 동일 DLL에 대해 자꾸 LoadLibrary를 반복 호출하면 해당 DLL의 레퍼런스 카운트가 증가하기 때문에 나중에 FreeLibrary를 한 번만 했을 땐 메모리에서 해제가 되지 않게 된다. 그 즉시 DLL_PROCESS_DETACH에 0이 들어오면서 DLL이 해제가 되지 않는다면 어딘가에 버그가 있다는 뜻이다.
결국 프로세스가 종료될 때가 돼서야 궁극적으로 해제가 되긴 하지만(이때는 1이 들어옴), 어쨌든 이런 것도 넓은 의미에서는 memory leak이 되는 셈이다. COM 오브젝트의 레퍼런스 카운트 관리와 완전히 똑같은 문제다.

*_PROCESS_ATTACH일 때에도 추가 인자에는 LoadLibrary에 의한 동적 로드일 때는 0, 그렇지 않고 import 테이블에 의한 정적 로드일 때는 1이나 이에 준하는 nonzero 값이 온다고는 하는데 본인은 그건 확인을 못 해 봤다.
그리고 DLL의 입장에서는 자신이 어떤 방식으로 로드되느냐에 따라 딱히 달리 동작하거나 자신의 로딩 방식을 굳이 알아야 할 일은 거의 없다.
이것 말고 단순히 스레드 생성/소멸 통지 때는 추가 인자는 쓰이지 않고 그냥 0만 온다.

한편, DllMain 함수의 리턴값은 일반적으로는 쓰이지 않고 1을 되돌리든 0을 되돌리든 무시된다.
이게 쓰이는 단 한 가지 상황은 *_PROCESS_ATTACH 때로, 이때는 함수가 TRUE를 되돌려야 DLL의 로딩이 성공한 것으로 간주된다. 안 그러면 이 DLL의 로딩은 거부되며 LoadLibrary의 리턴값은 NULL이 된다.

과거에 Visual C++이 2005와 2008 시절에 CRT와 MFC 라이브러리에 대해서 side-by-side assembly 방식을 강제 적용해서 이 방식으로 DLL이 로딩되지 않으면 로딩을 거부하곤 했는데, 그것 판단을 DllMain 함수에서 했다. 즉, 자신이 단순히 EXE와 같은 디렉터리나 운영체제 시스템 디렉터리에 있어서 로딩이 된 것이라면 DllMain이 고의로 FALSE를 되돌렸던 것이다.

그리고 중요한 점으로는.. DllMain의 *_PROCESS_* 실행 시점은 C++ 프로그램으로 비유하자면 main 함수가 실행되기도 전에 전역 클래스 객체의 생성자 내지 소멸자 함수가 실행된 것과 같다.
범용적인 DLL는 정말 기상천외한 EXE에 붙을 수도 있으며 DllMain의 호출 시점은 주변의 다른 DLL들이 모두 제대로 로딩 됐다고 장담할 수가 없는 때이다. 그렇기 때문에 이때는 다른 복잡한 초기화를 하지 말고 가능한 한 우리의 영원한 친구인 kernel32에 있는 함수만 호출해야 한다.

DllMain에서 할 만한 좋은 작업의 예로는 TLS 슬롯 할당, heap, 뮤텍스, 크리티컬 섹션, memory mapped file 같은 커널 오브젝트의 간단한 초기화 정도이다. 그 외에 user32나 gdi32까지 가는 작업은 권장되지 않으며 하물며 레지스트리나 COM/OLE 같은 시스템을 건드리는 정도만 되면 절대 금지이다.

이 함수 안에서 또 다른 DLL을 연쇄적으로 불러들이거나 해제하는 작업도 금물이다. 그게 가능하다면 참 편할 것 같지만 운영체제의 입장에서는 예측할 수 없는 엔트로피를 키우는 일이며 데드락을 야기할 수 있다. (특히 상호간에 LoadLibrary를 하는 경우는..?? =_=) 완전히 같은 예는 아니지만 생성자 안에서 가상 함수를 호출하는 게 왜 금지되어 있는지를 생각해 보자.

그러니 더 복잡하고 정교한 초기화나 마무리 작업은 DllMain에서 하지 말고, 함수를 따로 만든 뒤에 이 DLL의 사용자로 하여금 그걸 별도로 호출하게 해야 한다.
윈도우 컨트롤을 제공하는 DLL이라면 그냥 LoadLibrary를 하는 순간에 컨트롤의 윈도우 클래스들이 자동으로 등록돼 버리면 좋겠지만, 일단은 윈도우 클래스도 user 계층 관할이기 때문에 초기화를 별도의 함수에서 하는 게 바람직하다.

CWinApp 개체가 존재하는 MFC 확장 DLL의 경우, InitInstance와 ExitInstance가 호출되는 타이밍이 역시 DllMain이다. 그 때밖에 기회가 없으니 어찌 보면 당연한 얘기이다. 그러니 그때에도 사용하는 함수에 동일한 제약을 적용하여 주의해야 한다. 이건 Lyn 님의 블로그에서 발견한 정보임을 밝힌다. ^^

끝으로, 스레드와 관련하여 하나만 더 첨언하고 글을 맺겠다.
*_PROCESS_* 메시지야 어느 DLL에게나 자신의 생명 주기를 알리는 필수불가결한 메시지이겠지만, *_THREAD_*의 경우는 그렇지 않다. 모든 DLL들이 스레드 생성이나 소멸을 일일이 통보 받아야 할 필요는 없다.
그렇기 때문에 스레드 통보를 받을 필요가 없는 DLL은 DisableThreadLibraryCalls라는 함수를 호출함으로써 *_THREAD_*를 받지 않겠다고 운영체제에다 알려 주는 게 조금이나마 성능 향상에 도움이 된다.

얘는 물론 DllMain에서 곧장 호출해도 안전한 kernel32 함수이다. 수시로 켰다 껐다 할 필요가 있는 옵션이 아니어서 그런지, 한번 지정만 하고는 끝이다.
개인적으로는 이런 간단한 정보는 DllMain + DLL_PROCESS_ATTACH의 리턴값 플래그로 접수하는 게 좋지, 굳이 별도의 함수로 만들 필요가 있었나 싶은 생각이 든다. 리턴값이 0이면 로드 거부, 1이면 로드 허용, 2이면 로드 허용하되 앞으로 이 DLL은 스레드 통지는 안 함 정도로. 하지만 함수가 저렇게 만들어져 버렸으니 프로그래머의 입장에서는 그걸 적절히 사용만 하면 되겠다.

다만 C 라이브러리를 static 링크하는 DLL은 저 함수를 사용하지 않는 게 좋다. 자기는 스레드 통지를 사용하지 않더라도 배후의 CRT가 스레드 통지를 내부적으로 활용하기 때문이다. 물론 CRT를 DLL 링크하는 DLL에 대해서는 이런 제약이 적용되지 않는다.
그렇기 때문에 한 소스에 대해 CRT를 static/DLL 여러 방식으로 빌드하는 DLL이라면 #ifdef _DLL에 따라서 DisableThreadLibraryCalls 또는 __noop으로 대응하는 매크로 함수를 만들어 사용하는 게 바람직하다.

Posted by 사무엘

2015/04/28 08:20 2015/04/28 08:20
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1088

EXE와 DLL의 경계

1.
프로그래밍을 하다 보면 단독 실행이 가능한 EXE 형태의 프로그램만 만드는 게 아니라, 다른 프로그램에 부속물로 붙거나 여러 프로그램들 사이에서 공유되는 라이브러리, 플러그 인 같은 걸 만들 때가 있다.
플러그 인 정도야 호스트 프로그램이라도 분명하게 존재하니 양반이지만, 임의의 프로토콜을 갖는 공용 라이브러리는 static LIB이든 DLL이든, 그 자체로 단독 실행이 가능하지 않다. 그렇다 보니 그 라이브러리를 사용하는 프로그램을 또 별도로 만들어야 해서 테스트와 디버깅이 여러 모로 불편하다.

그래서 Windows에서 DLL을 만드는 솔루션의 경우, 그 솔루션에다가 DLL을 테스트하는 간단한 EXE도 프로젝트로 따로 만드는 게 보통이다.
Visual C++은 지난 2005부터인가 프로젝트를 새로 생성할 때, 솔루션 디렉터리 아래에 동명의 프로젝트 디렉터리가 한 단계 더 생기고, 한 솔루션에 소속된 프로젝트들의 생성물은 다 동일한 output 디렉터리에 만들어지도록 기본 동작 방식이 바뀌었다. obj 같은 임시 파일들만이 프로젝트별로 자기 고유한 위치에 생성된다. 이것은 나름 바람직한 조치라 여겨진다.

2003 이하 2005 이상
프로젝트1\Release\프로젝트1.exe
프로젝트1\Release\프로젝트1.obj
프로젝트1\프로젝트2\Release\프로젝트2.dll
프로젝트1\프로젝트2\Release\프로젝트2.obj
솔루션\Release\프로젝트1.exe
솔루션\Release\프로젝트2.dll
솔루션\프로젝트1\Release\프로젝트1.obj
솔루션\프로젝트2\Release\프로젝트2.obj

그런데, 발상을 전환하면 DLL을 생성하는 소스를 기반으로 곧바로 EXE를 만들어 DLL의 함수들을 의외로 굉장히 간편하게 테스트를 할 수 있다.
링커의 SUBSYSTEM 옵션 하나만 바꿈으로써 WinMain을 사용하는 GUI 프로그램과 main을 사용하는 콘솔 프로그램을 곧바로 전환할 수 있듯, EXE와 DLL은 똑같이 PE 헤더가 있는 실행 파일이며 본질적인 차이가 거의 없다. 구조체 필드 값이 일부 차이가 나고 entry point에서 같이 전달되는 인자의 타입이 다를 뿐이다.

DLL 프로젝트에서 configuration을 하나 만든다. 테스트와 디버그가 목적이므로 Debug 빌드 것을 초기값으로 가져오면 되겠다. configuration 이름은 Debug EXE 정도로 하자.
그 뒤 프로젝트 속성의 General (일반)으로 가서 Target Extension (대상 확장명)은 .dll이던 것을 당연히 .exe로 바꾼다.
그리고 제일 중요한 Configuration Type (구성 형식)을 Dynamic Library (.dll)이던 것을 Application (.exe)으로 바꾼다.

'확인'을 누른 뒤, DLL 소스의 한구석엔 원래의 DLL엔 없던 WinMain 내지 main 함수를 추가하고, 그 안에다 호출하고 싶은 DLL 클래스/함수들을 마음껏 사용하며 테스트한다.
이것만 해 주면 끝이다. 프로젝트를 이 configuration대로 빌드해서 돌리면 된다.

별도의 EXE를 따로 만들어서 테스트를 하는 거라면 그 EXE에 또 테스트 대상 DLL을 로딩하는 코드가 추가되어야 하지만 DLL 자체의 소스로부터 EXE를 생성하면 그런 번거로운 절차가 필요하지 않으니 더욱 좋다. EXE 자체에 DLL의 코드가 그대로 포함되기 때문이다.

static LIB을 만드는 프로젝트도 이런 식으로 별도의 EXE 생성 configuration을 만들어서 테스트가 가능할 것이다.
다만 DLL/EXE와는 달리 static LIB는 링크 절차가 존재하지 않고 그냥 컴파일만 가능하면 라이브러리 파일이 만들어지기 때문에 이로부터 온전한 EXE를 만들려면 추가적인 링커 설정 같은 게 필요할 것으로 보인다.

2.
여담이다만 DLL뿐만 아니라 EXE도 DLL처럼 export 심벌을 가질 수 있으며 그걸 GetProcAddress를 통해 얻어 올 수 있다.
EXE만 자신이 로딩한 플러그 인 DLL로부터 함수를 얻어 오는 게 아니라, DLL 역시 자신을 로드한 EXE로부터
GetProcAddress( GetModuleHandle(NULL), "GetHostInfo") 이런 식으로 코드를 얻을 수 있다. 이것도 참 기발한 발상이 아닐 수 없다. 어디 활용할 데가 없을까?

내가 개인적으로 굉장히 놀란 것은, 저렇게 한 프로세스 공간의 주인 역할을 하는 EXE가 아니라..
완전히 다른 EXE를 로딩해서 거기에 있는 코드를 실행하는 것도 가능하다는 것이다. EXE는 보통 0x400000 같은 고정된 주소에 로드되며 재배치 정보가 존재하지 않기 때문에 자기 위치에 로드가 못 되면 로딩이 실패한다.

그런데 자신과 로드 주소가 겹치는 EXE도 LoadLibrary를 하면 일단 작업이 성공하며 리소스 추출뿐만이 아니라 GetProcAddress도 실행 가능한 듯하다. 이쯤 되면 EXE와 DLL의 경계가 어찌 되는지가 궁금해진다.

3.
아무 중간 계층 없이 C/C++ 언어만으로 뭔가 라이브러리를 남에게 제공하는 건 애로사항이 적지 않다.

  • 디버그 or 릴리스?
  • 32 or 64비트?
  • 최종 형태는 DLL or LIB?
  • VC++ 어느 버전? (보안 기능 링크 에러)
  • 사용하는 CRT의 형태는 DLL or static?

이런 식으로 상호 일치해야 하는 변수가 급격히 늘어나기 때문이다. 조건부 컴파일이 괜히 필요했던 게 아니다.
C/C++ 런타임 라이브러리도 비주얼 C++의 버전이 바뀜에 따라 내부적으로 야금야금 더해지고 바뀌는 기능이 있기 때문에--특히 보안 관련-- static 링크하는 경우 빌드 툴의 버전이 안 맞으면 이상한 심벌명에서 링크 에러가 나고 각종 문제가 생기기 쉽다.

그나마 같은 비주얼 C++끼리이니까 망정이지 서로 다른 컴파일러끼리 C++ 클래스 라이브러리를 공유한다면 name decoration까지 문제가 됐을 것이다. 사실상 공유 불가능이다.
옛날에는 문자 집합의 크기(일명 유니코드/ANSI)조차도 변수가 따로 있었을 정도이지만 요즘은 그래도 유니코드, 정확히는 wide string만 고려하면 되니 그건 그나마 나아졌다.

이 문제가 워낙 복잡하니..
일차적으로는 COM 같은 바이너리 표준이 나왔을 것이다.
아니면 그냥 소스 코드를 통째로 넘겨줘서 필요한 사람이 알아서 빌드해서 쓰게 하든가. 그 라이브러리가 애초부터 오픈소스 진영의 작품이라면 다행이지만, 상업용 코드라면 인터페이스 부분을 제외한 나머지에다가는 난독화 처리가 필요할 것이다.

그것도 싫으면 저런 골치아픈 요소들을 싹 잊어버리고 자바/C# 같은 바이트코드 기반으로 가는 수밖에 없는데... 그건 물론 성능은 COM보다도 엄청나게 더 희생시킨 귀결일 것이다.
그래도 아무 클래스에나 public static void Main만 있으면 그게 곧 실행 가능한 물건이고 빌드 속도도 안드로메다 급으로 빠르며 골치 아픈 32/64비트 구분 같은 것도 없는 환경이.. C++ 프로그래머로서 참 부럽게 느껴질 때가 있다.

Posted by 사무엘

2014/12/31 08:30 2014/12/31 08:30
, , , ,
Response
No Trackback , 10 Comments
RSS :
http://moogi.new21.org/tc/rss/response/1045

오래 전, 본인은 PE 방식이라고 불리는 32비트 Windows 실행 파일의 내부 구조에 대해 처음 알아 가던 시절에 굉장히 신기해한 사실이 하나 있었다. 그건 바로 파일 내부에 자신이 호출하는 API 함수의 이름이 다 나와 있다는 점이었다. 그러면 이 프로그램이 대충 무슨 기능을 활용하며 만들어졌는지도 얼추 분석이 가능해질 텐데? 16비트 바이너리에는 이런 정보가 존재한 적이 없었다. (오히려 EXE가 윈도우 프로시저 같은 콜백 함수 이름을 노출하고 있었음)

static library가 그러한 것처럼 DLL도 프로그래머가 작성한 클래스/함수가 이름이 그대로 외부로 노출된다. 그 이름을 GetProcAddress에다 전달하면 이름에 해당하는 함수 주소를 얻을 수 있다.

그러나 DLL이 제공하는 심벌들은 이름뿐만 아니라 ordinal이라고 불리는 번호도 제각각 다르게 부여받는다. 그렇기 때문에 ordinal로 주소를 얻는 것 역시 가능하다.
이 ordinal은 index나 number이 아니라 ID에 가까운 개념이다. 반드시 0부터 N까지 조밀하게 분포해 있어야 할 필요가 없으며 1000부터 시작해도 되고 중간에 빈 번호가 있어도 괜찮다.

단, 범위는 16비트로 한정이다. GetProcAddress 함수는 인자의 정수값이 16비트보다 크면 포인터로 간주하여 문자열 검색을 하며, 그보다 작은 값이면 ordinal로 간주하여 숫자 검색을 하는 방식으로 동작한다.
다시 말해 Windows의 DLL은 구조적으로 65536개 이상의 심벌을 export할 수는 없다. 물론 그렇다 해도 이것은 현실적으로 아무런 한계가 없는 것이나 마찬가지다.

16비트 시절에는 DLL의 심벌 탐색이 이름이 아닌 오로지 ordinal 방식만 지원되었던 모양이다. (그럼 GetProcAddress 함수도 인자가 PCSTR이 아니라 그냥 UINT였나?)
문자열을 비교하는 것보다는 숫자를 비교하는 게 속도도 더 빠르고 공간도 더 적게 차지하니 좋다. 그러나 ordinal 방식은 두 가지 단점이 있는데, 먼저 보안이 좀 더 안 좋으며, 그리고 ordinal 관리가 매우 까다롭다는 점이다.

보안 이슈는 쉽게 비유하자면 이렇다.
GetProcAddress("My_unique_function_name")은 내가 직접 만들지 않은 DLL 에서는 성공할 확률이 거의 없다. 그 반면, GetProcAddress((PCSTR)5)는 함수깨나 있다 싶은 아무 어중이떠중이 DLL에서도 어지간해서는 성공하게 된다.

즉, 엉뚱한 DLL을 잘못 불러왔을 때, 이후 동작이 안전하고 깔끔하게 실패하는 게 아니라 그 상황을 사전 감지를 못 하고 나중에 crash로 도질 가능성이 높다는 뜻이다.
물론, 여기서 보안이라는 건 프로그램 실행과 관련된 보안이다. ReadFile, CreateWindow 이라는 함수 이름 대신 #35, #107 식의 암호 같은 ordinal은 프로그램의 역공학을 어렵게 하는 보안(?)은 더 뛰어날 수도 있으니 말이다.

ordinal 관리 문제는 생각보다 더 까다로운 문제이다.
어떤 DLL이 개발이 한창 진행 중이어서 수시로 함수가 추가되거나 삭제된다고 생각해 보자. 그렇더라도 한번 번호가 부여된 함수는 번호가 절대 고정불변이어야만 그 DLL을 사용하는 프로그램과의 하위 호환성이 보장될 수 있다.

같은 함수라도 DLL의 다음 버전에서 ordinal이 달라져 버리면 기존 프로그램은 그 DLL을 사용할 수 없게 된다. 그런데 수백, 수천 개의 ordinal간에 결번이 생기고 영역이 추가 할당되는 것, 과연 번호 관리가 그렇게 호락호락 수월하게 가능할까?

이런 이유로 인해 32비트 이래로 DLL 심벌은 ordinal이 아닌 문자열로 import/export하는 게 관행이 되었다. 16비트 시절에는 DLL을 하나 만들려면 DEF 파일을 무조건 반드시 만들어야 했고 export하는 심벌에 대한 ordinal을 수동으로 기입해야 했다.
그러나 32비트부터는 export하는 심벌만 쭉 기입해 주면, ordinal은 그냥 이름들의 ABC순으로 0부터 N까지 자동으로 생성된다. 별로 중요하지 않은 정보가 됐기 때문이다.

그러나 오늘날에도 ordinal이 전혀 불필요하고 쓸데없느냐 하면 그렇지는 않다.
딱히 컴포넌트화를 지향하지 않고 내가 만드는 프로그램에서나 내부적으로 몰래 쓰는 소형 private DLL이라면, export하는 함수의 이름이 전혀 중요하지 않을 테니 그냥 이름을 노출할 필요도 없이 ordinal 직통을 쓰면 된다. 하는 일이 붙박이로 정해져 있고 앞으로 프로토타입이 바뀔 일이 절대로 없는 물건이라면 금상첨화. 훅 프로시저 DLL 같은 게 좋은 예 되겠다.

혹은, 심벌 개수가 수천~수만 개로 너무 많은 대형 DLL의 경우, 로딩 시간을 조금이라도 단축하기 위해서 의도적으로 이름 대신 ordinal 기반 로딩 방식을 고집하기도 한다.
당장 MFC 라이브러리, 그리고 MS Office가 내부적으로 사용하는 공용 라이브러리인 mso.dll도 전부 ordinal 기반이다. MFC를 DLL 링크한 프로그램이라고 해서 export 섹션에 CWnd, CWinApp 이런 클래스 심벌들이 주룩 노출돼 있는 거 아니다.

심벌을 이름이 아닌 ordinal로 식별하게 DLL과 import library를 만들려면 빌드 시에 DEF 파일을 만들어서 심벌에 대한 특성과 ordinal 번호를 수동으로 지정해 줘야 한다.
그런데 C가 아닌 C++ 스타일의 클래스나 함수를 ordinal로 지정하는 법은 잘 모르겠다. 비주얼 C++ 스타일로 복잡하게 decorate된 명칭들을 일일이 다 열거하면서 @번호 NONAME 속성을 다 줬으려나? 그것도 보통일이 아닐 텐데.

Posted by 사무엘

2014/06/12 08:33 2014/06/12 08:33
, ,
Response
No Trackback , 3 Comments
RSS :
http://moogi.new21.org/tc/rss/response/973

윈도우 운영체제에는 잘 알다시피 DLL이라는 게 있어서 한 프로세스를 구성하는 코드들을 여러 모듈로 분할할 수 있고, 반대로 동일한 코드를 여러 프로세스가 동시에 효율적으로 공유할 수 있다. 유닉스 계열의 운영체제에는 이와 비슷한 개념으로 shared library (*.so)라는 게 있다.

윈도우 DLL은 주소 공간이 해당 DLL을 로딩한 프로세스(EXE)에 완전히 종속된다. 그렇기 때문에 DLL이 사용하는 코드는 공유될지언정, 그 DLL이 선언하는 전역변수 같은 건 EXE마다 제각각이며, 심지어 동일 EXE를 여러 번 실행하더라도 제각각이다. 이것은 유닉스의 shared library와 다른 점이다. 윈도우에서 진짜 여러 프로세스들이 공유할 수 있는 메모리를 만들려면 내부적으로 공유되는 메모리 섹션을 별도로 생성해 놔야 한다.

이런 차이로 인해 특별히 조심해야 할 점이 있다. 한 EXE 안에서 3개의 DLL이 돌아가고 있고, 이들은 모두 동일한 버전의 비주얼 C++로 개발되어 동일한 CRT 라이브러리를 사용한다고 치자. 만약 이들이 모두 CRT를 DLL로 링크하여 동일한 CRT 라이브러리의 코드와 데이터를 공유한다면, 어느 모듈에서 malloc/new한 메모리를 다른 모듈에서 곧바로 free/delete해도 된다.

그러나 이들이 제각각 CRT를 static link했다면 그럴 수 없다. 한 모듈에서 할당한 메모리는 반드시 동일한 모듈에서만 해제해야 한다. 비록 메모리를 할당하고 해제하는 코드는 실질적으로 동일하지만 해당 코드가 동작하는 context는 모듈들이 모두 서로 다르기 때문이다. 모듈간에 메모리 할당과 해제를 자유롭게 하려면 각 모듈들은 자신만의 메모리 할당/해제 함수를 외부에다 별도로 제공해 줘야 한다.

DLL을 만드는 일은 64비트 운영체제가 등장하면서 다소 까다로워졌다. 32비트와 64비트 사이에는 DLL의 교차 로딩이 되지 않기 때문이다. 즉, 32비트 모듈(EXE/DLL)은 64비트 DLL을 읽을 수 없으며, 반대로 64비트 모듈은 32비트 DLL을 읽을 수 없다. 다시 말해, x64 운영체제에서도 x86 EXE를 아무 차이 없이 실행은 할 수 있지만, 그래도 64비트 EXE가 32비트 DLL을 내부적으로 읽을 수는 없으므로 착오 없기 바란다.

한 주소 공간은 전부 32비트 아니면 전부 64비트뿐이지, 두 종류의 코드가 섞여 있을 수가 없어졌다. 옛날과는 달리 지저분한 썽킹 계층 같은 게 없다. 동일 코드를 32비트와 64비트용으로 제각기 빌드해서 다같이 내놓아야 한다.
그러니 태생적으로 반드시 네이티브 코드 DLL을 만들어야 하는 컴포넌트/미들웨어/드라이버 분야라든가, 훅킹 내지 운영체제의 shell extension을 만드는 쪽은 다른 어떤 분야보다도 진작부터 64비트 프로그래밍이 필요했다. 사실은 IME 쪽도 예외가 아니다.

그런데 문제가 있다. 지금까지 DLL은 코드를 담는 용도로만 쓰여 온 게 아니라는 점이다. 잘 알다시피 윈도우 운영체제는 실행 파일의 포맷 차원에서 리소스라 하여 나름 계층을 갖춘 custom 데이터를 간편하게 불러오는 기능이 있다. 일일이 메모리 할당을 할 필요가 없이, 운영체제가 알아서 메모리 주소를 잡아서 로딩해 주고, API 함수 호출 한 번에 데이터를 곧장 찾아 주기까지 하니, 상당히 편리하다. 그래서 데이터 리소스만 갖춘 DLL도 엄청 많이 쓰였다.

특히 다국어 지원을 위한 외국어 리소스를 집어넣는 데 DLL보다 더 좋은 수단은 지금까지 별로 없었다. 운영체제의 대화상자 내지 string table 같은 주요 리소스 파일의 포맷은 32비트나 64비트나 바뀐 게 없다. 호환성 차원에서 일부러 동일하게 유지시킨 듯하다. 비주얼 스튜디오 200x의 MSDN(Document Explorer)이 사용하는 *.hxs 파일도 리소스 전용 DLL이다.

그런데 DLL은 태생적으로 컴퓨터 기계 종류에 지극히 종속적인 코드를 담는 게 주 목적인 파일 포맷이다. 그렇다면 기계 종류에 관계없이 동일한 데이터를 담는 DLL도 기계별로 일일이 다 따로 만들어야 할까?
그렇지는 않다. 데이터밖에 들어있지 않은 리소스 전용 DLL을 위해서 운영체제는 다음과 같은 배려를 하고 있다.

먼저, DLL을 읽을 때 흔히 사용하는 LoadLibrary 대신, LoadLibraryEx 함수는 LOAD_LIBRARY_AS_IMAGE_RESOURCE 같은 플래그를 제공하여 DLL의 코드 부분을 전혀 감안하지 않고 데이터 부분만 읽게 할 수 있다. 이렇게 하면 해당 DLL은 DllMain 함수 자체가 실행되지 않으며, 자신과 호환되지 않는 기계를 타겟으로 만들어진 DLL도 읽을 수 있다. 물론, FindResource 같은 함수로 리소스 추출만 가능할 뿐, GetProcAddress 같은 기능을 사용할 수는 없다.

다음으로, DLL 자신이 리소스 전용 DLL일 뿐이라고 명시해 줄 수 있다. 비주얼 C++의 DLL 프로젝트에서 링커 옵션을 보면 /NOENTRY가 있다. 이 옵션이 지정되면 그 DLL에는 아무 코드도 삽입되지 않으며, 진입점인 DllMain 함수 자체가 없는 리소스 전용 DLL임이 인증된다. 이런 DLL은 외부에서 그냥 대놓고 LoadLibrary로만 호출해도 기계 종류에 관계 없이 읽을 수 있다.

윈도우는 공유 라이브러리를 다루는 개념이 유닉스 계열과 차이가 있을 뿐만 아니라 실행 파일을 로딩하는 방식에도 개념적인 차이가 있다. 윈도우는 position-dependent, 즉, 주소 종속적인 방식이다. 윈도우의 EXE와 DLL에는 preferred address라는 게 있어서, 이 메모리 주소에 딱 로딩이 되면 아주 성능이 좋지만, 그 주소에 로딩이 못 되면 재배치 페널티가 따르는 방식을 사용한다. 재배치 작업을 어떻게 하면 되는지에 대한 재배치 정보가 reloc이라는 섹션에 있다.

EXE야 Win32s 같은 과거의 열악한 환경이 아닌 이상, 자신만의 고유한 주소 공간이 있기 때문에 언제나 preferred address에 로딩된다는 보장이 있다. 그에 반해 남의 주소 공간에 달라붙는 형태인 DLL은 그렇지 못하기 때문에 일반적으로 반드시 reloc 섹션이 필요하다.
다만, 이런 재배치 작업도 코드가 없는 리소스 전용 DLL에는 전혀 해당사항이 없다. 리소스 내부에 DLL의 메모리 주소에 종속적인 숫자 데이터 같은 게 있을 리가 없기 때문이다.

그러니 이론적으로 리소스 전용 DLL은 reloc 정보를 생략해 버려도 괜찮지만, 그냥 대놓고 생까-_- 버리는 것도 곤란하다. 윈도우 2000 미만의 옛날 운영체제의 경우 DLL을 preferred base에다 로딩을 못 하는데 재배치 정보마저 없다면, 원래 재배치를 할 게 없는 리소스 전용 DLL임에도 불구하고 그냥 일방적으로 로딩을 거부해 버리게 된다.

재배치를 안 해도 괜찮은 DLL이라는 별도의 플래그는 윈도우 2000 이상에서부터 도입되었다. 물론, 애당초 기계어 코드가 들어있지 않은 DLL의 reloc 섹션에 의미 있는 재배치 정보 자체가 있을 리가 없다. 그냥 껍데기뿐인 잉여인 것이다.

이렇듯, DLL은 원래 코드를 공유하려고 만들어졌지만 기계와는 무관한 리소스나 데이터를 공유하는 container 역할도 결코 무시할 수 없는 위치에 있다. 심지어 트루타입(TTF) 글꼴이 도입되기 전에 윈도우에서 쓰이던 비트맵/벡터 글꼴인 FON 파일은 16비트 형태의 리소스 전용 DLL로, 오늘날의 64비트 윈도우에서도 그 잔재가 남아 있다!

그래서 운영체제가 리소스 전용 DLL에 대해서는 예외적으로 기계를 가리지 않고 읽을 수 있고, 또 그런 DLL에는 불필요한 정보를 합법적으로 생략도 할 수 있게 별도의 배려를 해 왔음을 우리는 윈도우 API의 변천사를 통해 알 수 있다.
그러고 보니, 32/64비트 응용 프로그램이 각각 32/64비트에 해당하는 윈도우 시스템 디렉터리나 각종 프로그램 디렉터리를 얻어 오고 접근하는 법도 꽤 복잡해져 있는데, 이건 다음 기회에 다루도록 하겠다.

Posted by 사무엘

2012/02/19 08:29 2012/02/19 08:29
,
Response
No Trackback , 2 Comments
RSS :
http://moogi.new21.org/tc/rss/response/643


블로그 이미지

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

- 사무엘

Archives

Authors

  1. 사무엘

Calendar

«   2020/09   »
    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:
1445358
Today:
101
Yesterday:
535