공유되는 위험한 DLL 내 전역 정적변수

주싱·2022년 11월 8일
0

Trouble Shooting

목록 보기
15/21
post-custom-banner

시작하며

윈도우 운영체제에서 DLL의 코드 영역은 운영체제 내 모든 프로세스가 공유하는 메모리 영역에 올라가서 시스템 전체에서 공유되어 사용된다. DLL 함수에서 선언된 지역변수는 각 프로세스의 쓰레드 스택에서 독립적으로 관리될 것이고, new 해서 생성되는 객체 역시 각 프로세스의 힙 메모리 영역에 독립적으로 관리될 것인데 정적으로 선언된 전역변수는 어떨까? 동일한 하나의 프로세스 내에서 하나의 DLL을 여러번 로드하면 전역변수는 공유될까? 서로 다른 프로세스에서 하나의 DLL을 로드할 때는 다를까? 이런 궁금증에 대한 답을 찾기 위해 테스트 코드를 작성하고 관련된 글도 찾아보며 정리해 본다.

하나의 프로세스 안에서

DLL 코드

아래의 코드는 DLL (Dynamic Linking Libraray)로 컴파일될 C++ 코드의 일부이다. number라는 전역 변수가 있고 IncAndGet() 메서드는 number를 1증가시키고 값을 반환하는 간단한 샘플코드이다.

int number = 0;

extern "C" __declspec(dllexport) int IncAndGet() {
    number = number + 1;
    return number;
}

EXE 코드

그리고 다음 코드는 DLL을 로드해서 사용하는 실행파일이 될 클라이언트 코드이다. 먼저 Test.dll을 두 번 로드해서 handleA와 handleB 를 각각 얻는다. 그리고 Test.dll이 익스포트하는 IncAndGet() 메서드의 주소 역시 각각의 핸들을 통해 incAndGetA, incAndGetB를 얻는다. 이제 incAndGetA를 먼저 호출하고 이어서 incAndGetB를 호출하여 결과를 확인한다.

typedef int (*GetIntProc)();
... 
HMODULE handleA = LoadLibrary(_T("Test.dll"));
HMODULE handleB = LoadLibrary(_T("Test.dll"));
GetIntProc incAndGetA = (GetIntProc)GetProcAddress(handleA, "IncAndGet");
GetIntProc incAndGetB = (GetIntProc)GetProcAddress(handleB, "IncAndGet");

printf("handleA = %p, handleB %p\n", handleA, handleA);
printf("incAndGetA = %p, incAndGetB %p\n", incAndGetA, incAndGetA);
printf("incAndGetA() returns %d\n", incAndGetA());
printf("incAndGetB() returns %d\n", incAndGetB());

하나의 프로세스 내에서 Test.dll을 로드(LoadLibrary 통해)할 때 마다 각각의 전역 변수가 할당된다면 IncAndGet() 결과는 항상 1일 것이다. 그러나 두 번째 Test.dll 로드 시, 이미 생성된 DLL 모듈의 전역변수를 공유해서 사용한다면 두번째 IncAndGet() 호출은 2를 반환할 것이다. 실제 실행 결과는 아래와 같다.

// Output: 
// handleA = 00007FF987260000, handleB 00007FF987260000
// incAndGetA = 00007FF9872615E0, incAndGetB 00007FF9872615E0
// incAndGetA() returns 1
// incAndGetB() returns 2

결과 정리

실행 결과를 정리해 보면 다음과 같다.

  • 두번 로드한 handleA와 handleB는 같은 값을 가진다.

공식 메뉴얼을 읽어 보면 HMODULE 타입의 이 핸들 값은 메모리에 올라간 모듈의 베이스 주소라고 설명한다.

  • incAndGetA와 incAndGetB 역시 같은 값을 가진다.
  • 두 번째 incAndGetB() 함수 호출 시, 직전의 incAndGetA() 호출을 통해 이미 1 증가된(변경된) 전역 변수의 값 때문에 1이 아니라 2가 반환된다.

서로 다른 프로세스에서

서로 다른 프로세스에서 하나의 DLL을 로드하고 똑같은 코드를 실행해 보면 어떨까? 아래와 같이 하나의 프로세스에서 DLL 로드와 IncAndGet() 메서드 호출까지 하고 Sleep으로 대기하고 있는 중에 다른 프로세스에서 동일한 DLL을 로드하고 IncAndGet() 메서드를 호출해 본다.

Process A

HMODULE handleA = LoadLibrary(_T("C:\\Repo\\Test.dll"));
GetIntProc incAndGetA = (GetIntProc)GetProcAddress(handleA, "IncAndGet");
printf("handleA = %p\n", handleA);
printf("incAndGetA = %p\n", incAndGetA);
printf("incAndGetA() returns %d\n", incAndGetA());
Sleep(10000);
FreeLibrary(handleA);

// Process A: 
// handleA = 00007FFC32270000
// incAndGetA = 00007FFC322715E0
// incAndGetA() returns 1

Process B

HMODULE handleB = LoadLibrary(_T("C:\\Repo\\Test.dll"));
GetIntProc incAndGetB = (GetIntProc)GetProcAddress(handleB, "IncAndGet");

printf("handleB = %p\n", handleB);
printf("incAndGetB = %p\n", incAndGetB);
printf("incAndGetB() returns %d\n", incAndGetB());
FreeLibrary(handleB);

// Process B:
// handleB = 00007FFC32270000
// incAndGetB = 00007FFC322715E0
// incAndGetB() returns 1

결과 정리

실행 결과를 정리해보면 다른 결과를 얻는다.

  • 두번째 로드한 DLL의 핸들과 IncAndGet 메서드 주소는 같은 값을 가진다.
  • 그러나 두번째 프로세스에서 두번째로 호출한 IncAndGet 메서드 결과는 2가 아니라 1이다.

프로세스 간에는 공유되지 않는 전역 데이터

두 가지 실험을 통해서 동일한 프로세스에서 여러번 LoadLibrary()를 통해 dll을 로드하더라도 실제로는 전역 정적변수를 포함한 하나의 모듈이 메모리에 로드되어 공유됨을 확인할 수 있었고, 반면에 다른 프로세스에서 여러번 LoadLibrary() 통해 dll을 로드하면 코드 영역은 공유되지만 전역 정적 변수는 독립적으로 관리됨을 확인할 수 있었다. 실험 결과는 확인했지만 이론적인 설명을 보고 싶어 책장에 꽂혀있는 고전 같은 책인 ‘제프리 리처의 Windows via C/C++’책을 꺼내 본다. DLL과 관련된 장을 읽다가 꼭 맞는 설명을 찾는다. 책에서 전역 정적변수에는 ‘카피 온 라이트’ 전략에 의해 각각의 프로세스가 복사본을 가지게 된다고 한다.

실행 파일 내에 전역으로 선언된 정적변수는 동일한 실행 파일이 여러 번 실행될 경우라도 공유되지 않는다. 이는 윈도우가 13장 “윈도우 메모리의 구조”에서 알아본 바 있는 카피 온 라이트 메커니즘을 이용하기 때문이다. DLL 파일 내에 전역으로 선언된 정적변수 또한 이와 동일한 메커니이 적용된다. 프로세스가 DLL 이미지 파일을 자신의 주소 공간 내에 매핑하는 경우, 실행 파일의 경우와 동일하게 전역으로 선언된 정적변수의 새로운 인스턴스가 생성된다. - 19장. DLL의 기본 687p -

참조 카운터 원리

관련 내용들을 조금 찾아본다. 먼저 마이크로소프트에서 제공하는 LoadLibaray의 공식 메뉴얼을 읽어본다. 아래와 같은 내용이 보인다. 윈도우 시스템은 프로세스 단위로 로드된 DLL 모듈 참조 카운터를 유지한다고 한다. 그래서 DLL이 처음 로드될 때에만 메모리에 올라가고 두 번째부터는 참조 카운터만 증가시키고 FreeLibrary메서드를 호출하면 참조 카운터를 1 감소시킨다고 한다. DLL 모듈 언로드 역시 FreeLibrary가 호출될 때 마다 실행되는 것이 아니라 참조 카운터가 0이되면 DLL 모듈을 언로드한다고 한다.

The system maintains a per-process reference count on all loaded modules. Calling LoadLibrary increments the reference count. Calling the FreeLibrary or FreeLibraryAndExitThread function decrements the reference count. The system unloads a module when its reference count reaches zero or when the process terminates (regardless of the reference count).

우리가 경험한 오류

최근에 Java코드로 JNA를 통해 윈도우 Native 코드인 DLL을 호출해서 장비를 제어할 일이 있었다. DLL에서는 장비와 연결을 수행하고 메시지를 주고 받는 코드가 있는데 연결과 관련된 핸들 값이 전역 변수로 되어있었다. 동일한 타입의 장비가 총 5대가 있었는데 LoadLibrary를 5번 호출하면 DLL 모듈이 5개가 생긴다고 생각하고 작성된 코드가 있었는데 결과는 전혀 그렇지 않았다. 그래서 확인해보니 이런 숨겨진 원리들이 있었다.

아마도 이런 원리들을 제대로 이해하지 못했다면 제대로 해결하기 힘든 문제가 아닐까 생각해 본다.

참조

profile
소프트웨어 엔지니어, 일상
post-custom-banner

0개의 댓글