책정리/윈도우 API 정복2

39장 메모리

GONII 2019. 4. 4. 23:39
01 Win32 메모리 구조
. 16비트 환경의 메모리 구조
응용 프로그램들은 예외없이 메모리 상에서 실행되며 또한 응용 프로그램이 다루는 데이터도 메모리에 존재한다. 메모리 관리는 운영체제의 고유 권한이자 가장 중요한 임무중 하나이다. 그래서 운영체제가 메모리를 관리하는 방법에 대한 기초적인 이해는 아주 중요하다.
Win32 메모리 체계는 무척 복잡한 편이지만 그래도 Win16 비해서는 훨씬 깔끔해졌다. 32비트의 메모리 체계를 공부하기 전에 이전의 환경인 Win16 메모리는 어떤 문제점이 있었는지부터 정리해보자.
 
용량상의 문제
메모리가 부족하게 근본적인 원인은 여러 개의 프로그램을 동시에 실행하는 멀티 태스킹 환경의 도래이며 또한 멀티 미디어는 이런 상황을 가속화했다.
구조적인 문제
8088 16비트 CPU였고 내부 레지스터가 16비트밖에 안되므로 20비트(1M) 주소 영역을 16비트로 가리킬 수가 없었다. 그래서 개의 값을 조합하여 메모리의 지점을 가리키는 입체적인 세그먼트/오프셋 구조를 사용했는데 구조는 프로그래머들의 골치를 썩히는 문젯거리였다.

개의 값을 조합하여 주소를 나타낸다는 개념 자체를 이해하기도 어려울 뿐만 아니라 같은 주소를 가리키는 여러 가지 값의 조합이 존재해서 사용하기도 불편했다. 시절의 개발자들은 안그래도 헷갈리는 포인터를 근거리(near), 원거리(far) 등으로 구분해서 사용해야 했으며 포인터의 종류에 따라 메모리 모델이라는 어려운 것들도 공부해야만 했다. 물론 지금의 32비트 환경에서는 이런 것들이 없어졌으므로 몰라도된다.
안정성의 문제
Win16 메모리 구조의 가장 심각한 문제는 구조적으로 안전성이 없다는 점이다. 모든 프로그램이 같은 주소 공간에서 실행되므로 포인터만 가지고 있으면 상호의 메모리 영역을 너무나도 쉽게 침범 있었던 것이다. 심지어 시스템 영역까지도 마음대로 건드릴 있어 운영체제 자체가 불안정할 밖에 없다.

각각의 프로그램은 자신의 메모리 영역을 액세스하고 때로는 시스템의 함수를 호출하면서 실행되는데 정상적인 프로그램은 자신이 변경할 있는 메모리 영역만 액세스하는데 비해 잘못 만든 프로그램은 자신의 영역이 아닌 곳을 건드릴 수도 있다.
이때는 예측이 불가능하여 일반적으로 시스템이 다운될 확률이 아주 높다. 모든 프로그램이 같은 주소 공간에서 실행된다는 근본적인 문제가 있어 버그있는 프로그램으로부터 시스템을 완벽하게 보호할 수가 없었다. 운영체제를 아무리 정교하게 만들어도 프로그램이 잘못된 동작을 하면 이를 원천적으로 막을 방법이 없는 것이다. 이런 환경에서 개발을 하면 시스템을 재부팅하는 일이 아주 빈번하며 이는 생산성의 저하로 직결된다.
. 4G 평면 메모리 모델
16비트의 부족한 메모리 용량과 구조적인 문제를 근본적으로 해결한 CPU 80386이다. 386 이전 세대의 제반 문제들을 한꺼번에 해결하고 32비트 시대를 활짝 역사적인 CPU 라고 있다. 이후 486, 팬티엄 2,3,4 등의 CPU 386 메모리 구조를 그대로 사용하고 있다. 그래서 메모리 구조에 관해서는 386 CPU 메모리를 어떻게 관리하는가만 제대로 이해하면 된다.
386 관리할 있는 메모리 용량은 최대 4G 바이트이다. 386 32비튼 CPU이며 범용 레지스터의 크기가 32비트이기 때문에 레지스터 하나로 지정할 있는 메모리 번지가2 32승인 4G이다. 4G바이트의 메모리 용량은 발표 당시의 가격으로 11 정도 되며 386 이전의 어떤 응용 프로그램도 사용하기 힘든 실로 엄청난 용량이었다.
주소 공간이란 물리적인 메모리와 연결될 가능성이 있는 메모리 번지일 뿐이며 실제로 값을 기억할 있는 메모리와는 다르다. 윈도우즈는 개별 프로세스마다 4G바이트의 주소 공간을 새엉하는데 어디까지나 주소공간이 주어질 뿐이므로 물리적인 메모리가 소모되는 것은 아니다. 가상 주소 공간이 어떻게 메모리 구실을 하는지는 다음 항에서 연구해 보자.
16비트 CPU 세그먼트와 오프셋값을 조합한 입체적인 주소 체계를 사용한 비해 386 하나의 주소값으로 4G바이트 주소 공간중 지점을 가리킬 있기 때문에 평면적(Flat)이라고 표현한다.
386 가장 업적은 용량상의 확대나 평면 메모리 구조보다도 안전성의 확보에 있다. 물리적인 메모리와 응용프로그램의 주소 공간이 완전히 격리됨으로 해서 잘못된 프로그램으로부터 운영체제가 스스로 방어할 있게 되었다. 메모리도 일종의 하드웨어인데 운영체제가 메모리 관리를 장악함으로써 응용프로그램의 잘못된 동작을 원천적으로 막을 있다.
. 가상 메모리
만약 물리적인 메모리(RAM) 부족하다면 윈도우즈는 하드 디스크의 페이징 파일(Paging File) 물리 메모리 대신 사용한다. 당장 사용되지 않는 부분은 하드 디스크의 페이징 파일에 저장하여 사용 가능한 RAM 늘린다. 물리적인 RAM 하드디스크의 페이징 파일을 합해서 가상 메모리(Virtual Memory)라고 한다.
사실 페이징 파일은 물리적인 RAM 논리적으로 동일하되 다마 속도가 느린 메모리일 뿐이다. 응용프로그램의 입장에서 볼때 자신의 주소 공간에 연결된 가상 메모리가 물리적인 RAM인가 페이징 파일인가는 전혀 신경 쓰지 않아도 된다.
가상 메모리는 많은 메모리를 있게 뿐만 아니라 운영체제의 안정성에도 역할을 한다. 프로세스의 주소공간은 상호 독립적이기 때문에 프로세스끼리 서로의 주소 영역을 침범할 없도록 되어 있다. 운영체제는 프로세스가 생성될 때마다 독립적인 4G바이트의 주소공간을 생성하고 물리적인 메모리를 논리적인 주소 공간에 연결(Map)한다. 이때 물리적인 메모리와 논리적인 주소 공간의 대응 관계는 페이지 테이블(Page Table)이라는 표에 작성되며 표는 운영체제에 의해 관리된다. 하나의 프로세스 A 실행되고 있을 때의 모양을 그려보면 다음과 같다.

페이지 테이블에는 가상 메모리의 어디쯤이 응용 프로그램 누구의 번지에 연결되어 있다는 정보가 기록되어 있으며 응용 프로그램은 오로지 자신의 주소 공간상의 번지만 다룰 있다. 프로세스는 자신에게 주어진 4G 가상 주소 공간이 실제의 메모리인 것처럼 사용하며 운영체제와 CPU 페이지 테이블을 통해 물리적인 메모리를 가상 주소 공간에 연결하여 프로세스가 물리적인 메모리를 사용할 있도록 한다. 주소 공간이라는 개념은 바로 페이지 테이블에 기록되어 있는 응용 프로그램이 참조하는 주소값이며 값이 32비트로 기록되므로 4G 영역을 가질 있는 것이다.
상태에서 다른 프로세스 B 실행되면 B에게도 4G 가상 주소 공간이 생성되며 B 필요로 하는 가상 메모리가 페이지 테이블을 통해 맵핑된다. 이때 A B 프로세스는 독립적인 주소 공간상에서 실행되기 때문에 상호 간섭을 없으며 서로의 주소 공간에 대해 액세스할 있는 방법이 제공되지 않는다. 설사 A 버그에 의해 엉뚱한 번지에 마구 값을 넣더라도 A 영향을 받을 B 전혀 영향을 받지 않는다. A 0x12345678번지에 값을 넣어도 번지는 B 0x12345678과는 물리적인 위치가 다르기 때문이다.
가상 주소 공간은 실제로 존재하는 메모리가 아니라 다만 페이지 테이블에 기록되는 개념적인 주소일 뿐이다. 개의 응용 프로그램 A B 실행되고 있을 가상 메모리에는 프로그램의 메모리들이 섞여서 존재하며 페이지 테이블에는 가상 메모리의 어떤 위치가 응용 프로그램의 주소 공간 어디쯤에 연결되어 있다는 정보가 기록되어 있다.

이때 표의 왼쪽 열의 번지는 중복될 없지만 오른쪽 열은 연결된 응용 프로그램이 다르다면 중복된 번지를 가질 수도 있다. 프로세스가 인식하는 주소 공간은 자신의 것일 뿐이므로 다른 프로세스와 우연히 가은 주소 공간을 쓴다 하더라도 실제로는 가상 메모리의 다른 지점이므로 충돌이 발생하지 않는다.
 
이런 주소 공간의 분리에 의해 프로세스간 악영향을 미칠 가능성이 전혀 없다. 설사 A 심각한 버그를 가지고 있어 임의의 번지를 마구 액세스한다고 하더라도 혼자 다운될 다른 응용 프로그램이나 운영체제 자체를 손상시킬 수는 없다. 그래서 Win32 운영체제는 Win16 비해 훨씬 안정적이다.
반면 주소 공간의 분리에 의해 프로세스간 데이터를 교환하기 어렵다는 단점이 있다. Win16에서는 공유하고자 하는 데이터를 메모리 번지의 어딘가에 두고 메시지를 통해 포인터만 알려주는 방식으로 프로세스끼리 데이터를 주고 받았지만 공간이 분리된 Win32에서는 이것이 불가능해졌다. 그래서 메모리 파일과 IPC라는 프로세스간 데이터 공유 기법이 필요해진 것이다.
. 주소 공간 배치
Win32 환경에서 모든 프로세스는 자신만의 4G 주소 공간을 가지는데 넓은 주소 영역을 아무렇게나 쓰는 것이 아니라 구획(Partition) 나누어 사용한다. 주소 공간을 배치하는 방법은 운영체제 버전에 따라 다른데 다음은 Win95 주소 공간 배치도이다. 같은 계열인 Win98 구조도 이와 동일하다.

프로세스는 제일 아래쪽 4K 주소 영역을 제외하고는 모든 부분을 액세스할 있다. 심지어 운영체제 영역에도 액세스가 가능하도록 되어 있기 때문에 잘못된 프로세스에 의해 운영체제가 마비될 가능성이존재한다. Win95 16비트 응용 프로그램과의 호환성을 고려하여 만들어진 운영체제이기 때문에 기술적인 문제로 운영체제 영역을 보호할 없으며 태성적으로 NT만큼의 안정성을 확보할 수가 없다.
NT환경에서 프로세스의 주소 공간은 다음과 같이 분할된다. 물론 NT 계승한 2000, XP 메모리 구조도 거의 동일하다.

NT에서의 프로세스는 자신의 고유 영역 외에는 어떤 부분도 액세스할 없다. 그래서 응용 프로그램이 운영체제를 마비시키는 불상사가 발생하지 않아 Win95 비해 훨씬 견고하다. 운영체제의 영역과 응용 프로그램의 영역이 절반씩 분할되어 있는데 이는 다소 불합리해 보인다. 디바이스 드라이버나 운영체제 자체의 코드는 크지 않으므로 가급적이면 응용 프로그램의 고유 영역을 많이 확보하는 것이 합리적이다.
운영체제 영역이 2G 되는 이유는 MIPS CPU와의 호환성을 위해서인데 CPU 구조가 이런 메모리 배치를 요구하기 때문이다. NT 개발 당시 멀티 플랫폼을 계획했기 때문에 모든 CPU에서 말썽이 없는 구조를 채택했고 당시로서는 응용 프로그램 영역으로 2G 정도면 아주 충분하다고 생각을 했었다. 그러나 요즘은 2G 부족하게 되었고 멀티 플랫폼을 포기한 2000이후에는 BOOT.INI /3GB 스위치를 붙여 응용 프로그램 영역을 3G까지 있는 옵션이 추가 되었다. 2000어드밴스드 서버 이후부터는 디폴트로 옵션을 채택하므로 그림과는 다소 다른 형태로 분할된다. 다음 그림은 2000 환경에서 4개의 프로세스가 실행주일 때의 메모리 구조도를 가상적으로 그린 것이다.

프로세스의 주소 공간은 모두 분리되어 있고 페이지 테이블에는 프로세스의 주소 공간과 가상 메모리의 연결 상태가 기록되어 있다. 응용 프로그램의 코드와 데이터는 가상 메모리에 로드되며 폐이지 테이블에는 물리 주소가 응용 프로그램의 가상 주소 공간의 어디쯤에 연결되어 있다는 기록이 작성되어 있을 것이다. 이후 응용 프로그램은 자신의 주소 공간을 액세스함으로써 가상 메모리를 액세스한다. 주소 공간의 대부분은 비어 있는 상태일 것이며 응용 프로그램이 메모리를 할당하면 요구한만큼 가상 메모리가 할당되고 영역은 다시 자신의 주소 공간에 맵된다. 만약 가상 메모리에 연결되지 않은 주소 영역을 액세스하면 이것을 액세스 위반(Access Violation)이라고 하며 이런 프로그램은 즉시 사살된다.
윈도우즈 환경에서는 비디오카드, 사운드카드, 네트워크 등의 하드웨어들을 운영체제가 관리하며 응용 프로그램이 하드웨어를 직접적으로 프로그래밍하지 못하게 금지되어 있다. 비디오 출력을 하고 싶으면 반드시 GDI 비디오 디바이스 드라이브를 경유해야만 하며 이런 계층에 의해 장치 독립성이 확보된다. 메모리의 경우도 마찬가지로 응용 프로그램이 직접 물리적인 메모리를 액세스하지 못하게 금지되어 있으며 자신의 주소 공간을 통해 운영체제의 중계를 거쳐야만 하며 대가로 안정성을 확보한다.
02 가상 메모리
. C 런타임 함수
응용 프로그램에서 메모리가 필요할 경우 운영체제에게 메모리 할당을 요청한다. 운영체제는 원칙적으로 응용 프로그램의 메모리 할당 요청을 거절하지 않으며 할당한 메모리는 해제하기 전에는 다른 할당 요청에 사용되지 않는다. Win32에서 메모리를 할당하는 방법에는 여러 가지가 있는데 가장 간단한 방법이 C 런타임 함수를 사용하는 것이다. 4G 평면 메모리 모델의 간단한 구조 덕분에 malloc, free 함수만 사용해도 기본적인 메모리 할당은 있다.
void* malloc(size_t size);
void free(void* memblock);
malloc 인수로 할당하고자 하는 메모리의 크기를 바이트 단위로 밝히기만 하면 된다. 운영체제는 물리적인 메모리를 할당하여 가상 주소 공간에 맵한 번지를 리턴한다. 만약 메모리 부족으로 할당되지 않을 경우 에러에 해당하는 NULL 리턴한다. malloc으로 할당한 메모리를 사용하나 free 해제한다.
 
malloc 유사한 할당 함수들이 있다.
void* calloc(size_t num, size_t size);
void* realloc(void* memblock, size_t size);
calloc size크기의 변수값 num 개분에 해당하는 메모리를 할당한다. mallooc(size*num) 동일하되 필요한 메모리 양을 논리적으로 나타낸다는 점만 다르다.
realloc 이미 할당된 메모리의 크기를 변경하여 재할당하는 함수이다. 확장시 연속된 공간에 재할당하기 위해 메모리의 위치가 변경될 있다.
운영체제가 필요에 의해 가상 메모리상의 위치를 옮기더라도 페이지 테이블을 같이 수정하면 응용 프로그램은 자신이 알고 있는 포인터로 계속 메모리를 사용할 있다. 가장 쉽고 무난하므로 특별한 기능 제한(메모리 보호, 액세스 지정 )없이 메모리 할당 자체가 목적이라면 함수만 사용해도 무방하다.
C++ 객체를 동적으로 할당할 때는 new 연산자를 사용한다. new 연산자는 피연산자로 주어진 클래스형 객체만큼의 메모리를 할당할 뿐만 아니라 해당 클래스의 생성자를 호출하여 객체를 초기화하기까지 한다. 그리고 해당 객체의 포인터를 리턴한다. 이렇게 동적으로 생성된 객체는 delete 연산자로 파괴한다.
. 가상 메모리 할당
Win32에서 추가된 가상 메모리 할당 함수들은 전통적인 이런 함수들에 비해 가지 추가적인 이점을 제공하며 메모리에 대한 섬세한 통제를 있다. 가상 메모리 함수가 malloc 함수에 비해 가지는 이점은 다음 가지이다.
  1. 메모리를 예약 상태로 할당할 있다. 예약이란 물리적인 메모리를 소비하지 않으면서 주소 공간만을 미리 할당해 놓는 방법이다. 이렇게 예약된 페이지는 필요할 언제든지 필요한 부분만 확정해서 사용할 있으므로 realloc 회수를 줄일 있다.
  2. 할당한 메모리의 액세스 권한을 지정할 있다. malloc으로 할당한 메모리는 언제나 읽기/쓰기가 가능하지만 가상 메모리 함수로 할당한 메모리는 읽기 전용, 액세스 금지 속성을 가질 있어 실수로 인한 데이터 파괴를 막을 있다.
가상 메모리를 할당할 때는 VirtualAlloc 함수를 사용하고 해제할 때는 VirtualFree 함수를 사용한다.
LPVOID VirtualAlloc(LPVOID lpAddress, DWORD dwSize, DWORD flAllocationType, DWORD flProtect);
BOOL VirtualFree(LPVOID lpAddress, DWORD dwSize, DWORD dwFreeType);
lpAddress 할당하고자 하는 메모리의 절대 번지를 지정하되 NULL이면 시스템이 알아서 할당 번지를 지정한다. 동적으로 메모리를 할당할 할당 위치는 의미가 없으므로 보통 NULL 주되 예약된 페이지를 확정할 때는 예약되어 있는 번지를 지정해야 한다. dwSize 할당하고자 하는 메모리의 양을 바이트 단위로 지정한다. flAllocationType 할당 방법을 지정한다.
할당 방법
설명
MEM_RESERVE
물리적인 메모리의 할당없이 주소 공간만을 예약한다.
MEM_COMMIT
물리적인 메모리를 확정한다.
MEM_TOPDOWN
가급적 높은 번지에 메모리를 할당한다. NT 이상에서만 있다.
flProtect 할당한 페이지의 액세스 타입을 지정하며 보통 PAGE_READWRITE 지정한다. 메모리 할당에 성공하면 할당된 메모리의 번지를 리턴하며 실패했을 경우 NULL 리턴한다.
VirtualFree함수는 할당된 페이지를 해제한다.
lpAddress 해제하고자 하는 메모리의 선두 번지를 지정하고, dwSize 해제하고자하는 메모리의 크기를 지정한다. dwFreeType 다음 값중 하나이되 둘을 같이 없으며 반드시 따로 사용해야한다. 만약 확정된 메모리를 해제하려면 확정 해제 예약 해제해야 한다.
설명
MEM_DECOMMIT
확정된 페이지를 확정 해제한다.
MEM_RELEASE
예약된 페이지를 예약 해제 한다.

. 예약과 확정
Win32 프로세스가 가지는 4G 가상 주소 공간은 '페이지'라는 단위로 구성된다. 페이지의 크기는 시스템마다 다른데 가장 많이 사용되는 인텔 계열의 CPU에서는 4K바이트의 크기를 가진다. 윈도우즈는 페이지 단위로 주소 공간을 관리한다. 할당하거나 해제하는 단위가 페이지 단위라는 뜻이다. 주소 공간을 구성하는 페이지는 다음 가지 상태 하나의 상태로 존재한다.
상태
설명
자유 영역(Free)
사용되지 않는 자유 영역이다. 언제든지 예약하거나 확정할 있다.
예약(Reserved)
장래 사용을 위해 예약만 되어 있는 페이지이며 물리적인 메모리가 할당되어 있지 않다. 주소 공간만 할당되어 있는 상태이다.
확정(Committed)
가상 메모리와 연결되어 있는 상태이며 바로 사용할 있다. 물리적 메모리를 소모한다.

프로세스가 처음 실행되었을 대부분의 주소 공간은 자유 영역일 것이며 실행 파일의 이미지와 공유 DLL 등이 확정되어 사용될 것이다. 자유 영역으로 남아있는 주소 공간은 언제든지 할당해 사용할 있는데 할당의 수준이 예약과 확정 종류가 있다.
예약이란 그대로 주소 공간만 할당하여 번지가 다른 목적으로 사용되지 않도록 하는 것이며 확정은 물리적인 메모리가 실제로 필요할 RAM 또는 페이징 파일을 주소 공간에 연결(Map)하는 것이다.
Win16에는 없던 이런 예약과 확정이라는 것이 필요하게 되었는가 하면 물리적인 메모리와 논리적인 주소 공간이 분리되었기 때문이다. 논리적인 주소 공간을 할당하는 것이 예약이고 예약된 주소 공간에 물리적인 메모리를 연결하는 것이 확정이다. 주소 공간만을 할당하는 예약은 물리적인 메모리를 전혀 소모하지 않는다. 그래서 충분한 주소 공간을 미리 예약해 두어도 전혀 손해볼 것이 없다. 일단 예약된 주소 공간은 다른 할당 요청에 의해 다시 할당되지 않으므로 필요할 때마다 물리적인 메모리를 확정해서 사용하면 된다.
예를 들어 어떤 프로그램에서 10M바이트의 연속적인 메모리가 필요하다고 하자. 그런데 당장은 메모리가 한꺼번에 사용되지 않지만 반드시 연속적인 메모리여야 한다면 일단 10M 주소 공간을 예약해 둔다. 예약만 했으므로 주소 공간만 할당되었을 물리적인 메모리는 전혀 소모하지 않았다. 그리고 필요할 때마다 원하는 위치의 주소 공간을 확정하여 물리적인 메모리와 연결하여 사용하면 된다. 주소 공간이 연속되어 있으므로 예약된 주소와 연결되는 물리 메모리의 번지가 반드시 연속되지 않아도 아무 문제가 없다.


메모리를 예약할 것인가 확정할 것인가는 VirtualAlloc 함수의 세번째 인수 flAllocationType으로 지정하는데 예약만 때는 MEM_RESERVE 주고 예약된 메모리를 확정할 때는 MEM_COMMIT 준다. 예약과 동시에 확정하려면 플래그를 OR 묶어서 같이 지정한다. 예약과 확정을 따로 하고 싶다면 다음과 같이 두번 호출 한다.
ptr=(int*)VirtualAlloc(NULL, sizeof(int)*10, MEM_RESERVE,PAGE_READWRITE);
ptr=(int*)VirtualAlloc(ptr, sizeof(int)*10, MEM_COMMIT,PAGE_READWRITE);
예약에 의해 주소 공간이 임의의 번지에 먼저 할당되고 확정에 의해 주소 공간이 가상 메모리에 맵된다. 예약만 하고 확정은 하지 않은 상태는 주소 공간만 할당되어 있고 물리적인 메모리와 맵되어 있지 않은 상태이기 때문에 실제 메모리 구실을 없다. 따라서 다음과 같은 코드는 Access Violation 예외를 발생시킨다.
ptr=(int*)VirtualAlloc(NULL, sizeof(int)*10, MEM_RESERVE,PAGE_READWRITE);
ptr[0] = 'S';
. 할당 단위와 페이지
VirtualAlloc 함수가 메모리를 할당할 때는 바이트 단위를 사용하지 않는다. 4G 되는 주소 공간을 바이트 단위로 사용하는 것은 너무 비효율적이기 때문에 일정한 단위로 주소 공간을 분할한다. 마치 하드디스크가 바이트 단위로 파일을 기록하지 않고 섹터, 클러스터 단위를 사용하는 것처럼 말이다. 클러스터 단위로 파일을 기록하면 낭비되는 디스크 공간이 생기지만 속도는 훨씬 빨라진다.
가상 주소 공간의 단위는 가지가 있다. 우선 할당의 시작점을 지정하는 할당 단위(Allocation Granualrity) 있고 할당의 크기를 지정하는 페이지(Page) 있다. 이런 단위를 사용하여 메모리를 관리하는 이유는 메모리가 지나치게 조각나는 것을 방지하고 신속하게 메모리를 관리하기 위해서이다.
VirutalAlloc으로 메모리를 할당(예약하거나 확정) 시작점은 반드시 할당 단위의 경계선에 정렬된다. 할당 단위의 배수 위치에서 할당이 시작된다. 대부분의 플랫폼에서 할당 단위는 64K이므로 가상 메모리 공간은 64K 단위로 할당된다고 있다.
ptr=(int*)VirtualAlloc(0xb71234, sizeof(int)*10, MEM_RESERVE,PAGE_READWRITE);
예약 시작점을 0xb71234번지로 강제로 지정하였다. 그러나 운영체제는 정확하게 번지에서 할당을 시작하지 않고 할당 단위의 배수가 되도록 번지를 내림하여 할당한다. 경우 실제 예약되는 번지는 0xb70000번지가 된다. 하위 2바이트를 0으로 만든 번지에서 할당한다고 생각하면 된다.
할당된 영역의 크기는 반드시 페이지 단위의 배수가 된다. 페이지의 크기는 플랫폼에 따라 다른데 인텔을 비롯한 대부분의 시스템에서 페이지 크기는 4K바이트이다. 예를 들어 10K 크기만큼 할당을 요청했다면 실제로 할당되는 영역의 크기는 12K 될것이다. 현재 플랫폼에서 할당 단위와 페이지 크기를 조사하고 싶다면 GetSystemInfo 함수를 사용하면 된다.
. 보호 속성
VirtualAlloc 네번째 인수 flProtect 할당하고자 하는 메모리의 액세스 타입(Access Protection) 지정한다. Win32 가상 메모리의 특징 하나가 메모리 페이지마다 액세스 타입을 설정하여 미연의 실수를 방지할 있다는 점이다.
액세스 권한
설명
PAGE_READONLY
읽기만 가능하다. 메모리에 쓰기를 없다.
PAGE_READWRITE
읽기 쓰기를 가능하다.
PAGE_EXECUTE
실행만 가능하다. 읽기, 쓰기 모두 없다.
PAGE_EXECUTE_READ
실행 읽기만 가능하다.
PAGE_EXECUTE_READWRITE
실행, 읽기, 쓰기를 가능하다.
PAGE_GUARD
보호 페이지로 지정한다. 페이지에 읽기, 쓰기를 시도하면 STATUS_GUARD_PAGE 예외가 발생하며 보호 페이지 상태가 해제 된다. 보호 페이지는 메모리의 끝을 표시하는 용도로 주로 사용된다. NT 이상만 지원한다.
PAGE_NOACCESS
어떤 액세스도 하지 못하게 된다.
PAGE_NOCACHE
캐시를 금지시킨다. 일반적으로 응용 프로그램은 플래그를 사용하지 않는 것이 좋다. 디바이스 드라이버 등의 시스템 소프트웨어에서 플래그를 사용한다.
PAGE_EXECUTE_WRITECOPY
공유된 영역에 쓰기를 사본을 작성한다. NT 이상만 지원
PAGE_GUARD, PAGE_NOCACHE 플래그는 PAGE_NOACCESS 플래그를 제외한 다른 플래그와 함께 사용할 수도 있다.
읽기는 해당 메모리의 데이터를 읽을 있다는 말이며, 실행은 해당 메모리의 코드를 CPU 실행할 있다는 뜻이다. Win32 플랫폼에 독립적인 API이다 보니 미래에 읽기와 실행을 구분하는 CPU 위해 이런 액세스 타입을 미리 준비해 두고 있을 뿐이다.
메모리의 액세스 타입은 실행중에도 다음 함수를 사용하여 변경할 있다.
BOOL VirtualProtect(LPVOID lpAddress, DWORD dwSize, DWORD flNewProtect, PDWORD lpflOldProtect);
 
가상 메모리의 보호 속성은 프로그램 로더가 사용한다. 응용 프로그램을 메모리로 읽어올 실행 파일(PE) 기록되어 있는 섹션 속성에 쓰기가 가능한 메모리 영역과 읽을 수만 있는 메모리 영역이 구분되어 가상 메모리에 배치된다. 일반적으로 코드 영역은 읽기 전용이며 전역 변수 영역은 읽고 있는 영역이다. 하지만 상수는 읽기 전용 영역에 배치되므로 값은 실행중에 변경할 없다.
 
PAGE_GUARD 속성은 가드 페이지를 설정함으로써 효율적인 스택 확장에 사용된다. 스택은 기본적으로 1M 예약되며 페이지 분량인 4K 정도만 확정된 채로 생성된다. 예약된 1M 스택의 최대 크기이며 확정된 페이지는 당장 사용할 있는 영역인데 스택을 많이 사용하면 확정을 점점 늘린다. 이때 스택을 언제 확장할지를 결정하기 위해 운영체제가 사용하는 속성이 PAGE_GUARD이다. 스택이 확장되는 과정은 다음과 같은데 아래쪽이 높은 번지이며 격자 하나가 1페이지이다.


최초 제일 높은 번지의 페이지만 확정되어 있으며 안에서 함수를 호출하고 지역 변수를 생성한다. 그러다가 스택 사용량이 늘어 가드 페이지에 닿으면 예외가 발생하는데 운영체제는 예외가 발생했을 가드 페이지를 추가로 확정하고 바로 다음의 예약 페이지를 다시 가드 페이지로 설정한다. 만약 가드 페이지가 없다면 스택을 액세스 때마다 확정 여부를 점검해야 하므로 무척 비효율 적일 것이다.
이런식으로 스택은 점차적으로 확장되는데 , 마지막 페이지는 확정하지 않음으로써 예약된 1M 영역을 절대로 넘지는 않는다. 만약 스택 크기가 1M 넘어서 오버플로우가 발생했다면 이는 대개의 경우 프로그램의 논리가 잘못된 것이다.
. 메모리 잠금
가상 주소 공간은 논리적으로 존재할 뿐이며 실제로 데이터나 코드가 저장되는 곳은 가상 메모리이다. 여기서 가상 메모리라고 칭하는 것은 RAM 하드 디스크의 페이징 파일을 합쳐 부르는 말이다. 페이징 파일도 RAM보다 느릴 뿐이지 분명히 메모리이다. 운영체제는 RAM 페이징 파일 사이를 끊임없이 교체하면서 돌아간다. 당장 필요한 부분은 RAM으로 읽혀지며 당분간 사용되지 않을 부분은 페이징 파일로 이동된다.
운영체제의 이런 가상 메모리 관리는 응용 프로그램 입장에서 완전히 투명하다. 원하는 데이터가 RAM 있든 페이징 파일에 있든 전혀 구분할 필요가 없다는 뜻이다. 다만 속도가 조금 차이가 뿐이다. 또한 가상 메모리의 통제는 완전히 운영체제의 고유하고 독점적인 권한이므로 응용 프로그램은 이에 대해 별로 간섭할 필요가 없다.
아주 특수한 경우, 주로 속도가 지극히 중요한 프로그램의 경우 특정 데이터가 반드시 RAM에만 있도록 있다. 페이징 파일로 복사하지 못하게 금지할 있는데 이렇게 하면 원하는 데이터를 물리 RAM에서 바로 찾을 있으므로 속도가 빨라진다. 이때는 다음 함수를 사용한다.
BOOL VirtualLock(LPVOID lpAddress, DWORD dwSize);
BOOL VirtualUnlock(LPVOID lpAddress, DWORD dwSize);
VirtualLock 함수는 lpAddress 지정한 번지로부터 dwSize 길이만큼의 메모리 페이지를 잠근다. 이렇게 하면 운영체제는 번지의 데이터를 페이징 파일로 보내지 않고 항상 RAM 남아있도록 한다. 이때 lpAddress번지는 반드시 가상 메모리가 맵되어 있는 확정 상태여야만 한다. 잠금을 때는 VirtualUnlock 함수를 사용한다.
잠겨진 메모리에 대해서는 페이징 파일로 스왑하지 않아 원하는 데이터가 RAM 없는 상태(Page Fault) 발생하지 않는다. 예외적으로 해당 프로세스가 액티브 상태가 아니면 때는 잠긴 페이지라도 페이징 파일로 이동시켜버릴 있다.
기능은 반드시 필요한 부분에만 신중해야 사용해야 한다. 그렇지 않으면 운영체제의 시스템 관리 능력을 저해하여 전반적으로 속도가 느려질 있다.
메모리 잠금을 반드시 사용해야 하는 프로그램은 디바이스 드라이버 정도이다. 응용 프로그램 수준에서는 거의 일이 없으며 함부로 메모리 관리에 개입해서는 안된다. 멀티 태스킹은 운영체제의 지휘 아래 응용 프로그램들의 자발적인 협조에 의해 부드럽게 돌아가는 것인데 특별한 이유 없이 혼자서 자원과 시간을 독점하는 것은 금물이다.
. 대용량 메모리
똑같은 동작을 malloc, realloc으로도 구현 가능하다.
대용량의 가변적인 메모리를 다룰 때는 가상 메모리의 이점을 활용하는 것이 좋다. 가상 메모리는 예약 필요한만큼만 점진적으로 확정해 가며 있으므로 물리적인 메모리를 낭비하지 않으면서도 한번 예약한 번지가 바뀌지도 않아 쓰기 편리하고 속도도 빠르다. , 가상 메모리는 할당 단위가 크므로 필요 메모리량이 때만 사용하는 것이 좋다.
03
. 힙의 정의와 장점
응용 프로그램이 필요로 하는 메모리를 얻는 다른 방법은 힙을 사용하는 것이다. 힙은 가상 메모리 공간상의 예약된  영역이다. 운영체제는 프로세스를 만들 1M 바이트 크기의 디폴트 힙을 같이 생성한다. 힙은 어디까지나 예약된 메모리일 뿐이므로 자체가 물리적인 메모리를 축내지는 않는다. 최초 힙이 만들어질 때는 1M 바이트 크기만큼 예약만 되어 있을 확정되어 있지는 않다. 응용 프로그램이 힙으로부터 메모리를 할당하면 운영체제는 필요한 만큼 힙의 영역을 확정하여 물리적 메모리를 할당하므로 필요한 만큼의 메모리만 사용하는 셈이다.


가상 메모리를 직접 할당하는 것에 비해 힙을 사용하는 방법은 작은 메모리 블록을 할당하는데 훨씬 유리하다.
힙과 가상 메모리에서 정수형 변수 10000개분을 할당하면 힙에 경우 sizeof(int)*10000만큼인 40K 소요되지만 가상 메모리에서 할당할 때는 4K*10000만큼인 40M 소요된다.
Win32에서는 주소 공간이 분리되어 있으므로 모든 힙은 지역적(Local)이다. Win16에서는 전역/지역 구분이 있어 힙으로부터 메모리를 할당하는 함수가 Local*, Global* 종류였지만 Win32에서는 프로세스별로 주소 공간이 격리됨으로 해서 지역 힙만이 존재한다.
. 힙으로부터 할당
프로세스가 처음 만들어질 운영체제는 프로세스의 주소 공간에 1M 힙을 만드는데 힙을 디폴트 (default heap)이라고 한다.
 
디폴트 힙으로부터 메모리를 할당받을 때는 다음 함수를 사용한다.
HANDLE GetProcessHeap(VOID);
LPVOID HeapAlloc(HANDLE hHeap, DWORD dwFlags, DWORD dwBytes);
BOOL HeapFree(HANDLE hHeap, DWORD dwFlags, LPVOID lpMem);
디폴트 힙을 사용할 때는 GetProcessHeap함수로 디폴트 힙의 핸들을 얻은 핸들을 HeapAlloc 등의 함수로 넘겨 할당 대상 힙을 지정한다.
dwFlag 힙을 할당하는 방법을 지정하는 플래그이다.
플래그
설명
HEAP_GENERATE_EXCEPTIONS
에러 발생시 NULL 리턴하지 않고 예외를 발생시킨다. 매번 리턴값을 점검하는 것은 번거로우므로 예외 처리 구문을 쓰는 것이 편리하다.
HEAP_NO_SERIALIZE
할당은 스레드에 안전하게 동기화 되는데 이런 동기화를 하지 않는다.
HEAP_ZERO_MEMORY
할당한 힙을 0으로 초기화한다. 플래그를 지정하지 않으면 쓰레기값이 들어 있을 것이다.
HEAP_NO_SERIALIZE 플래그는 설명이 필요하다.
힙에 대한 액세스는 운영체제의 의해 기본적으로 동기화되며 이상의 스레드가 동시에 힙에서 메모리를 할당하더라도 우연히 같은 번지를 할당하지 않도록 되어 있다. 시스템이 이런 처리를 하지 않는다면 스레드가 같은 번지를 동시에 할당해서 서로 방해할 있으므로 처리는 필요하다.
그래서 힙에 대한 모든 처리(할당, 해제, 조사) 시스템에 의해 동기화된다. , 이런 동기화는 스레드가 힙을 쓰는 동안 나머지 스레드가 힙을 쓰지 못하게 대기시켜야 하므로 여분의 코드가 필요하며 따라서 동기화를 하지 않을 때보다 당연히 속도가 느리다. HEAP_NO_SERIALIZE 플래그는 동기화를 금지하여 빠른 속도를 얻고자 사용한다. 스레드가 오직 하나뿐이라면 굳이 이런 느린 처리를 필요가 없는 것이다. 동기화를 하지 않을 때도 다음 함수로 힙을 잠시 잠글 수는 있다.
BOOL HeapLock(HANDLE hHeap);
BOOL HeapUnlock(HANDLE hHeap);
스레드가 힙에 대한 락을 걸면 힙을 잠시 독점적으로 소유하며 다른 스레드가 건드리지 못하게 한다. HEAP_NO_SERIALIZE 플래그는 할당할 때뿐만 아니라 추가 힙을 생성할 , 해제할 , 힙을 조사할 , 재할당할 각각 사용되는데 가급적이면 플래그는 쓰지 않는 것이 좋다. 그래서 dwFlags 인수는 통상 0으로 지정한다.
다음 함수는 이미 할당된 힙의 크기를 조정하거나 힙의 크기를 구한다.
LPVOID HeapReAlloc(HANDLE hHeap, DWORD dwFlags, LPVOID lpMem, DWORD dwBytes);
DWORD HeapSize(HANDLE hHeap, DWORD dwFlags, LPCVOID lpMem);
 
다음 함수는 힙을 관리하는 함수이다.
SIZE_T HeapCompact(HANDLE hHeap, DWORD dwFlags);
BOOL HeapValidate(HANDLE hHeap, DWORD dwFlags, LPCVOID lpMem);
BOOL HeapWalk(HANDLE hHeap, LPPROCESS_HEAP_ENTRY lpEntry);
힙은 언제든지 할당 해제를 있는 영역이며 할당과 해제를 자주 반복하다 보면 중간에 비는 공간들이 많아질 것이다. HeapCompact 함수는 힙의 공간을 병합하여 여유 공간을 만들고 과다하게 확정된 영역은 확정 해제하여 시스템에 반납한다. 이런 동작을 전문 용어로 컴팩션(Compaction)이라고 하는데 하드 디스크의 조각모음과 유사한 동작을 한다고 생각하면 이해하기 쉽다.
HeapValidate 함수는 lpMem 지정하는 블록이 유효한 블록인지를 검사한다. lpMem NULL이면 전체의 블록을 점검해 보고 이상이 있는지를 조사한다.
HeapWalk 함수는 힙의 모든 블록을 열거한다. 함수를 반복적으로 호출하면 힙의 블록부터 마지막 블록까지 순회하면서 다음 구조체에 블록에대한 정보를 채운다.
typedef struct _PROCESS_HEAP_ENTRY {
PVOID lpData;
DWORD dbData;
BYTE cbOverhead;
BYTE iRegionIndex;
WORD wFlags;
union {
struct {
HANDLE hMem;
DWORD dwReserved[3];
} Block;
struct {
DWORD dwCommittedSize;
DWORD dwUnCommittedSize;
LPVOID lpFirstBlock;
LPVOID lpLastBlock;
} Region;
};
} PROCESS_HEAP_ENTRY;
블록을 순회하면서 블록의 위치와 크기들을 점검해 보면 어디서 이상이 발생했는지 있다.
. 새로운 생성
디폴트 힙의 용량이 부족할 경우 자동으로 늘리므로 디폴트 힙만 사용해도 웬만한 메모리 요구는 충족할 있다. 그런데 특별한 이유로 디폴트 힙이 아닌 별도의 힙을 만들고자 한다면 그렇게 수도 있다. 어떤 경우에 힙을 만들어 사용하는 것이 좋은지 보자.
  1. 동적으로 필요한 메모리가 종류가 있으며 메모리가 질적으로 다를 경우 각각의 고유 힙을 만들 있다. 예를 들어 개의 연결 리스트를 관리할 경우 연결 리스트별로 별도의 힙을 만들어 메모리를 할당하면 리스트끼리의 노드가 분리 할당됨으로써 잠재적인 문제를 방지할 있다. 한쪽 연결 리스트의 버그로 인해 다른 연결 리스트까지 같이 파괴되지 않을 것이다. 또한 리스트의 노드 크기가 다를 빈번하게 할당/해제 되더라도 메모리의 단편화 현상(fragmentation) 최소화할 있다. 왜냐하면 노드가 해제된 자리에 같은 크기의 노드를 할당하면 낭비가 전혀 없기 때문이다.
  2. 스레드별로 사용하는 힙을 따로 만들 경우 동기화를 하지 않아도 상관없다. 특정 스레드가 자신이 사용할 힙을 만들어서 사용하면 힙은 혼자서 사용하므로 다른 스레드의 방해를 받지 않고 안전하게 사용할 있다. 이럴 경우는 생성 함수에 HEAP_NO_SERIALIZE 플래그를 지정하여 동기화를 하지 않도록 하여 속도를 높여도 무관하다.
  3. 힙에 할당된 개별 블록을 한꺼번에 해제할 있다. 힙이 파괴되면 안의 블록들도 자동으로 파괴되므로 파괴 속도가 굉장히 빠르다. 특징은 상당히 유용한데 거대한 이진 트리가 있을 트리의 노드들이 할당한 메모리를 해제하기 위해서는 한번의 순회가 필요하지만 별도의 전용 힙에 트리를 구성해 놓았으면 힙만 파괴함으로써 모든 노드를 한꺼번에 해제할 있다.
외에도 논리적으로 용도가 비슷한 메모리를 각각 분리된 힙에 할당함으로써 운영체제가 페이지를 교체할 있도록 한다. 힙을 만들 있다는 것은 논리적으로 메모리의 구획을 나눔으로써 안전성과 편의성을 높이는 훌륭한 기능이다. 힙을 만들 때는 다음 함수를 사용한다.
HANDLE HeapCreate(DWORD flOptions, DWORD dwInitialSize, DWORD dwMaximumSize);
BOOL HeapDestroy(HANDLE hHeap);
flOptions 새로 생성되는 힙의 속성을 설정하는 플래그로 등기화 금지, 예외 발생 등의 플래그를 지정할 있지만 보통 0으로 지정한다. dwInitialSize 초기에 확정될 힙의 크기를 지정하며 dwMaximumSize 힙의 최대 크기를 지정한다. 운영체제는 HeapCreate 지정한 dwMaximumSize만큼의 주소 공간을 예약하고 dwInitialSize만큼의 메모리를 확정해 둔다. 그리고 힙으로부터 메모리가 점점 할당됨에 따라 필요한 만큼 확정해 나간다. dwMaximumSize 0이면 상한이 없다는 뜻이며 경우 힙은 가상 메모리 크기만큼 커질 있다. HeapCreate 생성된 힙의 핸들을 리턴하며 에러 발생시 NULL 리턴한다. 핸들은 HeapAlloc 등의 함수의 번째 인수로 사용된다.
. Win16 함수
04 메모리 파일
. 정의
윈도우즈는 물리적이니 메모리(RAM) 부족할 경우 하드 디스크의 페이징 파일(Paging File) 메모리 대신 사용한다. 마치 물리적인 메모리의 일부인 것처럼 프로세스의 주소 공간에 맵하여 사용하며 필요할 경우 RAM으로 읽어오므로 응용 프로그램의 입장에서 페이징 파일은 속도가 느릴 RAM 전혀 다를 것이 없다.
운영체제가 하드 디스크의 페이징 파일을 RAM 대용으로 사용하는 것이 가능하다면 일반 파일도 RAM 대용으로 사용하여 주소 공간에 맵할 있을 것이다. 일반 파일도 정보를 저장하고 읽고 있으므로 이론적으로 전혀 문제가 없다. 메모리 파일(Memory Mapped File) 이런 이론에 기반하여 하드 디스크에 존재하는 파일의 내용을 프로세스의 주소 공간에 연결(Map)하는 기법이다.


이처럼 가상 주소 공간에 파일을 맵한 포인터를 사용하면 파일의 내용을 마치 메모리 다루듯이 똑같이 사용할 있다. 파일을 열고 닫고 파일 포인터를 옮기고 버펄르 유지하는 복잡한 처리를 필요없이 마치 메모리에 있는 데이터를 읽고 쓰듯 *ptr=data; 등과 같이 간편하게 파일 조작을 있는 것이다. 파일을 메모리처럼 사용해도 뒷처리는 운영체제가 철저하게 책임진다. 포인터로 파일을 액세스하면 RAM으로 스왑할 것이고 오랫동안 사용하지 않으면 다시 파일에 기록하며 파일 맵핑을 닫을 대기중인 모든 출력이 파일에 완전히 기록된다.
메모리 파일은 편리함뿐만 아니라 아주 여러 가지 용도를 가지고 있다. 운영체제가 실행 파일을 읽어오고 실행하는 내부적인 방법도 바로 메모리 파일이다. 실행 파일을 메모리로 읽어올 필요없이 디스크의 이미지를 곧바로 프로세스의 주소공간에 맵한 바로 실행할 있다. 물론 배경에서 시스템은 실행 파일 이미지에서 당장 필요한 부분을 물리적인 RAM으로 읽어오고 이상 필요하지 않은 부분은 RAM에서 제거하는 복잡한 처리를 것이다.
운영체제가 이런 식으로 파일 맵핑을 통해 실행 파일을 로드하기 때문에 로딩 속도가 대단히 빠르며 가상 메모리를 절약 있다. 실행 파일 이미지의 대부분은 읽기 전용이므로 파일 자체를 주소 공간에 맵해서 사용해도 상관이 없는 것이다. 100M 넘는 파일을 실행해도 순식간에 실행되는 이유가 바로 파일을 곧바로 맵핑하기 때문이다. 이동식 디스크같은 착탈식 미디어는 언제 제거될지 없으므로 가상 메모리로 전부 읽어들인 후에 실행한다.
또한 파일은 Win32에서 프로세스간 메모리를 공유하는 유일하고 합법적인 방법이다. 개의 프로세스가 하나의 메모리 파일을 동시에 액세스할 있기 때문에 메모리 파일을 통해 데이터를 주고 받을 있고 동시에 메모리 영역을 액세스할 수도 있다. 프로세스간 통신에 사용되는 여러 가지 기술들도 내부적으로 모두 메모리 파일을 활용한다.
. 파일 액세스
메모리 파일을 만드는 절차는 그리 간단하지 않다. 더구나 관련 함수들의 옵션이 매우 많기 때문에 옵션들의 의미까지 전부 한꺼번에 파악하기는 어렵다.
다음 예제는 파일 맵핑 오브젝트를 통해 디스크 상의 파일을 읽는 시범으 보이는 예제이다.
LRESULT CALLBACK WndProc(HWND hWnd, UINT iMessage, WPARAM wParam, LPARAM lParam)
{
HDC hdc;
HANDLE hFile, hMap;
TCHAR* ptrInFile;
RECT rt;
switch(iMessage)
{
case WM_LBUTTONDOWN:
hdc = GetDC(hWnd);
// 파일을 연다.
hFile = CreateFile("Naru.txt", GENERIC_READ, 0, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
 
if (hFile == INVALID_HANDLE_VALUE)
{
// 파일이 없음.
}
else
{
// 파일 맵핑 오브젝트를 만든다.
hFMap = CreateFileMapping(hFile, NULL, PAGE_READONLY, 0, 0, NULL);
// 주소 공간에 맵한다.
ptrInFile=(TCHAR*)MapViewOfFile(hFMap, FILE_MAP_READ, 0, 0, 0);
SetRect(&rt, 10, 10, 640, 400);
DrawText(hdc, ptrInFile, GetFileSize(hFile, NULL), &rt, DT_EXPANDTABS);
UnmapViewOfFile(ptrInFile);
CloseHandle(hFMap);
CloseHandle(hFile);
}
ReleaseDC(hWnd, hdc);
return 0;
case WM_RBUTTONDOWN:
// 파일을 연다.
hFile = CreateFile("Alpha.txt" GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
 
if (hFile == INVALID_HANDLE_VALUE)
{
// 파일이 없다.
}
else
{
// 파일 맵핑 오브젝트를 만든다.
hFMap = CREATEFileMapping(hFile, NULL, PAGE_READWRITE, 0, 0, NULL);
 
// 주소 공간에 맵한다.
ptrInFile = (TCHAR*)MapViewOfFile(hFMap, FILE_MAP_WRITE, 0, 0, 0);
strncpy(ptrInFile, "TEST", 4);
ptrInFile[4] = ' ';
UnmapViewOfFile(ptrInFile);
CloseHandle(hFMap);
CloseHandle(hFile);
}
return 0;
case WM_DESTROY:
PostQuitMessage(0);
return 0;
}
return (DefWindowProc(hWnd, iMessage, wParam, lParam));
}
먼저 CreateFile함수를 사용하여 Naru.txt 파일을 읽기 전용으로 열었다. 그리고 핸들을 사용하여 파일 맵핑 오브젝트를 만든다. 파일 맵핑 오브젝트는 디스크 상의 파일을 가상 주소 공간에 맵하는데 필요한 정보를 가진다.
HANDLE CreateFileMapping(HANDLE hFile, LPSECURITY_ATTRIBUTES lpFileMappingAttributes, DWORD flProtect, DWORD dwMaximumSizeHigh, DWORD dwMaximumSizeLow, LPCTSTR lpName);
번째 인수 hFile 대상 파일의 핸들이다. 핸들은 CreateFile 오픈한 핸들이거나 아니면 0xFFFFFFFF(=INVALID_HANDLE_VALUE) 주어 페이징 파일의 일부 영역을 대신 사용할 수도 있다. 페이징 파일은 가상 메모리의 일부분이므로 하드 디스크의 파일이 아닌 가상 메모리에 파일 맵핑이 생성되며 방법은 프로세스의 메모리 공유에 사용된다. 번째 인수는 보안 정보를 가지는 구조체이되 대부분 NULL 준다. flProtect 일종의 액세스 타입을 지정하는데 다음 값을 지정한다.
설명
PAGE_READONLY
읽기 전용의 파일 맵핑 오브젝트를 만든다. 이렇게 만들어진 메모리 파일에 쓰기를 해서는 안된다. 액세스 지정을 사용할 hFile 반드시 GENERIC_READ 열려 있어야 한다.
PAGE_READWRITE
읽고 있는 파일 맵핑 오브젝트를 만든다. hFile GENERIC_READ | GENERIC_WRITE 열려 있어야 한다.
PAGE_WRITECOPY
읽고 있는 파일 맵핑 오브젝트를 만들되 쓰기 즉시 복사(Write On Copy)기법을 사용한다. 쓰기를 수행하는 시점에서 별도의 복사본이 생성된다. 95/98 지원 안함
외에 flProtect 지정할 있는 SEC_COMMIT, SEC_IMAGE, SEC_NOCACHE, SEC_RESERVE등의 플래그들이 있는데 자세한 사항은 레퍼런스를 참고하기 바란다. 예제에서는 파일을 읽기만 것이므로 PAGE_READONLY 플래그를 주었다. hFile 디스크상의 파일일 경우 액세스 타입은 hFile 액세스 타입과 호환되야 한다. 파일을 읽기 모드로만 열고 파일 맵핑은 읽기 쓰기 모두 가능하도록 수는 없다.
번째 인수와 다섯 번째 인수는 생성될 파일 맵핑 오브젝트의 최대 크기를 지정하는 64비트의 정수이다.
마지막 인수 lpName 파일 맵핑 오브젝트의 이름이며 문자열 형태로 주므로 역슬레시 문자를 제외한 어떤 문자든 사용 가능하다. 필요하지 않을 경우 NULL 주면 된다. 인수는 복수 개의 프로세스가 하나의 파일 맵핑 오브젝트를 공동으로 사용하고자 사용한다. 서로 다른 프로세스에서 같은 파일 맵핑 오브젝트를 얻기 위해서 이름을 약속할 사용되는데 이런 예는 앞에서도 사용자 정의 메시지나 클립보드 포맷을 등록할 적이 있다. 동기화 객체의 경우도 프로세스간에 공유할 필요가 있는 것들은 구분 가능한 이름을 지정해야 한다.
CreateFileMapping 함수는 인수로 주어진 정보를 참고하여 파일 맵핑 오브젝트를 만들며 핸들을 리턴한다. 에러가 발생하면 NULL 리턴한다. 파일 맵핑 오브젝트를 만든 후에는 오브젝트를 프로세스의 주소 공간에 맵해야 한다. 응용 프로그램에서는 파일 맵핑 오브젝트를 곧바로 사용하는 것이 아니라 주소 공간에 맵한 주소 공간을 사용한다. 주소 공간에 맵된 파일의 일부분을 파일 (View)라고 하는데 이름은 메모리의 특정 번지를 통해 파일의 내용을 엿볼 있다는 의미로 붙여졌다. 파일 맵핑 오브젝트를 주소 공간에 맵할 때는 다음 함수를 사용한다.
LPVOID MapViewOfFile(HANDLE hFileMappingObject, DWORD dwDesiredAccess, DWORD dwFileOffsetHigh, DWORD dwFileOffsetLow, DWORD dwNumberOfBytesToMap);
함수 이름 그대로 파일의(OfFile) 뷰를(View) 주소 공간에 (Map)한다. 첫번째 인수는 주소 공간에 맵하려는 파일 맵핑 오브젝트의 핸들이며 이는 CreateFileMapping 함수에 의해 생성된 핸들이거나 아니면 OpenFileMapping 함수에 의해 오픈한 핸들이다. 두번째 인수는 액세스 지정이다. 파일 맵핑 오브젝트를 만들 때도 지정했지만 뷰를 만들 뷰에 대해서도 액세스 지정을 다르게 있다. 함수의 dwDesiredAccess 인수에서 지정한 액세스 지정은 CreateFileMapping 함수의 flProtect 인수와 적당히 호환되어야 한다. 예를 들어 여기서 FILE_MAP_WRITE 값을 주려면 맵핑 오브젝트를 만들 때도 쓰기가 가능하도록 했어야 한다는 상식적인 얘기다. dwDesiredAccess 다음 값을 가진다.
플래그
설명
FILE_MAP_WRITE
읽고 있다.
FILE_MAP_READ
읽을 있다.
FILE_MAP_ALL_ACCESS
읽을 수도 있고 수도 있다.
FILE_MAP_COPY
읽고 있다. 쓰기 시도가 발생하면 데이터의 복사본을 만든 쓴다.
세번째 인수와 네번째 인수는 맵핑을 시작할 오프셋 위치를 나타내는 64비트 정수를 지정한다. 값이 0이면 파일의 선두부터 맵핑되겠지만 0 아니면 파일의 중간부터 맵핑할 수도 있다. 오프셋은 반드시 시스템의 할당 단위(보통64k) 배수여야만 한다. 마지막 인수는 맵핑할 뷰의 크기를 지정한다. 값이 0이면 파일 전체가 맵되지만 일부만 맵하고자 하면 크기를 지정하면 된다.
MapViewOfFile함수는 인수가 지정하는대로 맵핑 오브젝트의 일부 또는 전체를 프로세스의 주소 공간에 맵한 시작 번지를 리턴한다. 포인터 값을 받아 마치 메모리를 읽고 쓰듯이 파일에 읽고 쓰기를 한다.
이렇게 연결된 파일을 사용하고 후에는 다음 함수로 뷰를 닫는다. 뷰의 시작 번지를 인수로 넘기기만 하면 된다.
BOOL UnmapViewOfFile(LPCVOID lpBaseAddress);
그리고 맵핑 오브젝트와 파일 자체는 CloseHandle 함수로 제거한다.
. 파일 관리
파일 맵핑이 유용하게 사용될 때는 파일을 액세스할 때이다. 1기가를 넘는 파일은 할당 자체가 불가능 수도 있고 설사 가능하다 하더라도 시스템에 부담이 된다.
이럴 때는 보통 약간의 메모리만 할당하여 파일의 일부분만 읽어서 사용하고 다른 부분이 필요하면 파일 포인터를 옮겨 가며 원하는 부분을 다시 액세스하는 방법을 사용한다. 메모리에 조그만 창을 만들고 창을 통해 파일의 필요한 부분만 가져오는 식이다.


예를 들어 500M 되는 문서 파일을 화면에 출력해야 한다고 파일 전체를 한꺼번에 화면에 출력할 필요는 없으므로 64K 정도의 버퍼를 마련하고 페이지 분량 정도만 읽어서 출력하면 사용자가 문서의 앞부분으 있다.
페이지를 출력해 놓은 상태에서 사용자가 PgDn이나 커서 이동키로 문서의 아래쪽으로 이동하면 이동한 만큼 파일 포인터를 옮겨 버퍼로 다시 읽어 들이면 된다. 파일이 아무리 커도 화면에 당장 표시할 있는 분량은 얼마 되지 않기 때문에 작은 버퍼로도 전체 파일의 부분으 ㄹ번갈아가며 출력할 있는 것이다. 물론 기술이 통하기 위해서는 사용자의 요구를 정확하게 파악하여 필요한 부분을 제때 읽어 와야 하는데 이는 어렵다기 보다 무척 성가신 일이다.
파일을 액세스 하는 이런 전통적인 방법은 과거 자원이 부족할 흔히 사용되었던 방법이다. 파일 맵핑을 사용하면 시스템이 필요한 부분을 선정하여 메모리로 올리는 작업을 대신하므로 훨씬 간단하게 구현할 있다.

HANDLE hFile;
HANDLE hFMap;
TCHAR* base;
int line;
#define LINE 6000000
#define PAGE 50
 
LRESULT CALLBACK WndProc(HWND hWnd, UINT iMessage, WPARAM wParam, LPARAM lParam)
{
HDC hdc;
PAINTSTRUCT ps;
TCHAR buf[256];
DWORD dwWritten;
int i;
 
switch(iMessage)
{
case WM_CREATE:
hWndMain = hWnd;
hFile = CreateFile("c:\\BigFile.dat", GENERIC_WRITE, 0, NULL, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, NULL);
 
if(hFile != INVALID_HANDLE_VALUE)
{
for(i=0; i<LINE; i++)
{
wsprintf(buf, "Read Very BigFile Using Memory Mapped File ", " - %06d Lines\r\n", i);
WriteFile(hFile, buf, lstrlen(buf), &dwWritten, NULL);
}
CloseHandle(hFile);
}
hFile=CreateFile("c:\\BigFile.dat", GENERIC_READ,0,NULL,OPEN_EXISTING, FILEATTRIBUTE_NORMAL,NULL);
hFMap=CreatefileMapping(hFile,NULL,PAGE_READONLY,0,0,NULL);
base=(TCHAR*)MapViewOfFile(hFMap,FILE_MAP_READ,0,0,0);
return 0;
case WM_KEYDOWN:
swtich(wParam)
{
case VK_DOWN:
line=min(line+1,LINE-1);
break;
case VK_UP:
line=max(line-1,0);
break;
case VK_NEXT:
line=min(line+PAGE, LINE-1);
break;
case VK_PRIOR:
line=max(line-PAGE,0);
break;
}
invalidateRect(hWnd,NULL,FALSE);
return 0;
case WM_PAINT:
hdc=BeginPaint(hWnd,&ps);
for(i=0; i<PAGE; i++)
{
if(line+i < LINE)
{
TextOut(hdc, 10, i*16, base+(line+i)*59, 57);
}
}
EndPaint(hWnd, &ps);
return 0;
case WM_DESTROY:
UnmapViewOfFile(base)
CloseHandle(hFMap);
CloseHandle(hFile);
PostQuitMessage(0);
return 0;
}
return (DefWindowProc(hWnd, iMessage, wParam, lParam));
}

과정에서 시스템은 맵핑된 파일의 일부분을 RAM으로 스왑하고 읽은 부분은 버리는 처리를 자동으로 수행하는데 이런 처리는 응용 프로그램 입장에서는 투명하다. 응용 프로그램은 마치 거대한 파일이 자신의 주소 공간에 존재하는 것처럼 번지만 읽으면 된다. 이런 식으로 주소 공간이 허락하는 한까지의 파일을 얼마든지 효율적으로 관리할 있다. 종료하기 전에 뷰를 해제하고 파일 맵핑과 파일을 닫는다.
이런 파일을 순차적으로 읽어야 하는 좋은 예는 동영상 재생기이다. 이런 파일을 메모리로 모두 읽어 놓고 재생할 수는 없는 노릇이다. 동영상 파일 자체를 파일 맵핑으로 만들어 주소 공간에 연결한 처음부터 순서대로 포인터를 증가시키면서 재생하기만 하면 나머지는 시스템이 알아서 관리할 것이다.
같은 방식으로 편집도 가능하지만 굉장히 비효율적이다. 왜냐하면 파일의 부분을 삽입, 삭제하면 부분을 밀거나 당겨야 하는데 이는 엄청난 메모리 이동을 초래하기 때문이다. 그래서 파일을 편집할 때는 파일 자체를 편집하는 것보다 파일의 어떤 부분이 변경되었는지만을 별도로 관리하며 파일을 저장할 변경된 부분을 일괄 적용한다.
. 메모리 공유
파일 맵핑의 또다른 활용예로 프로세스간 메모리 공유 기법이 있다. 하나의 파일 맵핑 오브젝트에 대해 뷰는 여러 개를 있다. 파일의 각각 다른 부분을 동시에 액세스하려면 이상의 뷰를 열어 놓고 사용해도 상관없다. 이렇게 되면 아무리 파일이라도 완전한 랜덤 액세스가 가능해진다.
프로세스간에 메모리를 공유하고자 때도 파일 맵핑은 아주 훌륭한 해결책이 된다. CreateFileMapping 첫번째 인수에 0xFFFFFFFF 주어 페이징 파일에 파일 맵핑 오브젝트를 만들고 오브젝트의 이름을 주면 다른 프로세스에서 같은 이름으로 OpenFileMapping 사용하여 파일 맵핑 오브젝트를 액세스할 있다.
다음 예제는 파일 맵핑으로 프로세스가 메모리를 공유하는 방법을 보여준다.

#define WM_SYNCSHAREMEMORY WM_USER+1
#define MAXSHAREMEMORY 1024
#define ID_EDIT 100
HWND hEdit;
HANDLE hFMap;
TCHAR* ptrInFile;
LRESULT CALLBACK WndProc(HWND hWnd, UINT iMessage, WPARAM wParam, LPARAM lParam)
{
HDC hdc;
PAINTSTRUCT ps;
HWND hTarget;
TCHAR* msg="메모리 맵파일을 사용하여 프로세스가 메모리 공유하는 예제";
 
switch(iMessage)
{
case WM_CREATE:
hEdit=CreateWindow("edit",NULL,WS_CHILD|WS_VISIBLE|WS_BORDER|WS_VSCROLL|ES_MULTILINE,10,10,500,200,hWnd,(HMENU)ID_EDIT,h_hInst,NULL);
SendMessage(hEdit,EM_LIMITTEXT,MAXSHAREMEMORY,0);
hFMap=CreateFileMapping(INVALID_HANDLE_VALUE,NULL,PAGE_READWRITE,0,MAXSHAREMEMORY,"MEMSHAREMAPPING");
ptrInFile=(TCHAR*)MapViewOfFile(hFMap,FILE_MAP_ALL_ACCESS,0,0,MAXSHAREMEMORY);
return 0;
 
case WM_COMMAND:
switch(HIWORD(wParam))
{
case EN_CHANGE:
GetWindowText(hEidt,ptrInFile,MAXSHAREMEMORY);
hTarget=FindWindow(NULL,"MemShare2");
if (hTarget)
SendMessage(hTarget,WM_SYNCSHAREMEMORY,0,0);
break;
}
return 0;
 
case WM_SYNCSHAREMEMORY:
SetWindowText(hEdit, ptrInFile);
return 0;
 
case WM_PAINT:
hdc=BeginPaint(hWnd, &ps);
TextOut(hdc, 10, 220, msg, lstrlen(msg));
EndPaint(hWnd, &ps);
return 0;
 
case WM_DESTROY:
UnmapViewOfFile(ptrInFile);
CloseHandle(hFMap);
PostQuitMessage(0);
return 0;
}
return(DefWindowsProc(hWnd,iMessage,wParam,lParam));
}

WM_CREATE에서 hFMap파일 맵핑을 만들되 파일 핸들을 주지 않으므로써 하드 디스크의 파일이 아닌 페이징 파일에 파일 맵핑을 만듬.
"MEMSHAREMAPPING"이라는 이름을 주었는데 복수 개의 프로세스가 오브젝트를 공유하기 위해서는 약속된 이름이 있어야한다. 커널 객체끼리는 이름 영역을 공유하므로 가급적이면 이름을 지정하는 것이 좋다.
파일 맵핑을 만든 오브젝트의 전체 영역에 대해 뷰를 생성하고 포인터를 ptrInFile변수에 대입. 영역에 데이터를 기록하면 hFMap 공유하는 다른 프로세스도 데이터를 읽을 있다.


가상 메모리의 지점이 파일 맵핑 오브젝트와 연결되어 있고 오브젝트를 통해 프로세스가 같은 메모리 영역을 들여다 보고 있는 것이다. 프로세스의 주소 공간이 분리되어 있으므로 각각이 받은 포인터는 다르겠지만 포인터가 가리키는 실체는 동일한 가상 메모리이다. 가상 주소 공간은 포인터가 같아도 실제 가리키는 곳이 다를 수도 있고 포인터가 달라고 실제 가리키는 곳이 같을 수도 있다.
. 메모리 파일 활용
Win32 메모리 구조는 프로세스끼리 분리된 주소 공간을 가지고 있어서 메시지를 통해 포인터를 전달하는 방법으로는 자료를 공유할 없다. 포인터가 지시하는 번지는 프로세스의 주소 공간내에 있으므로 다른 프로세스가 포인터를 받는다고 해서 번지의 내용을 액세스 없다.
주소 공간의 분리는 운영체제의 안정성을 높이며 잘못된 프로세스로부터 운영체제를 방어하는 근본적인 수단이다. 그러마 모든 경우에 있어서 포인터를 통한 자료 교환이 불가능한 것은 아니다.
#include <commctrl.h>
LRESULT CALLBACK WndProc(HWND hWnd, UINT iMessage, WPARAM wParam, LPARAM lParam)
{
HDC hdc;
PAINTSTRUCT ps;
HWND hOther, hChild;
LVITEM LI;
TCHAR* msg="왼쪽:리스트 박스에 문자열 추가, 오른쪽:리스트 컨트롤에 항목 추가";
 
switch(iMessage)
{
case WM_LBUTTONDOWN:
hOther=FindWindow("MyListBox",NULL);
SendMessage(hOther,WM_SETTEXT,0,(LPARAM)"남의 타이틀바");
if(hOther)
{
hChild=GetWindow(hOther,GW_CHILD);
SendMessage(hChild,LB_ADDSTRING,0,(LPARAM)"장난꾸러기");
}
return 0;
case WM_RBUTTONDOWN:
hOther=FindWindow("ListCtrl",NULL);
if(hOther)
{
hChild=GetWindow(hOther,GW_CHILD);
LI.mask=LVIF_TEXT;
LI.iSubItem=0;
LI.iItem=0;
LI.pszText="욕숨꾸러기";
SendMessage(hChild,LVM_INSERTITEM,0,(LPARAM)&LI);
}
return 0;
case WM_PAINT:
hdc=BeginPaing(hWnd,&ps);
TextOut(hdc, 10, 10, msg, lstrlen(msg));
EndPaint(hWnd, &ps);
return 0;
case WM_DESTROY:
PostQuitMessage(0);
return 0;
}
return(DefWindowsProc(hWnd,iMessage,wParam,lParam));
}
왼쪽 마우스 클릭하면 MyListBox라는 윈도우를 찾아 텍스트를 변경.
WM_SETTEXT메시지를 hOther 보냈는데 호출문은 SetWindowText함수와 동격이다. 이때 lParam으로 "남의 타이블바"라는 문자열 포인터를 보냈으며 hOther 문자열을 읽어 자신의 텍스트를 변경한다.
결과는 주소 공간이 분리되어 있어 포인터를 통해 문자열을 전달할 없다는 얘기와는 완전히 반대되는 현상이다. WM_SETTEXT lParam으로 전달된 문자열 상수는 SetOther프로세스의 주소 공간에 존재하므로 ListBox 프로세스가 메시지를 받았을 lParam으로 전달된 포인터로부터 문자열을 읽을 없어야 한다. 그런데 동작한다.
코드가 동작하는 이유는 시스템이 내부적으로 메모리 파일을 생성하여 보내는 쪽의 문자열을 공유 영역에 복사하고 받는 쪽에서 영역을 읽을 있도록 조치하기 때문이다. 임시적인 메모리 파일은 프로세스간의 통신을 위해 생성되며 SendMessage 리턴될 파괴된다.
운영체제는 필요하다고 판단될 경우 임시적인 메모리 파일을 생성하여 프로세스간의 자료 교환을 돕지만 그렇지 않을 경우는 아무 조치도 취하지 않는다. 그렇다면 WM_SETTEXT LB_ADDSTRING 대해서는 비싼 비용을 치르고라도 이런 처리를 하는데 반해 공통 컨트롤에 대해서는 아무런 처리도 하지 않는 것일까? 이유는 바로 호환성이다.
오버랩드 윈도우나 리스트 박스같은 표준 컨트롤은 16비트 윈도우 시절부터 존재했었고 16비트의 메모리는 분리되어 있지 않았었다. 그래서 메시지를 통한 포인터 교환이 가능했었고 도한 유용한 기법의 하나로 활용되었다. 이런 기법을 활용한 16비트 프로그램이 32비트에서도 여전히 실행 가능해야 하는데 32비트의 분리된 메모리 구조에서는 사실상 포인터를 통한 자료 교환이 불가능하다. 그래서 운영체제는 호환성을 확보하기 위해 코드가 동작하도록 보장해야 했으며 방편으로 메모리 파일을 활용하는 것이다. 물론 외부에서 때는 이런 처리가 투명해서 전혀 눈치채지 못한다.
반면 공통 컨트롤은 32비트 윈도우즈에서 처음 만들어졌고 메시지를 통한 프로세스간의 자료 교환이 처음부터 불가능했다. 그러므로 컨트롤에 대해서는 시스템이 지나친 서비스를 필요가 없으며 동작하지 않는다 하여 문제가 되지도 않는 것이다. 그래서 표준 컨트롤은 16비트 방식대로 프로그래밍해도 동작하지만 다른 프로세스의 공통 컨트롤을 프로그래밍하려면 훅킹이라는 기법이 동원되어야 한다.

반응형

'책정리 > 윈도우 API 정복2' 카테고리의 다른 글

42장 동기화  (0) 2019.05.07
41장 멀티 스레드  (0) 2019.04.16
40장 프로세스  (0) 2019.04.04
39장 메모리  (0) 2019.04.03
34장 시스템 정보  (0) 2019.03.19