SungJin Kang

SungJin Kang

hour30000@gmail.com

© 2024

Dark Mode

Windows 메모리 구조, 동작 원리

이 글은 “제프리 리처의 Windows Via C/C++” 책 중 Window 플랫폼의 메모리 파트를 정리한 글이다.
구체적인 Window API는 언급하지 않고 Window의 메모리 동작 원리를 중점으로 설명할 것이다.


모든 프로그램 ( 프로세스 )는 자신만의 가상 주소 공간을 가지고 있다.
64비트 프로세스는 64비트 포인트 즉 0xFFFFFFFF FFFFFFFF 16엑사바이트라는 어마 어마한 가상 주소 공간을 가지고 있다.
강조하지만 가상(!) 주소 공간이다. 실제 DRAM, 디스크의 물리적 메모리 저장 용량과는 무관하다.

프로세스의 가상 주소 공간은 파티션이라는 분리 공간으로 분리가 되어 있다.
가상 주소 공간에는 크게 4가지 주소 파티션으로 나뉜다.
그리고 이 파티션은 최소한 64KB 이상의 사이즈를 가지고 있어야하며 시작 주소가 64KB에 Align되어 있어야한다. 이 64KB는 OS에서 강제하는 예약 영역의 최소 사이즈이다.

첫째는 NULL 포인터 할당을 대비한 주소 공간이다. 0x00000000부터 0x0000FFFF까지 차지하며 이 영역에 대한 읽기/쓰기 동작은 접근 위반이다. 포인터에 NULL 포인터를 넣고 해당 포인터가 가지고 있는 주소 공간에 대한 쓰기 동작을 수행하였을 때를 대비하는 공간이다. 예를들면 malloc 사용시 할당할 수 있는 메모리 공간이 없으면 NULL을 반환하는데 이 NULL을 확인하지 않고 반환받은 주소에 쓰기/읽기 동작을 수행할 수 있으니 그럴시 OS는 메모리 접근 위반을 발생시키고 프로그램을 종료시킨다. WIN API 함수들은 이 영역에 대한 예약 동작조차도 허용하지 않는다.

두번째로는 유저 모드 파티션이다. 우리가 흔히 사용하는 유저 모드 프로그램이 사용하는 가상 주소 공간이다. x64 아키텍처 기준으로 0x00000000 00010000 ~ 0x000007FF FFFEFFFF까지의 주소 공간을 가진다. 각각의 프로세스가 자신만의 파티션을 가지기 때문에 다른 프로세스의 데이터 영역에 영향을 줄 가능성은 거의 없다. 윈도우에서 모든 .exe, DLL 모듈은 이 파티션에 로드된다. 동일한 DLL을 서로 다른 프로세스가 로드하는 경우 각각의 프로세스가 가지는 이 파티션 내의 서로 다른 주소에 로드될 수 있다. 또한 OS는 모든 메모리 맵 파일에 대해서도 이 파티션에 매핑을 수행한다.

그 다음으로는 64KB 접근 근지 파티션이다. 이 파티션은 프로그램이 유저 모드에서 작업을 하다 실수로 커널 모드 파티션에 접근할 수 있으니 이를 방지하기 위한 파티션이다.

마지막으로 커널 모드 파티션이다. 커널 모드 파티션이 거의 16,777,208TB를 차지한다. 이 커널 모드 파티션은 스레드 스케줄링, 메모리 관리, 파일 시스템 지원, 네트워크 지원 등을 지원하는 코드와 모든 디바이스 드라이버들이 이 파티션에 로드된다. 이 파티션 영여겡 존재하는 내용은 모든 프로세스에 의해 공유된다. 이 파티션이 유저 모드 파티션 위쪽에 위치하기는 하지만 이 파티션 내의 코드와 데이터는 유저 모드 프로그램으로부터 완벽하게 보호된다. 만약 유저 애플리케이션이 이 파티션에 접근하는 경우 접근 위반이 발생하게 된다.


프로세스가 생성되고 가상 주소 공간이 주어지면 대부분의 주소 공간은 Free이거나 할당되지 않은 상태가 된다. 이러한 주소 공간을 사용하기 위해서는 우선 주소 공간에 대한 예약을 수행해야한다. 예약을 할 때는 항상 예약할 영역의 시작 주소가 할당 단위 ( 64KB )에 Align되어 있어야한다. 그리고 예약할 영역의 사이즈는 CPU의 페이지 크기의 배수여야한다.

예약한 영역과 영역내의 페이지는 엄연히 다른 개념이다. 예약한 영역은 OS에서 강제하는 주소 공간 예약 단위이고, 예약한 영역내의 페이지는 CPU가 MMU를 통해 가상 주소 공간을 변환하는데 사용하는 CPU 관점에서의 개념이다.

주소 공간을 예약한 후 사용을 하기 위해서는 반드시 커밋을 수행하여 해당 예약 공간에 매핑된 물리적 주소 공간을 할당해야한다. 예약만한 상태에서는 실제로 해당 가상 주소 영역이 물리적 주소 공간을 가지지 않은 상태이다. 그래서 에약해둔 프로세스 가상 주소 공간에 대해 물리적 공간을 페이지 단위로 커밋을 수행하면 그 순간 물리적 디스크에 페이징 파일이 만들어지고 이 페이징 파일과 가상 주소 공간이 매핑이 된다. 예약한 공간 내의 일부 페이지만 커밋을 수행할 수도 있다.

요즘 운영체제는 메모리 DRAM뿐만아니라 디스크 ( HDD, SDD ) 공간을 메모리 처럼 활용할 수 있다. 디스크 상에 존재하는 페이징 파일을 통해 가상 주소 공간의 데이터들을 디스크에 저장해둘 수 있다. 이를 가상 메모리라고도 한다. 이를 위해서 CPU는 가상 주소 공간을 읽을 때 그 가상 주소 공간에 매핑된 물리적 주소 공간 데이터가 메모리 ( DRAM )에 있는지 디스크에 있는지 판단할 수 있어야한다. 프로그램 관점에서 보면 이 가상 메모리 개념은 실제 DRAM의 용량보다 활용할 수 있는 메모리 크기가 더 커진 것과 같은 효과를 가져온다. 디스크에 페이징 파일에 프로그램이 필요로하는 페이지 데이터가 있을 때는 기존 메모리 ( DRAM )에 있는 페이지를 디스크로 축출하고 필요했던 페이징 파일을 메모리 ( DRAM )으로 읽어온다. 만약 축출하는 페이지에 쓰기 동작이 수행되었던 dirty 상태인 경우 축출 전 매핑되어 있는 디스크상의 페이징 파일에 복사를 수행한 후 축출한다. ( 페이지가 dirty한 상태이더라도 “리셋”을 수행하게되면 축출히 페이징파일로의 복사를 막을 수 있다. )

다시 본론으로 돌아오면 예약한 주소 공간에 대해 커밋을 수행하는 순간 디스크에는 해당 영역에 대한 페이징 파일이 만들어진다.

프로그램 ( .exe ) 혹은 DLL 모듈을 프로세스의 가상 주소로 가져오는 경우는 어떨까?
해당 프로그램, .DLL 모듈에 대해서도 페이징 파일을 디스크에 따로 일일이 만들어서 데이터를 가져올까??
그렇지 않다. 프로그램, DLL 모듈을 메모리에 가져올 때는 새로 페이징 파일을 만들고 디스크에 있는 프로그램, DLL 모듈 코드, 데이터를 이 새로 만든 페이징 파일에 복사를 해서 해당 데이터에 접근을 하는게 아니라 그냥 디스크에 있는 프로그램, DLL 모듈의 코드, 데이터에다 바로 매핑을 해버린다. 디스크에 있는 프로그램의 데이터를 페이징 파일로 복사를 하는 것이 아니다. 그냥 있는 그대로 바로 매핑해서 접근하는 것이다. 이렇게 디스크 상에 존재하는 프로그램 파일이 가상 주소 공간에 매핑되는 경우 이러한 파일들을 메모리 맵 파일이라고 부른다. 시스템은 .exe 파일 실행시 혹은 .dll 로드시 자동으로 가상 주소 공간을 예약하고 해당 파일에 매핑을 한다. ( 따로 커밋을 해서 페이징 파일을 만들지 않는다. )


물리적 저장소의 각각의 페이지들은 서로 다른 보호 특성을 가진다.
구체적인 보호 특성은 이 글을 읽어보기 바란다.

카피 온 라이트 접근.
윈도우는 서로 다른 프로세스가 같은 물리적 저장소를 공유하는 기능을 제공한다. 서로 다른 프로그램이 같은 프로그램 ( .exe ), DLL 모듈 혹은 페이지 ( 페이징 파일 )을 공유할 수 있다는 것이다.
생각을 해봐라. 어떤 하나의 .dll 모듈을 컴퓨터 상의 수 많은 프로그램들이 사용을 하는데 매 프로그램마다 굳이 같은 dll 모듈을 따로 복사해서 각자가 하나씩 가지고 있을 필요가 있겠는가? 그냥 하나의 .dll 모듈을 공유해서 쓰면 된다.
다만 공유되는 블록은 읽기 전용 데이터 혹은 실행 전용 코드이어야 한다.
그러나 만약 어떤 프로세스가 공유되는 블록에 대해 쓰기 동작을 수행하면 어떻게 될까? 그럼 다른 프로세스들은 이 프로세스가 쓴 데이터를 읽게되고 엉망이 될 것이다.
이를 방지하기 위해 카피 온 라이트가 등장한다. 카피 온 라이트는 읽을 때는 원본 블록에서 읽기를 수행하다가 쓰기를 수행하려할시 원본 블록을 복사해서 자신만의 복사본을 만들고 그 복사본에 쓰기를 수행하는 개념이다.
프로그램은 .exe, dll 모듈이 프로세스의 가상 주소 공간에 매핑을 할 때 해당 .exe, dll 모듈 내의 쓰기 가능 페이지 ( PAGE_READWRITE )가 몇개인지 미리 확인을 하여 해당 페이지 개수만큼을 미리 커밋해둔다. ( 카피 온 라이트를 대비해 미리 물리적 주소 공간을 할당해두는 것이다. ) 그리고 향후 기존 .exe, dll 모듈의 페이지에 쓰기 동작이 수행되면 미리 커밋해둔 페이지로 데이터를 복사한 후 프로세스의 페이지 테이블을 교체하여 쓰기 동작을 수행한다.

추가적으로 몇가지 특수 접근 보호 특성 플래그를 소개하겠다.

PAGE_NOCACHE : 커밋된 페이지에 대해 캐싱을 수행하지 않는 것이다. 흔히 렌더링을 할 때 읽기 동작을 수행할 가능성이 있는 프레임 버퍼 같은 데이터는 GPU가 메모리에서 데이터를 읽어가야하기 때문에 캐싱을 수행하면 안된는데 이 경우 PAGE_NOCACHE 플래그를 사용한다. 메모리 맵 IO에 사용된다고 생각하면 된다.

PAGE_WRITECOMBINE : 쓰기 동작을 수행할 때 캐시 라인만큼의 쓰기 동작을 모아서 버스트 모드에서 한번에 쓰기 위해 사용되는 플래그이다. 자세한건 이 글을 읽기바란다.

PAGE_GUARD : 페이지에 쓰기 동작이 수행되었을 떄 프로그램에 예외를 통해 알려주는데 사용하는 플래그이다.


위에서 배운 메모리의 영역들을 다시 요약하면 크게 4가지 타입의 영역이 존재한다.
그리고 메모리 영역과 페이지는 별개의 개념이다.

프리 ( 메모리 영역 ) : 어떠한 공간에도 매핑되지 않은 영역이다. 커밋뿐만아니라 예약도 되지 않은 영역이다.
프라이비트 : OS의 페이징 파일에 매핑이된 영역이다.
이미지 : 메모리 맵 이미지 파일 ( .exe, DLL 모듈 )에 매핑이 된 영역이다. 그러나 실제로 디스크 상의 이미지 파일과 매핑이 되어 있지 않았을 수도 있는데, 카피 온 라이트 수행시 새로운 페이지 파일에 매핑이 되는 경우에도 이 타입으로 간주되기 때문이다.
맵 : 이전에 메모리 맵 데이터 파일에 매핑이 된 영역이다. 그러나 마찬가지로 카피 온 라이트 수행으로 새로운 페이지 파일에 매핑된 경우에도 이 타입일 수 있다.

같은 영역내에 페이지들이라도 서로 다른 타입의 물리적 저장소에 매핑이 될 수 있다.


CPU는 데이터가 적절하게 정렬된 경우 효과적으로 접근할 수 있다. ( CPU가 데이터를 접근하는 것은 무조건 정렬된 단위로만 접근할 수 있다. )
만약 접근하려는 데이터가 정렬이 되지 않은 경우에는 예외를 유발하거나, 정렬된 단위로 접근을 여러번하여서 비트 Shift를 통해 데이터들을 조합하여 읽어온다.
일반적으로는 CPU 하드웨어가 직접 이 비트 Shift를 수행하여 데이터를 읽어온다.


스레드가 생성되면 해당 스레드가 사용할 스레드 스택 공간이 예약된다. 그리고 그 일부는 커밋된다.
그 후 커밋된 공간을 넘어서 스택이 늘어나면 그때 그때 새로운 페이지를 커밋해서 스택 공간을 늘린다.
프로그램 코드로 보면 스택 공간을 늘리는 것은 단순히 스택 포인터 레지스터에 값을 빼는 행위에 불가하다.
그러니깐 스택 공간을 늘리더라도 ( 스택 포인터 레지스터에 빼기를 수행하더라도 ) 실제로 늘어난 스택 공간에 메모리 접근을 수행하기 전까지는 커밋이 되지 않을 수도 있다는 것이다.


IO 작업을 수행할 때 파일을 읽을 때 버퍼 사이즈는 얼만큼해야한, 어떻게 파일을 열고 닫는게 좋은가 등 IO 통신은 개발자들에게 머리 아픈일다. 그래서 윈도우는 개발자가 이것들을 신경쓰지 않게 해주기 위해 메모리 맵 파일이라는 개념을 도입하였다.
메모리 맵 파일은 프로세스의 가상 주소 공간을 예약하고 물리적 공간을 커밋한다는 점에서 가상 메모리와 비슷하지만, 이 커밋하는 물리적 영역이 새로 만든 페이징 파일이 아니라 디스크 상에 이미 존재하는 파일이라는 점에서 가상 메모리와 차이를 보인다. 메모리 맵 파일을 사용하면 마치 일반적으로 메모리를 접근하는 것 처럼 파일에서 데이터를 읽고, 파일로 데이터를 쓸 수 있다.
당연히 캐싱도 적용되고, 또한 매핑한 페이지에 WRITECOPY 속성 ( 이 경우에는 페이지에 쓰기 동작 수행이 카피 온 라이트를 수행하여 새로운 페이징 파일에 쓰므로 페이지 축출시에 기존의 원본의 디스크 파일에 쓰기 동작을 수행하지 않는다. )이 아니라 READWRITE 속성을 부여하면 파일 쓰기시 곧바로 디스크 상에 파일에 쓰기 동작 ( IO 통신 )을 수행하는 것이 아니라 일단은 메모리에 쓰고 ( 버퍼링, 빠름 ), 페이지 축출시에 디스크 상의 파일에 쓰기를 수행하기 때문에 빠르게 쓰기 동작을 수행할 수 있다.

메모리 맵 파일은 서로 다른 세가지 목적으로 사용된다.
첫째. .exe파일이나 DLL 파일을 읽고 수행하기 위해 메모리 맵 파일을 사용한다. 복사를 한번하는 것이 아니라 원래 있던 파일에 매핑을 하는 것이기 때문에 파일 시작 시간을 절약할 수 있다. ( 다만 나중에 쓰기를 하려면 그때는 복사를 해야되지만….. 카피 온 라이트 )
둘째. 디스크에 있는 데이터에 접근하기 위해 메모리 맵 파일을 사용한다. 메모리 맵 파일을 사용하면 파일에 대한 IO 작업이나 버퍼링등을 OS가 자동적으로 수행해준다.
셋째. 동일한 머신에서 수행 중인 다수의 프로세스간에 데이터를 공유하기 위해 사용된다.


DLL 파일을 로드할 경우 각 DLL 파일은 자신이 선호하는 가상 주소 공간상 로드될 위치를 가지고 있다. 그러나 만약 해당 선호 위치가 이미 다른 DLL에 의해 사용되고 있는 경우 DLL은 다른 위치에 매핑이 되어야하는데 이 경우에는 DLL 파일에 바로 매핑하는 것이 아니라 페이징 파일을 만들어 그곳으로 복사를 한 후 매핑을 한다.


EXE파일, DLL 파일이 메모리 맵 파일로 프로세스의 가상 주소 공간에 로드된 경우 해당 파일들의 데이터는 해당 파일을 사용하는 모든 프로세스가 공유하게된다. 이 말은 동일한 EXE파일, DLL파일이 여러 프로세스에 의해 매핑되는 경우 해당 프로세스들은 각자 따로 페이지 인을 하지 않고 메모리 상에서 한번만 할당을 해서 프로세스들이 공유한다는 것이다.
( 여기서 조금 더 자세히 설명하면 프로세스가 공유하는 파일의 특정 페이지를 읽어 페이지 인이 되면 메모리의 커널 공간의 파일 IO를 위한 페이지 캐시에 복사된다. 이 페이지 캐시는 어느 프로세스나 유저 모드에서도 커널 모드으로의 전환 없이 바로 접근이 가능하다. )
그리고 위에서 배운 것과 같이 쓰기 동작을 수행하려는 경우 ( 그리고 해당 페이지가 WRITECOPY Flag가 붙은 경우 )에는 해당 페이지에 한하여 카피 온 라이트를 수행하여 미리 커밋해둔 페이징 파일에 파일 데이터를 복사한 후 매핑하여 쓰기 동작을 수행한다.

그리고 기본적으로 EXE파일, DLL 파일이 프로세스간 공유되더라도 정적 데이터들은 프로세스간에 공유가 되지 않는다. ( 보안을 위해 ) 다만 때로는 공유가 필요하다면 공유를 할 수 있다. 여기서 섹션이라는 개념이 존재하는데 아마 당신은 프로그램을 컴파일하면 프로그램 파일내에 크게 .text ( 코드 ), .bss ( 초기화되지 않은 데이터 ), .data ( 초기화된 데이터 ) 섹션으로 나누어진다는 것을 알고 있을 것이다. 이러한 각 섹션에는 특성을 부여할 수 있다. READ ( 읽혀질 수 있다 ), WRITE ( 쓰여질 수 있다 ), EXECUTE ( 수행될 수 있다 ), SHARED ( 여러 프로세스 사이에서 공유될 수 있다. 카피 온 라이트가 적용되지 않는다 )로 크게 나뉘는데 SHARED 특성을 부여하면 .EXE, DLL 파일 내의 해당 섹션은 프로세스간 공유가 된다. 그래서 지시어를 통해 프로세스간 공유하고자 하는 데이터를 특별한 섹션을 생성해 거기 넣고, 해당 섹션에 SHARED 특성을 부여하면 프로세스간 공유를 할 수 있다.

이러한 메모리 맵 파일의 특성 덕분에 만약 엄청나게 큰 파일을 읽을 때도 해당 파일 전체를 한번에 다 메모리로 복사하는 것이 아니라 페이지에 접근할 때마다 해당 페이지만 페이지 인 하면 되기 때문에 처음 셋팅을 하는 시간을 단축할 수 있다. ( 파일 전체 접근하는 경우 결국에는 전부 페이지 인해야하니 결국 처음에 엄청 느리느냐, 느린 동작을 여러 시간에 걸쳐 분배하느냐의 차이인 것 같다. )